mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
SQ serverside refactoring, add tests, add Knip (#2665)
This commit is contained in:
parent
459d172989
commit
94a93b0006
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
|
|
@ -33,6 +33,8 @@ jobs:
|
|||
run: npm run typecheck
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
- name: Knip unused check
|
||||
run: npm run knip
|
||||
- name: Check translations jsons
|
||||
run: npm run check-translation-jsons:no-write
|
||||
- name: Check homemade badges
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@
|
|||
- library used for E2E testing is Playwright
|
||||
- `page.goto` is forbidden, use the `navigate` function to do a page navigation
|
||||
- to submit a form you use the `submit` function
|
||||
- `page.waitForTimeout` should be avoided
|
||||
|
||||
## Unit testing
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Link } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { GearType, Tables, UserWithPlusTier } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types";
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import clsx from "clsx";
|
||||
import type * as React from "react";
|
||||
|
||||
export function Details({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <details className={className}>{children}</details>;
|
||||
}
|
||||
|
||||
export function Summary({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <summary className={clsx("summary", className)}>{children}</summary>;
|
||||
}
|
||||
|
|
@ -89,7 +89,6 @@ export function FormWithConfirm({
|
|||
</div>
|
||||
</SendouDialog>
|
||||
{React.cloneElement(children, {
|
||||
// @ts-expect-error broke with @types/react upgrade. TODO: figure out narrower type than React.ReactNode
|
||||
onPress: openDialog,
|
||||
type: "button",
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
SendouSelectItemSection,
|
||||
} from "~/components/elements/Select";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import type { AnyWeapon } from "~/features/build-analyzer";
|
||||
import type { AnyWeapon } from "~/features/build-analyzer/analyzer-types";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { filterWeapon } from "~/modules/in-game-lists/utils";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
export function AdminIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
export function ArrowsPointingInIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import type { CSSProperties } from "react";
|
||||
|
||||
export function ChatIcon({
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.848 2.771A49.144 49.144 0 0112 2.25c2.43 0 4.817.178 7.152.52 1.978.292 3.348 2.024 3.348 3.97v6.02c0 1.946-1.37 3.678-3.348 3.97-1.94.284-3.916.455-5.922.505a.39.39 0 00-.266.112L8.78 21.53A.75.75 0 017.5 21v-3.955a48.842 48.842 0 01-2.652-.316c-1.978-.29-3.348-2.024-3.348-3.97V6.741c0-1.946 1.37-3.68 3.348-3.97z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
export function CheckInIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l2-2a1 1 0 00-1.414-1.414L11 7.586V3a1 1 0 10-2 0v4.586l-.293-.293z" />
|
||||
<path d="M3 5a2 2 0 012-2h1a1 1 0 010 2H5v7h2l1 2h4l1-2h2V5h-1a1 1 0 110-2h1a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
export function SuccessIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
export default function UndoIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.53 2.47a.75.75 0 010 1.06L4.81 8.25H15a6.75 6.75 0 010 13.5h-3a.75.75 0 010-1.5h3a5.25 5.25 0 100-10.5H4.81l4.72 4.72a.75.75 0 11-1.06 1.06l-6-6a.75.75 0 010-1.06l6-6a.75.75 0 011.06 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
export function UploadIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.233-2.33 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,23 +21,21 @@ import {
|
|||
} from "~/features/plus-voting/core";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import { addMember } from "~/features/sendouq/queries/addMember.server";
|
||||
import { createMatch } from "~/features/sendouq/queries/createMatch.server";
|
||||
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
|
||||
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
||||
import { calculateMatchSkills } from "~/features/sendouq-match/core/skills.server";
|
||||
import {
|
||||
summarizeMaps,
|
||||
summarizePlayerResults,
|
||||
} from "~/features/sendouq-match/core/summarizer.server";
|
||||
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import { winnersArrayToWinner } from "~/features/sendouq-match/q-match-utils";
|
||||
import { addMapResults } from "~/features/sendouq-match/queries/addMapResults.server";
|
||||
import { addPlayerResults } from "~/features/sendouq-match/queries/addPlayerResults.server";
|
||||
import { addReportedWeapons } from "~/features/sendouq-match/queries/addReportedWeapons.server";
|
||||
import { addSkills } from "~/features/sendouq-match/queries/addSkills.server";
|
||||
import { findMatchById } from "~/features/sendouq-match/queries/findMatchById.server";
|
||||
import { reportScore } from "~/features/sendouq-match/queries/reportScore.server";
|
||||
import { setGroupAsInactive } from "~/features/sendouq-match/queries/setGroupAsInactive.server";
|
||||
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
|
||||
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
|
||||
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
|
||||
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/sendouq-settings/q-settings-constants";
|
||||
|
|
@ -230,7 +228,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
|
|||
arts,
|
||||
commissionsOpen,
|
||||
playedMatches,
|
||||
groups,
|
||||
variation === "NO_SQ_GROUPS" ? undefined : groups,
|
||||
friendCodes,
|
||||
lfgPosts,
|
||||
variation === "NO_SCRIMS" ? undefined : scrimPosts,
|
||||
|
|
@ -2088,7 +2086,7 @@ async function groups() {
|
|||
users.push(NZAP_TEST_ID);
|
||||
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const group = await QRepository.createGroup({
|
||||
const group = await SQGroupRepository.createGroup({
|
||||
status: "ACTIVE",
|
||||
userId: users.pop()!,
|
||||
});
|
||||
|
|
@ -2198,15 +2196,14 @@ async function playedMatches() {
|
|||
// -> create groups
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const users = i === 0 ? [...groupAlphaMembers] : [...groupBravoMembers];
|
||||
const group = await QRepository.createGroup({
|
||||
const group = await SQGroupRepository.createGroup({
|
||||
status: "ACTIVE",
|
||||
userId: users.pop()!,
|
||||
});
|
||||
|
||||
// -> add regular members of groups
|
||||
for (let i = 0; i < 3; i++) {
|
||||
addMember({
|
||||
groupId: group.id,
|
||||
await SQGroupRepository.addMember(group.id, {
|
||||
userId: users.pop()!,
|
||||
});
|
||||
}
|
||||
|
|
@ -2220,7 +2217,7 @@ async function playedMatches() {
|
|||
|
||||
invariant(groupAlpha !== 0 && groupBravo !== 0, "groups not created");
|
||||
|
||||
const match = createMatch({
|
||||
const match = await SQMatchRepository.create({
|
||||
alphaGroupId: groupAlpha,
|
||||
bravoGroupId: groupBravo,
|
||||
mapList: randomMapList(groupAlpha, groupBravo),
|
||||
|
|
@ -2258,7 +2255,9 @@ async function playedMatches() {
|
|||
["ALPHA", "BRAVO", "ALPHA", "BRAVO", "BRAVO", "BRAVO"],
|
||||
]) as ("ALPHA" | "BRAVO")[];
|
||||
const winner = winnersArrayToWinner(winners);
|
||||
const finishedMatch = findMatchById(match.id)!;
|
||||
const finishedMatch = SendouQ.mapMatch(
|
||||
(await SQMatchRepository.findById(match.id))!,
|
||||
);
|
||||
|
||||
const { newSkills, differences } = calculateMatchSkills({
|
||||
groupMatchId: match.id,
|
||||
|
|
@ -2267,16 +2266,13 @@ async function playedMatches() {
|
|||
loserGroupId: winner === "ALPHA" ? groupBravo : groupAlpha,
|
||||
winnerGroupId: winner === "ALPHA" ? groupAlpha : groupBravo,
|
||||
});
|
||||
|
||||
const members = [
|
||||
...(await QMatchRepository.findGroupById({
|
||||
groupId: match.alphaGroupId,
|
||||
}))!.members.map((m) => ({
|
||||
...finishedMatch.groupAlpha.members.map((m) => ({
|
||||
...m,
|
||||
groupId: match.alphaGroupId,
|
||||
})),
|
||||
...(await QMatchRepository.findGroupById({
|
||||
groupId: match.alphaGroupId,
|
||||
}))!.members.map((m) => ({
|
||||
...finishedMatch.groupBravo.members.map((m) => ({
|
||||
...m,
|
||||
groupId: match.bravoGroupId,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import cron from "node-cron";
|
|||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||
import { config } from "~/modules/i18n/config"; // your i18n configuration file
|
||||
import i18next from "~/modules/i18n/i18next.server";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import { resources } from "./modules/i18n/resources.server";
|
||||
import { daily, everyHourAt00, everyHourAt30 } from "./routines/list.server";
|
||||
import { logger } from "./utils/logger";
|
||||
|
|
|
|||
|
|
@ -5,4 +5,5 @@ export const SEED_VARIATIONS = [
|
|||
"SMALL_SOS",
|
||||
"NZAP_IN_TEAM",
|
||||
"NO_SCRIMS",
|
||||
"NO_SQ_GROUPS",
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from "zod/v4";
|
|||
import { seed } from "~/db/seed";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
|
||||
import { SEED_VARIATIONS } from "~/features/api-private/constants";
|
||||
import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server";
|
||||
import { parseRequestPayload } from "~/utils/remix.server";
|
||||
|
||||
const seedSchema = z.object({
|
||||
|
|
@ -25,5 +26,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
|
||||
await seed(variation);
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { cors } from "remix-utils/cors";
|
||||
import * as ApiRepository from "~/features/api/ApiRepository.server";
|
||||
|
||||
export async function loadApiTokensCache() {
|
||||
async function loadApiTokensCache() {
|
||||
const envTokens = process.env.PUBLIC_API_TOKENS?.split(",") ?? [];
|
||||
const dbTokens = await ApiRepository.allApiTokens();
|
||||
return new Set([...envTokens, ...dbTokens]);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { json, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { cors } from "remix-utils/cors";
|
||||
import { z } from "zod/v4";
|
||||
import { findCurrentGroupByUserId } from "~/features/sendouq/queries/findCurrentGroupByUserId.server";
|
||||
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import {
|
||||
|
|
@ -23,7 +23,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
|||
schema: paramsSchema,
|
||||
});
|
||||
|
||||
const current = findCurrentGroupByUserId(userId);
|
||||
const current = SendouQ.findOwnGroup(userId);
|
||||
|
||||
const result: GetUsersActiveSendouqMatchResponse = {
|
||||
matchId: current?.matchId ?? null,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { json, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { cors } from "remix-utils/cors";
|
||||
import { z } from "zod/v4";
|
||||
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import i18next from "~/modules/i18n/i18next.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import {
|
||||
|
|
@ -25,19 +24,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
|||
schema: paramsSchema,
|
||||
});
|
||||
|
||||
const match = notFoundIfFalsy(await QMatchRepository.findById(matchId));
|
||||
|
||||
const [groupAlpha, groupBravo] = await Promise.all([
|
||||
QMatchRepository.findGroupById({
|
||||
groupId: match.alphaGroupId,
|
||||
}),
|
||||
QMatchRepository.findGroupById({
|
||||
groupId: match.bravoGroupId,
|
||||
}),
|
||||
]);
|
||||
|
||||
invariant(groupAlpha, "Group alpha not found");
|
||||
invariant(groupBravo, "Group bravo not found");
|
||||
const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId));
|
||||
|
||||
const t = await i18next.getFixedT("en", ["game-misc"]);
|
||||
|
||||
|
|
@ -56,7 +43,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
|||
(acc, cur) => {
|
||||
if (!cur.winnerGroupId) return acc;
|
||||
|
||||
if (cur.winnerGroupId === match.alphaGroupId) {
|
||||
if (cur.winnerGroupId === match.groupAlpha.id) {
|
||||
return [acc[0] + 1, acc[1]];
|
||||
}
|
||||
|
||||
|
|
@ -83,14 +70,14 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
|||
})),
|
||||
teamAlpha: {
|
||||
score: score[0],
|
||||
players: groupAlpha.members.map((member) => ({
|
||||
players: match.groupAlpha.members.map((member) => ({
|
||||
userId: member.id,
|
||||
rank: userIdToRank(member.id),
|
||||
})),
|
||||
},
|
||||
teamBravo: {
|
||||
score: score[1],
|
||||
players: groupBravo.members.map((member) => ({
|
||||
players: match.groupBravo.members.map((member) => ({
|
||||
userId: member.id,
|
||||
rank: userIdToRank(member.id),
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import * as TournamentRepository from "~/features/tournament/TournamentRepositor
|
|||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import { resolveMapList } from "~/features/tournament-bracket/core/mapList.server";
|
||||
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import i18next from "~/modules/i18n/i18next.server";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { z } from "zod/v4";
|
|||
import { db } from "~/db/sql";
|
||||
import { ordinalToSp } from "~/features/mmr/mmr-utils";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import i18next from "~/modules/i18n/i18next.server";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import { nullifyingAvg } from "~/utils/arrays";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
|
||||
|
|
|
|||
|
|
@ -43,12 +43,12 @@ export const editArtSchema = z.object({
|
|||
isShowcase: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
|
||||
});
|
||||
|
||||
export const deleteArtSchema = z.object({
|
||||
const deleteArtSchema = z.object({
|
||||
_action: _action("DELETE_ART"),
|
||||
id,
|
||||
});
|
||||
|
||||
export const unlinkArtSchema = z.object({
|
||||
const unlinkArtSchema = z.object({
|
||||
_action: _action("UNLINK_ART"),
|
||||
id,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import type { MetaFunction } from "@remix-run/node";
|
||||
import { Form, useLoaderData } from "@remix-run/react";
|
||||
import { Form, useFetcher, useLoaderData } from "@remix-run/react";
|
||||
import Compressor from "compressorjs";
|
||||
import { nanoid } from "nanoid";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher } from "react-router-dom";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { TablesInsertable, TablesUpdatable } from "~/db/tables";
|
||||
import type { TablesInsertable } from "~/db/tables";
|
||||
import type { AssociationVirtualIdentifier } from "~/features/associations/associations-constants";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
|
||||
|
|
@ -140,17 +140,6 @@ export function insert({ userId, ...associationArgs }: InsertArgs) {
|
|||
});
|
||||
}
|
||||
|
||||
export function update(
|
||||
associationId: number,
|
||||
args: Partial<TablesUpdatable["Association"]>,
|
||||
) {
|
||||
return db
|
||||
.updateTable("Association")
|
||||
.set(args)
|
||||
.where("id", "=", associationId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function refreshInviteCode(associationId: number) {
|
||||
return db
|
||||
.updateTable("Association")
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import type {
|
|||
} from "~/modules/in-game-lists/types";
|
||||
import type { DAMAGE_TYPE } from "./analyzer-constants";
|
||||
import type { SPECIAL_EFFECTS } from "./core/specialEffects";
|
||||
import type { abilityValues } from "./core/utils";
|
||||
import type { weaponParams } from "./core/weapon-params";
|
||||
|
||||
type Overwrites = Record<
|
||||
|
|
@ -288,8 +287,6 @@ export interface AnalyzedBuild {
|
|||
|
||||
export type SpecialEffectType = (typeof SPECIAL_EFFECTS)[number]["type"];
|
||||
|
||||
export type AbilityValuesKeys = keyof typeof abilityValues;
|
||||
|
||||
export type AnyWeapon =
|
||||
| { type: "MAIN"; id: MainWeaponId }
|
||||
| { type: "SUB"; id: SubWeaponId }
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
export { DAMAGE_TYPE, damageTypeToWeaponType } from "./analyzer-constants";
|
||||
export type {
|
||||
AbilityPoints,
|
||||
AnalyzedBuild,
|
||||
AnyWeapon,
|
||||
DamageType,
|
||||
SpecialWeaponParams,
|
||||
SubWeaponParams,
|
||||
} from "./analyzer-types";
|
||||
export {
|
||||
buildStats,
|
||||
specialDeviceHp,
|
||||
specialFieldHp,
|
||||
subStats,
|
||||
} from "./core/stats";
|
||||
export {
|
||||
buildToAbilityPoints,
|
||||
hpDivided,
|
||||
possibleApValues,
|
||||
serializeBuild,
|
||||
validatedAnyWeaponFromSearchParams,
|
||||
validatedBuildFromSearchParams,
|
||||
validatedWeaponIdFromSearchParams,
|
||||
} from "./core/utils";
|
||||
|
|
@ -89,23 +89,6 @@ function dbAbilitiesToArrayOfArrays(
|
|||
];
|
||||
}
|
||||
|
||||
export async function countByUserId({
|
||||
userId,
|
||||
showPrivate,
|
||||
}: {
|
||||
userId: number;
|
||||
showPrivate: boolean;
|
||||
}) {
|
||||
return (
|
||||
await db
|
||||
.selectFrom("Build")
|
||||
.select(({ fn }) => fn.countAll<number>().as("count"))
|
||||
.where("ownerId", "=", userId)
|
||||
.$if(!showPrivate, (qb) => qb.where("Build.private", "=", 0))
|
||||
.executeTakeFirstOrThrow()
|
||||
).count;
|
||||
}
|
||||
|
||||
interface CreateArgs {
|
||||
ownerId: TablesInsertable["Build"]["ownerId"];
|
||||
title: TablesInsertable["Build"]["title"];
|
||||
|
|
@ -119,7 +102,7 @@ interface CreateArgs {
|
|||
private: TablesInsertable["Build"]["private"];
|
||||
}
|
||||
|
||||
export async function createInTrx({
|
||||
async function createInTrx({
|
||||
args,
|
||||
trx,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Ability } from "~/components/Ability";
|
|||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { ModeImage } from "~/components/Image";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import { possibleApValues } from "~/features/build-analyzer";
|
||||
import { possibleApValues } from "~/features/build-analyzer/core/utils";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { abilities } from "~/modules/in-game-lists/abilities";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Tables } from "~/db/tables";
|
||||
import { buildToAbilityPoints } from "~/features/build-analyzer";
|
||||
import { buildToAbilityPoints } from "~/features/build-analyzer/core/utils";
|
||||
import type {
|
||||
BuildAbilitiesTuple,
|
||||
ModeShort,
|
||||
|
|
|
|||
|
|
@ -25,11 +25,9 @@ import {
|
|||
} from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
COMMON_USER_FIELDS,
|
||||
concatUserSubmittedImagePrefix,
|
||||
tournamentLogoWithDefault,
|
||||
} from "~/utils/kysely.server";
|
||||
import type { Unwrapped } from "~/utils/types";
|
||||
import { calendarEventPage, tournamentPage } from "~/utils/urls";
|
||||
import {
|
||||
modesIncluded,
|
||||
|
|
@ -278,76 +276,6 @@ function findAllBetweenTwoTimestampsMapped(
|
|||
return dates;
|
||||
}
|
||||
|
||||
export type ForShowcase = Unwrapped<typeof forShowcase>;
|
||||
|
||||
export function forShowcase() {
|
||||
return db
|
||||
.selectFrom("Tournament")
|
||||
.innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEvent.id",
|
||||
"CalendarEventDate.eventId",
|
||||
)
|
||||
.select((eb) => [
|
||||
"Tournament.id",
|
||||
"Tournament.settings",
|
||||
"CalendarEvent.authorId",
|
||||
"CalendarEvent.name",
|
||||
"CalendarEventDate.startTime",
|
||||
withTeamsCount(eb).as("teamsCount"),
|
||||
tournamentLogoWithDefault(eb).as("logoUrl"),
|
||||
withOrganization(eb).as("organization"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TournamentResult")
|
||||
.innerJoin("User", "TournamentResult.userId", "User.id")
|
||||
.innerJoin(
|
||||
"TournamentTeam",
|
||||
"TournamentResult.tournamentTeamId",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.leftJoin("AllTeam", "TournamentTeam.teamId", "AllTeam.id")
|
||||
.leftJoin(
|
||||
"UserSubmittedImage as TeamAvatar",
|
||||
"AllTeam.avatarImgId",
|
||||
"TeamAvatar.id",
|
||||
)
|
||||
.leftJoin(
|
||||
"UserSubmittedImage as TournamentTeamAvatar",
|
||||
"TournamentTeam.avatarImgId",
|
||||
"TournamentTeamAvatar.id",
|
||||
)
|
||||
.whereRef("TournamentResult.tournamentId", "=", "Tournament.id")
|
||||
.where("TournamentResult.placement", "=", 1)
|
||||
.select((eb) => [
|
||||
...COMMON_USER_FIELDS,
|
||||
"User.country",
|
||||
"TournamentTeam.name as teamName",
|
||||
concatUserSubmittedImagePrefix(eb.ref("TeamAvatar.url")).as(
|
||||
"teamLogoUrl",
|
||||
),
|
||||
concatUserSubmittedImagePrefix(
|
||||
eb.ref("TournamentTeamAvatar.url"),
|
||||
).as("pickupAvatarUrl"),
|
||||
]),
|
||||
).as("firstPlacers"),
|
||||
])
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.where("CalendarEventDate.startTime", ">", databaseTimestampWeekAgo())
|
||||
.orderBy("CalendarEventDate.startTime", "asc")
|
||||
.$narrowType<{ teamsCount: NotNull }>()
|
||||
.execute();
|
||||
}
|
||||
|
||||
function databaseTimestampWeekAgo() {
|
||||
const now = new Date();
|
||||
|
||||
now.setDate(now.getDate() - 7);
|
||||
|
||||
return dateToDatabaseTimestamp(now);
|
||||
}
|
||||
|
||||
export async function findById(
|
||||
id: number,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export function TagsFormField<T extends FieldValues>({
|
|||
);
|
||||
}
|
||||
|
||||
export const SelectableTags = React.forwardRef<
|
||||
const SelectableTags = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
selectedTags: Array<CalendarEventTag>;
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ async function cachedParticipationInfo(
|
|||
return participation.get(userId) ?? emptyParticipationInfo();
|
||||
}
|
||||
|
||||
export const SHOWCASE_TOURNAMENTS_CACHE_KEY = "front-tournaments-list";
|
||||
const SHOWCASE_TOURNAMENTS_CACHE_KEY = "front-tournaments-list";
|
||||
|
||||
export const clearCachedTournaments = () =>
|
||||
cache.delete(SHOWCASE_TOURNAMENTS_CACHE_KEY);
|
||||
|
|
|
|||
|
|
@ -136,13 +136,6 @@ export function filterByWeaponCategory<
|
|||
);
|
||||
}
|
||||
|
||||
export function addPlacementRank<T>(entries: T[]) {
|
||||
return entries.map((entry, index) => ({
|
||||
...entry,
|
||||
placementRank: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
export function ownEntryPeek({
|
||||
leaderboard,
|
||||
userId,
|
||||
|
|
|
|||
|
|
@ -4,22 +4,6 @@ export function seasonHasTopTen(season: number) {
|
|||
return !!playerData[season];
|
||||
}
|
||||
|
||||
export function playerTopTenData({
|
||||
season,
|
||||
userId,
|
||||
}: {
|
||||
season: number;
|
||||
userId: number;
|
||||
}) {
|
||||
for (const player of playerData[season] ?? []) {
|
||||
if (player.id === userId) {
|
||||
return player;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function playerTopTenPlacement({
|
||||
season,
|
||||
userId,
|
||||
|
|
|
|||
|
|
@ -58,23 +58,5 @@ export const TIERS_BEFORE_LEVIATHAN = [
|
|||
|
||||
export type TierName = (typeof TIERS)[number]["name"];
|
||||
|
||||
// won 4 in row vs. equally skilled opponents, about 1200SP
|
||||
export const DEFAULT_SKILL_HIGH = {
|
||||
mu: 34.970668845350744,
|
||||
sigma: 7.362186212527989,
|
||||
} as const;
|
||||
|
||||
// lost 4 in row vs. equally skilled opponents, about 900SP
|
||||
export const DEFAULT_SKILL_LOW = {
|
||||
mu: 15.02933115464926,
|
||||
sigma: 7.362186212527989,
|
||||
} as const;
|
||||
|
||||
// won 2, lost 2 vs. equally skilled opponents, about 1050SP
|
||||
export const DEFAULT_SKILL_MID = {
|
||||
mu: 25.189621801205735,
|
||||
sigma: 7.362186212527989,
|
||||
} as const;
|
||||
|
||||
export const USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN = 200;
|
||||
export const TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN = 100;
|
||||
|
|
|
|||
|
|
@ -10,10 +10,6 @@ export function ordinalToSp(ordinal: number) {
|
|||
return toTwoDecimals(ordinal * 15 + 1000);
|
||||
}
|
||||
|
||||
export function spToOrdinal(sp: number) {
|
||||
return (sp - 1000) / 15;
|
||||
}
|
||||
|
||||
export function ordinalToRoundedSp(ordinal: number) {
|
||||
return Math.round(ordinalToSp(ordinal));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import pLimit from "p-limit";
|
|||
import { WebPushError } from "web-push";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import type { NotificationSubscription } from "../../../db/tables";
|
||||
import i18next from "../../../modules/i18n/i18next.server";
|
||||
import { i18next } from "../../../modules/i18n/i18next.server";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import * as NotificationRepository from "../NotificationRepository.server";
|
||||
import type { Notification } from "../notifications-types";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import type {
|
||||
AnyWeapon,
|
||||
DamageType,
|
||||
} from "~/features/build-analyzer/analyzer-types";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import type { AnyWeapon, DamageType } from "../build-analyzer";
|
||||
import type { CombineWith } from "./calculator-types";
|
||||
import type objectDamages from "./core/object-dmg.json";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { useSearchParams } from "@remix-run/react";
|
||||
import {
|
||||
type AnalyzedBuild,
|
||||
buildStats,
|
||||
type DAMAGE_TYPE,
|
||||
type DamageType,
|
||||
possibleApValues,
|
||||
validatedAnyWeaponFromSearchParams,
|
||||
} from "~/features/build-analyzer";
|
||||
import { exampleMainWeaponIdWithSpecialWeaponId } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { assertType } from "~/utils/types";
|
||||
import type { AnyWeapon } from "../build-analyzer/analyzer-types";
|
||||
import type { DAMAGE_TYPE } from "../build-analyzer/analyzer-constants";
|
||||
import type {
|
||||
AnalyzedBuild,
|
||||
AnyWeapon,
|
||||
DamageType,
|
||||
} from "../build-analyzer/analyzer-types";
|
||||
import { buildStats } from "../build-analyzer/core/stats";
|
||||
import {
|
||||
possibleApValues,
|
||||
validatedAnyWeaponFromSearchParams,
|
||||
} from "../build-analyzer/core/utils";
|
||||
import {
|
||||
calculateDamage,
|
||||
resolveAllUniqueDamageTypes,
|
||||
|
|
@ -99,7 +101,7 @@ function validatedMultiShotFromSearchParams(searchParams: URLSearchParams) {
|
|||
return searchParams.get(MULTI_SHOT_SP_KEY) !== "false";
|
||||
}
|
||||
|
||||
export const damageTypePriorityList = [
|
||||
const damageTypePriorityList = [
|
||||
"TURRET_MAX",
|
||||
"TURRET_MIN",
|
||||
"DIRECT_MAX",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { DamageType } from "../build-analyzer";
|
||||
import type { DamageType } from "~/features/build-analyzer/analyzer-types";
|
||||
import type { DAMAGE_RECEIVERS } from "./calculator-constants";
|
||||
|
||||
export type DamageReceiver = (typeof DAMAGE_RECEIVERS)[number];
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import type {
|
|||
AbilityPoints,
|
||||
AnalyzedBuild,
|
||||
DamageType,
|
||||
} from "~/features/build-analyzer";
|
||||
import { buildStats } from "~/features/build-analyzer";
|
||||
} from "~/features/build-analyzer/analyzer-types";
|
||||
import { buildStats } from "~/features/build-analyzer/core/stats";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import type {
|
|||
AbilityPoints,
|
||||
AnalyzedBuild,
|
||||
AnyWeapon,
|
||||
Damage,
|
||||
DamageType,
|
||||
} from "~/features/build-analyzer";
|
||||
import type { Damage } from "~/features/build-analyzer/analyzer-types";
|
||||
} from "~/features/build-analyzer/analyzer-types";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
|
|
@ -23,7 +23,7 @@ import type { CombineWith, DamageReceiver } from "../calculator-types";
|
|||
import objectDamages from "./object-dmg.json";
|
||||
import { objectHitPoints } from "./objectHitPoints";
|
||||
|
||||
export function damageTypeToMultipliers({
|
||||
function damageTypeToMultipliers({
|
||||
type,
|
||||
weapon,
|
||||
}: {
|
||||
|
|
@ -104,7 +104,7 @@ function resolveRelevantKey({
|
|||
);
|
||||
}
|
||||
|
||||
export function multipliersToRecordWithFallbacks(
|
||||
function multipliersToRecordWithFallbacks(
|
||||
multipliers: ReturnType<typeof damageTypeToMultipliers>,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import type {
|
||||
AbilityPoints,
|
||||
SpecialWeaponParams,
|
||||
SubWeaponParams,
|
||||
} from "~/features/build-analyzer/analyzer-types";
|
||||
import {
|
||||
type AbilityPoints,
|
||||
hpDivided,
|
||||
type SpecialWeaponParams,
|
||||
type SubWeaponParams,
|
||||
specialDeviceHp,
|
||||
specialFieldHp,
|
||||
subStats,
|
||||
} from "~/features/build-analyzer";
|
||||
} from "~/features/build-analyzer/core/stats";
|
||||
import { hpDivided } from "~/features/build-analyzer/core/utils";
|
||||
import { weaponParams } from "~/features/build-analyzer/core/weapon-params";
|
||||
import {
|
||||
BIG_BUBBLER_ID,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import { Ability } from "~/components/Ability";
|
|||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import type { DamageType } from "~/features/build-analyzer";
|
||||
import { possibleApValues } from "~/features/build-analyzer";
|
||||
import {
|
||||
BIG_BUBBLER_ID,
|
||||
BOOYAH_BOMB_ID,
|
||||
|
|
@ -38,6 +36,8 @@ import "../calculator.css";
|
|||
import type { MetaFunction } from "@remix-run/node";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||
import type { DamageType } from "~/features/build-analyzer/analyzer-types";
|
||||
import { possibleApValues } from "~/features/build-analyzer/core/utils";
|
||||
import { roundToNDecimalPlaces } from "~/utils/number";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { assertType } from "~/utils/types";
|
|||
import { safeJSONParse } from "~/utils/zod";
|
||||
import { PLUS_DOWNVOTE, PLUS_UPVOTE } from "./plus-voting-constants";
|
||||
|
||||
export const voteSchema = z.object({
|
||||
const voteSchema = z.object({
|
||||
votedId: z.number(),
|
||||
score: z.number().refine((val) => [PLUS_DOWNVOTE, PLUS_UPVOTE].includes(val)),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { scrimsPage } from "~/utils/urls";
|
||||
import * as QRepository from "../../sendouq/QRepository.server";
|
||||
import * as SQGroupRepository from "../../sendouq/SQGroupRepository.server";
|
||||
import * as TeamRepository from "../../team/TeamRepository.server";
|
||||
import * as ScrimPostRepository from "../ScrimPostRepository.server";
|
||||
import { SCRIM } from "../scrims-constants";
|
||||
|
|
@ -161,7 +161,7 @@ async function validatePickup(userIds: number[], authorId: number) {
|
|||
async function validatePickupTrust(userIds: number[], authorId: number) {
|
||||
const unconsentingUsers: string[] = [];
|
||||
|
||||
const trustedBy = await QRepository.usersThatTrusted(authorId);
|
||||
const trustedBy = await SQGroupRepository.usersThatTrusted(authorId);
|
||||
|
||||
for (const userId of userIds) {
|
||||
const user = await UserRepository.findLeanById(userId);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
import { associationIdentifierSchema } from "../associations/associations-schemas";
|
||||
import { LUTI_DIVS, SCRIM } from "./scrims-constants";
|
||||
|
||||
export const deletePostSchema = z.object({
|
||||
const deletePostSchema = z.object({
|
||||
_action: _action("DELETE_POST"),
|
||||
scrimPostId: id,
|
||||
});
|
||||
|
|
@ -47,12 +47,12 @@ export const newRequestSchema = z.object({
|
|||
at: z.preprocess(date, z.date()).nullish(),
|
||||
});
|
||||
|
||||
export const acceptRequestSchema = z.object({
|
||||
const acceptRequestSchema = z.object({
|
||||
_action: _action("ACCEPT_REQUEST"),
|
||||
scrimPostRequestId: id,
|
||||
});
|
||||
|
||||
export const cancelRequestSchema = z.object({
|
||||
const cancelRequestSchema = z.object({
|
||||
_action: _action("CANCEL_REQUEST"),
|
||||
scrimPostRequestId: id,
|
||||
});
|
||||
|
|
@ -109,7 +109,7 @@ export const scrimsFiltersSearchParamsObject = z.object({
|
|||
.catch({ weekdayTimes: null, weekendTimes: null, divs: null }),
|
||||
});
|
||||
|
||||
export const persistScrimFiltersSchema = z.object({
|
||||
const persistScrimFiltersSchema = z.object({
|
||||
_action: _action("PERSIST_SCRIM_FILTERS"),
|
||||
filters: scrimsFiltersSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,18 +1,15 @@
|
|||
import { add } from "date-fns";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import type { ExpressionBuilder, NotNull, Transaction } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type {
|
||||
DB,
|
||||
ParsedMemento,
|
||||
QWeaponPool,
|
||||
Tables,
|
||||
UserSkillDifference,
|
||||
} from "~/db/tables";
|
||||
import type { DB, ParsedMemento } from "~/db/tables";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
|
||||
import { mostPopularArrayElement } from "~/utils/arrays";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
COMMON_USER_FIELDS,
|
||||
concatUserSubmittedImagePrefix,
|
||||
|
|
@ -20,15 +17,14 @@ import {
|
|||
userChatNameColor,
|
||||
} from "~/utils/kysely.server";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { FULL_GROUP_SIZE } from "../sendouq/q-constants";
|
||||
import { MATCHES_PER_SEASONS_PAGE } from "../user-page/user-page-constants";
|
||||
|
||||
export function findById(id: number) {
|
||||
return db
|
||||
export async function findById(id: number) {
|
||||
const result = await db
|
||||
.selectFrom("GroupMatch")
|
||||
.select(({ exists, selectFrom, eb }) => [
|
||||
"GroupMatch.id",
|
||||
"GroupMatch.alphaGroupId",
|
||||
"GroupMatch.bravoGroupId",
|
||||
"GroupMatch.createdAt",
|
||||
"GroupMatch.reportedAt",
|
||||
"GroupMatch.reportedByUserId",
|
||||
|
|
@ -52,146 +48,81 @@ export function findById(id: number) {
|
|||
.where("GroupMatchMap.matchId", "=", id)
|
||||
.orderBy("GroupMatchMap.index", "asc"),
|
||||
).as("mapList"),
|
||||
groupWithTeamAndMembers(eb, "GroupMatch.alphaGroupId").as("groupAlpha"),
|
||||
groupWithTeamAndMembers(eb, "GroupMatch.bravoGroupId").as("groupBravo"),
|
||||
])
|
||||
.where("GroupMatch.id", "=", id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
export interface GroupForMatch {
|
||||
id: Tables["Group"]["id"];
|
||||
chatCode: Tables["Group"]["chatCode"];
|
||||
tier?: ParsedMemento["groups"][number]["tier"];
|
||||
skillDifference?: ParsedMemento["groups"][number]["skillDifference"];
|
||||
team?: {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
customUrl: string;
|
||||
};
|
||||
members: Array<{
|
||||
id: Tables["GroupMember"]["userId"];
|
||||
discordId: Tables["User"]["discordId"];
|
||||
username: Tables["User"]["username"];
|
||||
discordAvatar: Tables["User"]["discordAvatar"];
|
||||
role: Tables["GroupMember"]["role"];
|
||||
customUrl: Tables["User"]["customUrl"];
|
||||
inGameName: Tables["User"]["inGameName"];
|
||||
weapons: Array<QWeaponPool>;
|
||||
chatNameColor: string | null;
|
||||
vc: Tables["User"]["vc"];
|
||||
languages: string[];
|
||||
skillDifference?: UserSkillDifference;
|
||||
friendCode?: string;
|
||||
privateNote: Pick<
|
||||
Tables["PrivateUserNote"],
|
||||
"sentiment" | "text" | "updatedAt"
|
||||
> | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function findGroupById({
|
||||
loggedInUserId,
|
||||
groupId,
|
||||
}: {
|
||||
groupId: number;
|
||||
loggedInUserId?: number;
|
||||
}) {
|
||||
const row = await db
|
||||
.selectFrom("Group")
|
||||
.leftJoin("GroupMatch", (join) =>
|
||||
join.on((eb) =>
|
||||
eb.or([
|
||||
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
|
||||
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.select(({ eb }) => [
|
||||
"Group.id",
|
||||
"Group.chatCode",
|
||||
"GroupMatch.memento",
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("AllTeam")
|
||||
.leftJoin(
|
||||
"UserSubmittedImage",
|
||||
"AllTeam.avatarImgId",
|
||||
"UserSubmittedImage.id",
|
||||
)
|
||||
.select((eb) => [
|
||||
"AllTeam.name",
|
||||
"AllTeam.customUrl",
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"avatarUrl",
|
||||
),
|
||||
])
|
||||
.where("AllTeam.id", "=", eb.ref("Group.teamId")),
|
||||
).as("team"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("User", "User.id", "GroupMember.userId")
|
||||
.select((arrayEb) => [
|
||||
...COMMON_USER_FIELDS,
|
||||
"GroupMember.role",
|
||||
"User.inGameName",
|
||||
"User.vc",
|
||||
"User.languages",
|
||||
"User.qWeaponPool as weapons",
|
||||
arrayEb
|
||||
.selectFrom("UserFriendCode")
|
||||
.select("UserFriendCode.friendCode")
|
||||
.whereRef("UserFriendCode.userId", "=", "User.id")
|
||||
.orderBy("UserFriendCode.createdAt", "desc")
|
||||
.limit(1)
|
||||
.as("friendCode"),
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("PrivateUserNote")
|
||||
.select([
|
||||
"PrivateUserNote.sentiment",
|
||||
"PrivateUserNote.text",
|
||||
"PrivateUserNote.updatedAt",
|
||||
])
|
||||
.where("authorId", "=", loggedInUserId ?? -1)
|
||||
.where("targetId", "=", arrayEb.ref("User.id")),
|
||||
).as("privateNote"),
|
||||
userChatNameColor,
|
||||
])
|
||||
.where("GroupMember.groupId", "=", groupId)
|
||||
.orderBy("GroupMember.userId", "asc"),
|
||||
).as("members"),
|
||||
])
|
||||
.where("Group.id", "=", groupId)
|
||||
.$narrowType<{
|
||||
groupAlpha: NotNull;
|
||||
groupBravo: NotNull;
|
||||
}>()
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) return null;
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
chatCode: row.chatCode,
|
||||
tier: row.memento?.groups[row.id]?.tier,
|
||||
skillDifference: row.memento?.groups[row.id]?.skillDifference,
|
||||
team: row.team,
|
||||
members: row.members.map((m) => ({
|
||||
...m,
|
||||
languages: m.languages ? m.languages.split(",") : [],
|
||||
plusTier: row.memento?.users[m.id]?.plusTier,
|
||||
skill: row.memento?.users[m.id]?.skill,
|
||||
skillDifference: row.memento?.users[m.id]?.skillDifference,
|
||||
})),
|
||||
} as GroupForMatch;
|
||||
invariant(result.groupAlpha, `Group alpha not found for match ${id}`);
|
||||
invariant(result.groupBravo, `Group bravo not found for match ${id}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function groupMembersNoScreenSettings(groups: GroupForMatch[]) {
|
||||
return db
|
||||
.selectFrom("User")
|
||||
.select("User.noScreen")
|
||||
.where(
|
||||
"User.id",
|
||||
"in",
|
||||
groups.flatMap((group) => group.members.map((member) => member.id)),
|
||||
)
|
||||
.execute();
|
||||
function groupWithTeamAndMembers(
|
||||
eb: ExpressionBuilder<DB, "GroupMatch">,
|
||||
groupIdRef: "GroupMatch.alphaGroupId" | "GroupMatch.bravoGroupId",
|
||||
) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("Group")
|
||||
.select(({ eb }) => [
|
||||
"Group.id",
|
||||
"Group.chatCode",
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("AllTeam")
|
||||
.leftJoin(
|
||||
"UserSubmittedImage",
|
||||
"AllTeam.avatarImgId",
|
||||
"UserSubmittedImage.id",
|
||||
)
|
||||
.select((eb) => [
|
||||
"AllTeam.name",
|
||||
"AllTeam.customUrl",
|
||||
concatUserSubmittedImagePrefix(
|
||||
eb.ref("UserSubmittedImage.url"),
|
||||
).as("avatarUrl"),
|
||||
])
|
||||
.where("AllTeam.id", "=", eb.ref("Group.teamId")),
|
||||
).as("team"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("User", "User.id", "GroupMember.userId")
|
||||
.leftJoin("PlusTier", "User.id", "PlusTier.userId")
|
||||
.select((arrayEb) => [
|
||||
...COMMON_USER_FIELDS,
|
||||
"GroupMember.role",
|
||||
"User.inGameName",
|
||||
"User.vc",
|
||||
"User.languages",
|
||||
"User.noScreen",
|
||||
"User.qWeaponPool as weapons",
|
||||
"User.mapModePreferences",
|
||||
"PlusTier.tier as plusTier",
|
||||
arrayEb
|
||||
.selectFrom("UserFriendCode")
|
||||
.select("UserFriendCode.friendCode")
|
||||
.whereRef("UserFriendCode.userId", "=", "User.id")
|
||||
.orderBy("UserFriendCode.createdAt", "desc")
|
||||
.limit(1)
|
||||
.as("friendCode"),
|
||||
userChatNameColor,
|
||||
])
|
||||
.whereRef("GroupMember.groupId", "=", groupIdRef)
|
||||
.orderBy("GroupMember.userId", "asc"),
|
||||
).as("members"),
|
||||
])
|
||||
.where("Group.id", "=", eb.ref(groupIdRef)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -440,3 +371,118 @@ export async function seasonCanceledMatchesByUserId({
|
|||
.orderBy("GroupMatch.createdAt", "desc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function create({
|
||||
alphaGroupId,
|
||||
bravoGroupId,
|
||||
mapList,
|
||||
memento,
|
||||
}: {
|
||||
alphaGroupId: number;
|
||||
bravoGroupId: number;
|
||||
mapList: TournamentMapListMap[];
|
||||
memento: ParsedMemento;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const match = await trx
|
||||
.insertInto("GroupMatch")
|
||||
.values({
|
||||
alphaGroupId,
|
||||
bravoGroupId,
|
||||
chatCode: shortNanoid(),
|
||||
memento: JSON.stringify(memento),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.insertInto("GroupMatchMap")
|
||||
.values(
|
||||
mapList.map((map, i) => ({
|
||||
matchId: match.id,
|
||||
index: i,
|
||||
mode: map.mode,
|
||||
stageId: map.stageId,
|
||||
source: String(map.source),
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await syncGroupTeamId(alphaGroupId, trx);
|
||||
await syncGroupTeamId(bravoGroupId, trx);
|
||||
|
||||
await validateCreatedMatch(trx, alphaGroupId, bravoGroupId);
|
||||
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
async function syncGroupTeamId(groupId: number, trx: Transaction<DB>) {
|
||||
const members = await trx
|
||||
.selectFrom("GroupMember")
|
||||
.leftJoin(
|
||||
"TeamMemberWithSecondary",
|
||||
"TeamMemberWithSecondary.userId",
|
||||
"GroupMember.userId",
|
||||
)
|
||||
.select(["TeamMemberWithSecondary.teamId"])
|
||||
.where("GroupMember.groupId", "=", groupId)
|
||||
.execute();
|
||||
|
||||
const teamIds = members.map((m) => m.teamId).filter((id) => id !== null);
|
||||
|
||||
const counts = new Map<number, number>();
|
||||
|
||||
for (const teamId of teamIds) {
|
||||
const newCount = (counts.get(teamId) ?? 0) + 1;
|
||||
if (newCount === 4) {
|
||||
await trx
|
||||
.updateTable("Group")
|
||||
.set({ teamId })
|
||||
.where("id", "=", groupId)
|
||||
.execute();
|
||||
return;
|
||||
}
|
||||
|
||||
counts.set(teamId, newCount);
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("Group")
|
||||
.set({ teamId: null })
|
||||
.where("id", "=", groupId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function validateCreatedMatch(
|
||||
trx: Transaction<DB>,
|
||||
alphaGroupId: number,
|
||||
bravoGroupId: number,
|
||||
) {
|
||||
for (const groupId of [alphaGroupId, bravoGroupId]) {
|
||||
const members = await trx
|
||||
.selectFrom("GroupMember")
|
||||
.select("GroupMember.userId")
|
||||
.where("GroupMember.groupId", "=", groupId)
|
||||
.execute();
|
||||
|
||||
if (members.length !== FULL_GROUP_SIZE) {
|
||||
throw new Error(`Group ${groupId} does not have full group members`);
|
||||
}
|
||||
|
||||
const matches = await trx
|
||||
.selectFrom("GroupMatch")
|
||||
.select("GroupMatch.id")
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("GroupMatch.alphaGroupId", "=", groupId),
|
||||
eb("GroupMatch.bravoGroupId", "=", groupId),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Group ${groupId} is already in a match`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,13 @@ import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
|||
import type { ChatMessage } from "~/features/chat/chat-types";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { refreshUserSkills } from "~/features/mmr/tiered.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import { findCurrentGroupByUserId } from "~/features/sendouq/queries/findCurrentGroupByUserId.server";
|
||||
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import {
|
||||
refreshSendouQInstance,
|
||||
SendouQ,
|
||||
} from "~/features/sendouq/core/SendouQ.server";
|
||||
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
|
||||
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
||||
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
|
||||
import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
|
|
@ -72,7 +76,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||
})();
|
||||
};
|
||||
|
||||
const match = notFoundIfFalsy(findMatchById(matchId));
|
||||
const unmappedMatch = notFoundIfFalsy(
|
||||
await SQMatchRepository.findById(matchId),
|
||||
);
|
||||
const match = SendouQ.mapMatch(unmappedMatch, user);
|
||||
if (match.isLocked) {
|
||||
reportWeapons();
|
||||
return null;
|
||||
|
|
@ -83,17 +90,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||
"Only mods can report scores as admin",
|
||||
);
|
||||
const members = [
|
||||
...(await QMatchRepository.findGroupById({
|
||||
groupId: match.alphaGroupId,
|
||||
}))!.members.map((m) => ({
|
||||
...match.groupAlpha.members.map((m) => ({
|
||||
...m,
|
||||
groupId: match.alphaGroupId,
|
||||
groupId: match.groupAlpha.id,
|
||||
})),
|
||||
...(await QMatchRepository.findGroupById({
|
||||
groupId: match.bravoGroupId,
|
||||
}))!.members.map((m) => ({
|
||||
...match.groupBravo.members.map((m) => ({
|
||||
...m,
|
||||
groupId: match.bravoGroupId,
|
||||
groupId: match.groupBravo.id,
|
||||
})),
|
||||
];
|
||||
|
||||
|
|
@ -105,9 +108,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||
|
||||
const winner = winnersArrayToWinner(data.winners);
|
||||
const winnerGroupId =
|
||||
winner === "ALPHA" ? match.alphaGroupId : match.bravoGroupId;
|
||||
winner === "ALPHA" ? match.groupAlpha.id : match.groupBravo.id;
|
||||
const loserGroupId =
|
||||
winner === "ALPHA" ? match.bravoGroupId : match.alphaGroupId;
|
||||
winner === "ALPHA" ? match.groupBravo.id : match.groupAlpha.id;
|
||||
|
||||
// when admin reports match gets locked right away
|
||||
const compared = data.adminReport
|
||||
|
|
@ -133,12 +136,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||
compared === "SAME" && !matchIsBeingCanceled
|
||||
? calculateMatchSkills({
|
||||
groupMatchId: match.id,
|
||||
winner: (await QMatchRepository.findGroupById({
|
||||
groupId: winnerGroupId,
|
||||
}))!.members.map((m) => m.id),
|
||||
loser: (await QMatchRepository.findGroupById({
|
||||
groupId: loserGroupId,
|
||||
}))!.members.map((m) => m.id),
|
||||
winner: (match.groupAlpha.id === winnerGroupId
|
||||
? match.groupAlpha
|
||||
: match.groupBravo
|
||||
).members.map((m) => m.id),
|
||||
loser: (match.groupAlpha.id === loserGroupId
|
||||
? match.groupAlpha
|
||||
: match.groupBravo
|
||||
).members.map((m) => m.id),
|
||||
winnerGroupId,
|
||||
loserGroupId,
|
||||
})
|
||||
|
|
@ -188,8 +193,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||
}
|
||||
// admin reporting, just set both groups inactive
|
||||
if (data.adminReport) {
|
||||
setGroupAsInactive(match.alphaGroupId);
|
||||
setGroupAsInactive(match.bravoGroupId);
|
||||
setGroupAsInactive(match.groupAlpha.id);
|
||||
setGroupAsInactive(match.groupBravo.id);
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
@ -216,6 +221,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||
// in a different transaction but it's okay
|
||||
reportWeapons();
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
if (match.chatCode) {
|
||||
const type = (): NonNullable<ChatMessage["type"]> => {
|
||||
if (compared === "SAME") {
|
||||
|
|
@ -242,13 +249,20 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||
const season = Seasons.current();
|
||||
errorToastIfFalsy(season, "Season is not active");
|
||||
|
||||
const previousGroup = await QMatchRepository.findGroupById({
|
||||
groupId: data.previousGroupId,
|
||||
});
|
||||
errorToastIfFalsy(previousGroup, "Previous group not found");
|
||||
const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId));
|
||||
const previousGroup =
|
||||
match.groupAlpha.id === data.previousGroupId
|
||||
? match.groupAlpha
|
||||
: match.groupBravo.id === data.previousGroupId
|
||||
? match.groupBravo
|
||||
: null;
|
||||
errorToastIfFalsy(
|
||||
previousGroup,
|
||||
"Previous group not found in this match",
|
||||
);
|
||||
|
||||
for (const member of previousGroup.members) {
|
||||
const currentGroup = findCurrentGroupByUserId(member.id);
|
||||
const currentGroup = SendouQ.findOwnGroup(member.id);
|
||||
errorToastIfFalsy(!currentGroup, "Member is already in a group");
|
||||
if (member.id === user.id) {
|
||||
errorToastIfFalsy(
|
||||
|
|
@ -258,11 +272,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||
}
|
||||
}
|
||||
|
||||
await QRepository.createGroupFromPrevious({
|
||||
await SQGroupRepository.createGroupFromPrevious({
|
||||
previousGroupId: data.previousGroupId,
|
||||
members: previousGroup.members.map((m) => ({ id: m.id, role: m.role })),
|
||||
});
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
throw redirect(SENDOUQ_PREPARING_PAGE);
|
||||
}
|
||||
case "REPORT_WEAPONS": {
|
||||
|
|
@ -287,7 +303,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||
break;
|
||||
}
|
||||
case "ADD_PRIVATE_USER_NOTE": {
|
||||
await QRepository.upsertPrivateUserNote({
|
||||
await PrivateUserNoteRepository.upsert({
|
||||
authorId: user.id,
|
||||
sentiment: data.sentiment,
|
||||
targetId: data.targetId,
|
||||
|
|
|
|||
|
|
@ -6,16 +6,16 @@ import { FormMessage } from "~/components/FormMessage";
|
|||
import { Label } from "~/components/Label";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { SQMatchGroup } from "~/features/sendouq/core/SendouQ.server";
|
||||
import { SENDOUQ } from "~/features/sendouq/q-constants";
|
||||
import { preferenceEmojiUrl } from "~/utils/urls";
|
||||
import type { GroupForMatch } from "../QMatchRepository.server";
|
||||
|
||||
export function AddPrivateNoteDialog({
|
||||
aboutUser,
|
||||
close,
|
||||
}: {
|
||||
aboutUser?: Pick<
|
||||
GroupForMatch["members"][number],
|
||||
SQMatchGroup["members"][number],
|
||||
"id" | "username" | "privateNote"
|
||||
>;
|
||||
close: () => void;
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
|||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import { getDefaultMapWeights } from "~/features/sendouq/core/default-maps.server";
|
||||
import { addSkillsToGroups } from "~/features/sendouq/core/groups.server";
|
||||
import type {
|
||||
SQMatch,
|
||||
SQUncensoredGroup,
|
||||
} from "~/features/sendouq/core/SendouQ.server";
|
||||
import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants";
|
||||
import type { LookingGroupWithInviteCode } from "~/features/sendouq/q-types";
|
||||
import {
|
||||
BANNED_MAPS,
|
||||
SENDOUQ_MAP_POOL,
|
||||
|
|
@ -20,7 +22,6 @@ import type {
|
|||
} from "~/modules/tournament-map-list-generator/types";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { averageArray } from "~/utils/number";
|
||||
import type { MatchById } from "../queries/findMatchById.server";
|
||||
|
||||
type WeightsMap = Map<string, number>;
|
||||
|
||||
|
|
@ -312,7 +313,7 @@ export function compareMatchToReportedScores({
|
|||
newReporterGroupId,
|
||||
previousReporterGroupId,
|
||||
}: {
|
||||
match: MatchById;
|
||||
match: SQMatch;
|
||||
winners: ("ALPHA" | "BRAVO")[];
|
||||
newReporterGroupId: number;
|
||||
previousReporterGroupId?: number;
|
||||
|
|
@ -342,7 +343,7 @@ export function compareMatchToReportedScores({
|
|||
if (newWinner && !previousWinnerGroupId) return differentConstant;
|
||||
|
||||
const previousWinner =
|
||||
previousWinnerGroupId === match.alphaGroupId ? "ALPHA" : "BRAVO";
|
||||
previousWinnerGroupId === match.groupAlpha.id ? "ALPHA" : "BRAVO";
|
||||
|
||||
if (previousWinner !== newWinner) return differentConstant;
|
||||
}
|
||||
|
|
@ -355,11 +356,11 @@ export function compareMatchToReportedScores({
|
|||
|
||||
type CreateMatchMementoArgs = {
|
||||
own: {
|
||||
group: LookingGroupWithInviteCode;
|
||||
group: SQUncensoredGroup;
|
||||
preferences: { userId: number; preferences: UserMapModePreferences }[];
|
||||
};
|
||||
their: {
|
||||
group: LookingGroupWithInviteCode;
|
||||
group: SQUncensoredGroup;
|
||||
preferences: { userId: number; preferences: UserMapModePreferences }[];
|
||||
};
|
||||
mapList: TournamentMapListMap[];
|
||||
|
|
@ -368,17 +369,9 @@ export function createMatchMemento(
|
|||
args: CreateMatchMementoArgs,
|
||||
): Omit<ParsedMemento, "mapPreferences"> {
|
||||
const skills = userSkills(Seasons.currentOrPrevious()!.nth);
|
||||
const withTiers = addSkillsToGroups({
|
||||
groups: {
|
||||
neutral: [],
|
||||
likesReceived: [args.their.group],
|
||||
own: args.own.group,
|
||||
},
|
||||
...skills,
|
||||
});
|
||||
|
||||
const ownWithTier = withTiers.own;
|
||||
const theirWithTier = withTiers.likesReceived[0];
|
||||
const ownWithTier = args.own.group;
|
||||
const theirWithTier = args.their.group;
|
||||
|
||||
return {
|
||||
modePreferences: modePreferencesMemento(args),
|
||||
|
|
@ -401,7 +394,7 @@ export function createMatchMemento(
|
|||
[ownWithTier, theirWithTier].map((group) => [
|
||||
group!.id,
|
||||
{
|
||||
tier: group!.tier,
|
||||
tier: group!.tier!,
|
||||
},
|
||||
]),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { GroupForMatch } from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import type { SQMatchGroup } from "~/features/sendouq/core/SendouQ.server";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import type { MatchById } from "../queries/findMatchById.server";
|
||||
import type { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server";
|
||||
|
|
@ -67,8 +67,8 @@ export function reportedWeaponsToArrayOfArrays({
|
|||
}: {
|
||||
reportedWeapons: ReturnType<typeof reportedWeaponsByMatchId>;
|
||||
mapList: MatchById["mapList"];
|
||||
groupAlpha: GroupForMatch;
|
||||
groupBravo: GroupForMatch;
|
||||
groupAlpha: SQMatchGroup;
|
||||
groupBravo: SQMatchGroup;
|
||||
}) {
|
||||
if (!reportedWeapons) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import type { Tables } from "~/db/tables";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import type { SQMatch } from "~/features/sendouq/core/SendouQ.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { winnersArrayToWinner } from "../q-match-utils";
|
||||
import type { MatchById } from "../queries/findMatchById.server";
|
||||
|
||||
export function summarizeMaps({
|
||||
match,
|
||||
winners,
|
||||
members,
|
||||
}: {
|
||||
match: MatchById;
|
||||
match: SQMatch;
|
||||
winners: ("ALPHA" | "BRAVO")[];
|
||||
members: { id: number; groupId: number }[];
|
||||
}) {
|
||||
|
|
@ -23,7 +23,7 @@ export function summarizeMaps({
|
|||
for (const [i, map] of playedMaps.entries()) {
|
||||
const winnerSide = winners[i];
|
||||
const winnerGroupId =
|
||||
winnerSide === "ALPHA" ? match.alphaGroupId : match.bravoGroupId;
|
||||
winnerSide === "ALPHA" ? match.groupAlpha.id : match.groupBravo.id;
|
||||
|
||||
const winnerPlayers = members.filter((p) => p.groupId === winnerGroupId);
|
||||
const loserPlayers = members.filter((p) => p.groupId !== winnerGroupId);
|
||||
|
|
@ -59,7 +59,7 @@ export function summarizePlayerResults({
|
|||
winners,
|
||||
members,
|
||||
}: {
|
||||
match: MatchById;
|
||||
match: SQMatch;
|
||||
winners: ("ALPHA" | "BRAVO")[];
|
||||
members: { id: number; groupId: number }[];
|
||||
}) {
|
||||
|
|
@ -108,8 +108,8 @@ export function summarizePlayerResults({
|
|||
const type = member.groupId === member2.groupId ? "MATE" : "ENEMY";
|
||||
const won =
|
||||
winner === "ALPHA"
|
||||
? member.groupId === match.alphaGroupId
|
||||
: member.groupId === match.bravoGroupId;
|
||||
? member.groupId === match.groupAlpha.id
|
||||
: member.groupId === match.groupBravo.id;
|
||||
|
||||
addMapResult({
|
||||
ownerUserId: member.id,
|
||||
|
|
@ -130,8 +130,8 @@ export function summarizePlayerResults({
|
|||
const type = member.groupId === member2.groupId ? "MATE" : "ENEMY";
|
||||
const won =
|
||||
winner === "ALPHA"
|
||||
? member.groupId === match.alphaGroupId
|
||||
: member.groupId === match.bravoGroupId;
|
||||
? member.groupId === match.groupAlpha.id
|
||||
: member.groupId === match.groupBravo.id;
|
||||
|
||||
result.push({
|
||||
ownerUserId: member.id,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import cachified from "@epic-web/cachified";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
|
||||
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
|
||||
import { reportedWeaponsToArrayOfArrays } from "~/features/sendouq-match/core/reported-weapons.server";
|
||||
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import { reportedWeaponsByMatchId } from "~/features/sendouq-match/queries/reportedWeaponsByMatchId.server";
|
||||
import { cache } from "~/utils/cache.server";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
|
||||
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
|
||||
import { qMatchPageParamsSchema } from "../q-match-schemas";
|
||||
|
||||
|
|
@ -16,97 +14,30 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
|||
params,
|
||||
schema: qMatchPageParamsSchema,
|
||||
}).id;
|
||||
const match = notFoundIfFalsy(await QMatchRepository.findById(matchId));
|
||||
const matchUnmapped = notFoundIfFalsy(
|
||||
await SQMatchRepository.findById(matchId),
|
||||
);
|
||||
|
||||
const [groupAlpha, groupBravo] = await Promise.all([
|
||||
QMatchRepository.findGroupById({
|
||||
groupId: match.alphaGroupId,
|
||||
loggedInUserId: user?.id,
|
||||
}),
|
||||
QMatchRepository.findGroupById({
|
||||
groupId: match.bravoGroupId,
|
||||
loggedInUserId: user?.id,
|
||||
}),
|
||||
]);
|
||||
invariant(groupAlpha, "Group alpha not found");
|
||||
invariant(groupBravo, "Group bravo not found");
|
||||
const matchUsers = [
|
||||
...matchUnmapped.groupAlpha.members,
|
||||
...matchUnmapped.groupBravo.members,
|
||||
].map((m) => m.id);
|
||||
const privateNotes = user
|
||||
? await PrivateUserNoteRepository.byAuthorUserId(user.id, matchUsers)
|
||||
: undefined;
|
||||
|
||||
const isTeamAlphaMember = groupAlpha.members.some((m) => m.id === user?.id);
|
||||
const isTeamBravoMember = groupBravo.members.some((m) => m.id === user?.id);
|
||||
const isMatchInsider =
|
||||
isTeamAlphaMember || isTeamBravoMember || user?.roles.includes("STAFF");
|
||||
const matchHappenedInTheLastMonth =
|
||||
databaseTimestampToDate(match.createdAt).getTime() >
|
||||
Date.now() - 30 * 24 * 3600 * 1000;
|
||||
|
||||
const censoredGroupAlpha = {
|
||||
...groupAlpha,
|
||||
chatCode: undefined,
|
||||
members: groupAlpha.members.map((m) => ({
|
||||
...m,
|
||||
friendCode:
|
||||
isMatchInsider && matchHappenedInTheLastMonth
|
||||
? m.friendCode
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
const censoredGroupBravo = {
|
||||
...groupBravo,
|
||||
chatCode: undefined,
|
||||
members: groupBravo.members.map((m) => ({
|
||||
...m,
|
||||
friendCode:
|
||||
isMatchInsider && matchHappenedInTheLastMonth
|
||||
? m.friendCode
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
const censoredMatch = { ...match, chatCode: undefined };
|
||||
|
||||
const groupChatCode = () => {
|
||||
if (isTeamAlphaMember) return groupAlpha.chatCode;
|
||||
if (isTeamBravoMember) return groupBravo.chatCode;
|
||||
|
||||
return null;
|
||||
};
|
||||
const match = SendouQ.mapMatch(matchUnmapped, user, privateNotes);
|
||||
|
||||
const rawReportedWeapons = match.reportedAt
|
||||
? reportedWeaponsByMatchId(matchId)
|
||||
: null;
|
||||
|
||||
const banScreen = !match.isLocked
|
||||
? await cachified({
|
||||
key: `matches-screen-ban-${match.id}`,
|
||||
cache,
|
||||
async getFreshValue() {
|
||||
const noScreenSettings =
|
||||
await QMatchRepository.groupMembersNoScreenSettings([
|
||||
groupAlpha,
|
||||
groupBravo,
|
||||
]);
|
||||
|
||||
return noScreenSettings.some((user) => user.noScreen);
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
match: censoredMatch,
|
||||
matchChatCode: isMatchInsider ? match.chatCode : null,
|
||||
canPostChatMessages: isTeamAlphaMember || isTeamBravoMember,
|
||||
groupChatCode: groupChatCode(),
|
||||
groupAlpha: censoredGroupAlpha,
|
||||
groupBravo: censoredGroupBravo,
|
||||
banScreen,
|
||||
groupMemberOf: isTeamAlphaMember
|
||||
? ("ALPHA" as const)
|
||||
: isTeamBravoMember
|
||||
? ("BRAVO" as const)
|
||||
: null,
|
||||
match,
|
||||
reportedWeapons: match.reportedAt
|
||||
? reportedWeaponsToArrayOfArrays({
|
||||
groupAlpha,
|
||||
groupBravo,
|
||||
groupAlpha: match.groupAlpha,
|
||||
groupBravo: match.groupBravo,
|
||||
mapList: match.mapList,
|
||||
reportedWeapons: rawReportedWeapons,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,3 +7,21 @@ export function winnersArrayToWinner(winners: ("ALPHA" | "BRAVO")[]) {
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveGroupMemberOf(args: {
|
||||
groupAlpha: { members: { id: number }[] };
|
||||
groupBravo: { members: { id: number }[] };
|
||||
userId: number | undefined;
|
||||
}): "ALPHA" | "BRAVO" | null {
|
||||
if (!args.userId) return null;
|
||||
|
||||
if (args.groupAlpha.members.some((m) => m.id === args.userId)) {
|
||||
return "ALPHA";
|
||||
}
|
||||
|
||||
if (args.groupBravo.members.some((m) => m.id === args.userId)) {
|
||||
return "BRAVO";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function addSkills({
|
|||
Tables["Skill"],
|
||||
"groupMatchId" | "identifier" | "mu" | "season" | "sigma" | "userId"
|
||||
>[];
|
||||
oldMatchMemento: ParsedMemento;
|
||||
oldMatchMemento: ParsedMemento | null;
|
||||
differences: MementoSkillDifferences;
|
||||
}) {
|
||||
for (const skill of skills) {
|
||||
|
|
|
|||
128
app/features/sendouq-match/routes/q.match.$id.module.css
Normal file
128
app/features/sendouq-match/routes/q.match.$id.module.css
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
.stagePopoverButton {
|
||||
background-color: transparent;
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
padding: 0;
|
||||
border: none;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
font-weight: var(--body);
|
||||
height: 19.8281px;
|
||||
}
|
||||
|
||||
.stagePopoverButton:focus {
|
||||
outline: none;
|
||||
color: var(--theme);
|
||||
}
|
||||
|
||||
.modePopoverButton {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modePopoverButton:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
/** Push footer down to avoid it "flashing" when the score reporter animates */
|
||||
padding-bottom: 14rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.teamsContainer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.mapListChatContainer {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 2fr;
|
||||
place-items: center;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.userNameContainer {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
width: 175px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.reportSection {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
row-gap: var(--s-2);
|
||||
column-gap: var(--s-2-5);
|
||||
align-items: center;
|
||||
font-size: var(--fonts-xs);
|
||||
}
|
||||
|
||||
.poolPassContainer {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
flex-direction: column;
|
||||
max-width: max-content;
|
||||
}
|
||||
|
||||
.chatContainer {
|
||||
align-self: flex-start;
|
||||
top: var(--sticky-top);
|
||||
position: sticky;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bottomMidSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: flex-start;
|
||||
top: var(--sticky-top);
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.infoHeader {
|
||||
text-transform: uppercase;
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
font-size: var(--fonts-xl);
|
||||
font-weight: var(--semi-bold);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.screenLegality svg {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.screenLegality .alert {
|
||||
padding-block: var(--s-1);
|
||||
padding-inline: var(--s-2-5);
|
||||
}
|
||||
|
||||
.screenLegalityButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.screenLegalityButton:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.screenLegalityButton:focus-visible .alert {
|
||||
background-color: var(--bg-lighter);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 640px) {
|
||||
.teamsContainer {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows";
|
|||
import { ScaleIcon } from "~/components/icons/Scale";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Placeholder } from "~/components/Placeholder";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
|
@ -73,9 +74,10 @@ import {
|
|||
import { action } from "../actions/q.match.$id.server";
|
||||
import { matchEndedAtIndex } from "../core/match";
|
||||
import { loader } from "../loaders/q.match.$id.server";
|
||||
import { resolveGroupMemberOf } from "../q-match-utils";
|
||||
export { loader, action };
|
||||
|
||||
import "~/features/sendouq/q.css";
|
||||
import styles from "./q.match.$id.module.css";
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
const data = args.data as SerializeFrom<typeof loader> | null;
|
||||
|
|
@ -85,9 +87,9 @@ export const meta: MetaFunction = (args) => {
|
|||
return metaTags({
|
||||
title: `SendouQ - Match #${data.match.id}`,
|
||||
description: `${new Intl.ListFormat("en-US").format(
|
||||
data.groupAlpha.members.map((m) => m.username),
|
||||
data.match.groupAlpha.members.map((m) => m.username),
|
||||
)} vs. ${new Intl.ListFormat("en-US").format(
|
||||
data.groupBravo.members.map((m) => m.username),
|
||||
data.match.groupBravo.members.map((m) => m.username),
|
||||
)}`,
|
||||
location: args.location,
|
||||
});
|
||||
|
|
@ -102,7 +104,20 @@ export const handle: SendouRouteHandle = {
|
|||
}),
|
||||
};
|
||||
|
||||
export default function QMatchPage() {
|
||||
export default function QMatchShell() {
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
if (!isMounted)
|
||||
return (
|
||||
<Main>
|
||||
<Placeholder />
|
||||
</Main>
|
||||
);
|
||||
|
||||
return <QMatchPage />;
|
||||
}
|
||||
|
||||
function QMatchPage() {
|
||||
const user = useUser();
|
||||
const isStaff = useHasRole("STAFF");
|
||||
const isMounted = useIsMounted();
|
||||
|
|
@ -119,16 +134,16 @@ export default function QMatchPage() {
|
|||
}, [data.reportedWeapons, data.match.id]);
|
||||
|
||||
const ownMember =
|
||||
data.groupAlpha.members.find((m) => m.id === user?.id) ??
|
||||
data.groupBravo.members.find((m) => m.id === user?.id);
|
||||
data.match.groupAlpha.members.find((m) => m.id === user?.id) ??
|
||||
data.match.groupBravo.members.find((m) => m.id === user?.id);
|
||||
const canReportScore = Boolean(
|
||||
!data.match.isLocked && (ownMember || isStaff),
|
||||
);
|
||||
|
||||
const ownGroup = data.groupAlpha.members.some((m) => m.id === user?.id)
|
||||
? data.groupAlpha
|
||||
: data.groupBravo.members.some((m) => m.id === user?.id)
|
||||
? data.groupBravo
|
||||
const ownGroup = data.match.groupAlpha.members.some((m) => m.id === user?.id)
|
||||
? data.match.groupAlpha
|
||||
: data.match.groupBravo.members.some((m) => m.id === user?.id)
|
||||
? data.match.groupBravo
|
||||
: null;
|
||||
|
||||
const ownTeamReported = Boolean(
|
||||
|
|
@ -138,17 +153,23 @@ export default function QMatchPage() {
|
|||
const showScore =
|
||||
data.match.isLocked || (data.match.reportedByUserId && ownGroup);
|
||||
|
||||
const groupMemberOf = resolveGroupMemberOf({
|
||||
groupAlpha: data.match.groupAlpha,
|
||||
groupBravo: data.match.groupBravo,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
const addingNoteFor = (
|
||||
data.groupMemberOf === "ALPHA" ? data.groupAlpha : data.groupBravo
|
||||
groupMemberOf === "ALPHA" ? data.match.groupAlpha : data.match.groupBravo
|
||||
).members.find((m) => m.id === safeNumberParse(searchParams.get("note")));
|
||||
|
||||
return (
|
||||
<Main className="q-match__container stack xl">
|
||||
<Main className={`${styles.container} stack xl`}>
|
||||
<AddPrivateNoteDialog
|
||||
aboutUser={addingNoteFor}
|
||||
close={() => navigate(sendouQMatchPage(data.match.id))}
|
||||
/>
|
||||
<div className="q-match__header">
|
||||
<div className={styles.header}>
|
||||
<h2>{t("q:match.header", { number: data.match.id })}</h2>
|
||||
<div
|
||||
className={clsx("text-xs text-lighter", {
|
||||
|
|
@ -187,15 +208,14 @@ export default function QMatchPage() {
|
|||
) : null}
|
||||
{!showWeaponsForm ? (
|
||||
<>
|
||||
<div className="q-match__teams-container">
|
||||
{[data.groupAlpha, data.groupBravo].map((group, i) => {
|
||||
<div className={styles.teamsContainer}>
|
||||
{[data.match.groupAlpha, data.match.groupBravo].map((group, i) => {
|
||||
const side = i === 0 ? "ALPHA" : "BRAVO";
|
||||
const isOwnGroup = data.groupMemberOf === side;
|
||||
const isOwnGroup = groupMemberOf === side;
|
||||
|
||||
const matchHasBeenReported = Boolean(data.match.reportedByUserId);
|
||||
const showAddNote =
|
||||
data.groupMemberOf === side && matchHasBeenReported;
|
||||
|
||||
groupMemberOf === side && matchHasBeenReported;
|
||||
return (
|
||||
<div className="stack sm text-lighter text-xs" key={group.id}>
|
||||
<div className="stack horizontal justify-between items-center">
|
||||
|
|
@ -246,14 +266,18 @@ function Score({
|
|||
const { formatDateTime } = useTimeFormat();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const reporter =
|
||||
data.groupAlpha.members.find((m) => m.id === data.match.reportedByUserId) ??
|
||||
data.groupBravo.members.find((m) => m.id === data.match.reportedByUserId);
|
||||
data.match.groupAlpha.members.find(
|
||||
(m) => m.id === data.match.reportedByUserId,
|
||||
) ??
|
||||
data.match.groupBravo.members.find(
|
||||
(m) => m.id === data.match.reportedByUserId,
|
||||
);
|
||||
|
||||
const score = data.match.mapList.reduce(
|
||||
(acc, cur) => {
|
||||
if (!cur.winnerGroupId) return acc;
|
||||
|
||||
if (cur.winnerGroupId === data.match.alphaGroupId) {
|
||||
if (cur.winnerGroupId === data.match.groupAlpha.id) {
|
||||
return [acc[0] + 1, acc[1]];
|
||||
}
|
||||
|
||||
|
|
@ -411,7 +435,7 @@ function ReportWeaponsForm() {
|
|||
|
||||
const playedMaps = data.match.mapList.filter((m) => m.winnerGroupId);
|
||||
const winners = playedMaps.map((m) =>
|
||||
m.winnerGroupId === data.match.alphaGroupId ? "ALPHA" : "BRAVO",
|
||||
m.winnerGroupId === data.match.groupAlpha.id ? "ALPHA" : "BRAVO",
|
||||
);
|
||||
|
||||
const handleCopyWeaponsFromPreviousMap =
|
||||
|
|
@ -441,8 +465,17 @@ function ReportWeaponsForm() {
|
|||
});
|
||||
};
|
||||
|
||||
const groupMemberOf = resolveGroupMemberOf({
|
||||
groupAlpha: data.match.groupAlpha,
|
||||
groupBravo: data.match.groupBravo,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
const playersToReport = () => {
|
||||
const allPlayers = [...data.groupAlpha.members, ...data.groupBravo.members];
|
||||
const allPlayers = [
|
||||
...data.match.groupAlpha.members,
|
||||
...data.match.groupBravo.members,
|
||||
];
|
||||
|
||||
switch (reportingMode) {
|
||||
case "ALL": {
|
||||
|
|
@ -455,9 +488,9 @@ function ReportWeaponsForm() {
|
|||
return [me];
|
||||
}
|
||||
case "MY_TEAM": {
|
||||
return data.groupMemberOf === "ALPHA"
|
||||
? data.groupAlpha.members
|
||||
: data.groupBravo.members;
|
||||
return groupMemberOf === "ALPHA"
|
||||
? data.match.groupAlpha.members
|
||||
: data.match.groupBravo.members;
|
||||
}
|
||||
default:
|
||||
assertUnreachable(reportingMode);
|
||||
|
|
@ -549,7 +582,7 @@ function ReportWeaponsForm() {
|
|||
key={member.id}
|
||||
className="stack horizontal sm justify-between items-center flex-wrap"
|
||||
>
|
||||
<div className="q-match__report__user-name-container">
|
||||
<div className={styles.userNameContainer}>
|
||||
<Avatar user={member} size="xxs" />{" "}
|
||||
{member.inGameName ? (
|
||||
<>
|
||||
|
|
@ -638,10 +671,9 @@ function BottomSection({
|
|||
|
||||
const chatUsers = React.useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
[...data.groupAlpha.members, ...data.groupBravo.members].map((m) => [
|
||||
m.id,
|
||||
m,
|
||||
]),
|
||||
[...data.match.groupAlpha.members, ...data.match.groupBravo.members].map(
|
||||
(m) => [m.id, m],
|
||||
),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
|
|
@ -652,12 +684,17 @@ function BottomSection({
|
|||
setUnseenMessages((msg) => msg + 1);
|
||||
}, []);
|
||||
|
||||
const groupChatCode =
|
||||
data.match.groupAlpha.chatCode ?? data.match.groupBravo.chatCode;
|
||||
|
||||
const chatRooms = React.useMemo(() => {
|
||||
return [
|
||||
data.matchChatCode ? { code: data.matchChatCode, label: "Match" } : null,
|
||||
data.groupChatCode ? { code: data.groupChatCode, label: "Group" } : null,
|
||||
data.match.chatCode
|
||||
? { code: data.match.chatCode, label: "Match" }
|
||||
: null,
|
||||
groupChatCode ? { code: groupChatCode, label: "Group" } : null,
|
||||
].filter(Boolean) as ChatProps["rooms"];
|
||||
}, [data.matchChatCode, data.groupChatCode]);
|
||||
}, [data.match.chatCode, groupChatCode]);
|
||||
|
||||
const chatHidden = chatRooms.length === 0;
|
||||
|
||||
|
|
@ -706,7 +743,7 @@ function BottomSection({
|
|||
onUnmount={onChatUnmount}
|
||||
users={chatUsers}
|
||||
rooms={chatRooms}
|
||||
disabled={!data.canPostChatMessages}
|
||||
disabled={!groupChatCode} // no message sending by staff to match chat
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -721,9 +758,7 @@ function BottomSection({
|
|||
);
|
||||
|
||||
const roomJoiningInfoElement = (
|
||||
<div
|
||||
className={clsx("q-match__pool-pass-container", { "mx-auto": !isMobile })}
|
||||
>
|
||||
<div className={clsx(styles.poolPassContainer, { "mx-auto": !isMobile })}>
|
||||
<InfoWithHeader header={t("q:match.pool")} value={poolCode()} />
|
||||
<InfoWithHeader
|
||||
header={t("q:match.password.short")}
|
||||
|
|
@ -755,6 +790,12 @@ function BottomSection({
|
|||
</LinkButton>
|
||||
);
|
||||
|
||||
const groupMemberOf = resolveGroupMemberOf({
|
||||
groupAlpha: data.match.groupAlpha,
|
||||
groupBravo: data.match.groupBravo,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
const cancelMatchElement =
|
||||
canReportScore && !data.match.isLocked ? (
|
||||
<FormWithConfirm
|
||||
|
|
@ -762,7 +803,7 @@ function BottomSection({
|
|||
fields={[
|
||||
["_action", "REPORT_SCORE"],
|
||||
["winners", "[]"],
|
||||
...(!data.groupMemberOf ? [["adminReport", "on"] as const] : []),
|
||||
...(!groupMemberOf ? [["adminReport", "on"] as const] : []),
|
||||
]}
|
||||
submitButtonText={t("common:actions.cancel")}
|
||||
fetcher={cancelFetcher}
|
||||
|
|
@ -779,10 +820,13 @@ function BottomSection({
|
|||
</FormWithConfirm>
|
||||
) : null;
|
||||
|
||||
const screenLegalityInfoElement =
|
||||
data.banScreen !== null ? (
|
||||
<ScreenLegalityInfo ban={data.banScreen} />
|
||||
) : null;
|
||||
const screenBanned = Boolean(
|
||||
data.match.groupAlpha.noScreen || data.match.groupBravo.noScreen,
|
||||
);
|
||||
|
||||
const screenLegalityInfoElement = !data.match.isLocked ? (
|
||||
<ScreenLegalityInfo ban={screenBanned} />
|
||||
) : null;
|
||||
|
||||
if (!showMid && chatHidden) {
|
||||
return mapListElement;
|
||||
|
|
@ -832,10 +876,10 @@ function BottomSection({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="q-match__map-list-chat-container">
|
||||
<div className={styles.mapListChatContainer}>
|
||||
{mapListElement}
|
||||
<div
|
||||
className={clsx("q-match__bottom-mid-section", {
|
||||
className={clsx(styles.bottomMidSection, {
|
||||
invisible: !showMid,
|
||||
})}
|
||||
>
|
||||
|
|
@ -847,7 +891,7 @@ function BottomSection({
|
|||
{cancelMatchElement}
|
||||
</div>
|
||||
</div>
|
||||
<div className="q-match__chat-container">
|
||||
<div className={styles.chatContainer}>
|
||||
{chatRooms.length > 0 ? chatElement : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -869,13 +913,13 @@ function ScreenLegalityInfo({ ban }: { ban: boolean }) {
|
|||
const { t } = useTranslation(["q", "weapons"]);
|
||||
|
||||
return (
|
||||
<div className="q-match__screen-legality">
|
||||
<div className={styles.screenLegality}>
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="small"
|
||||
className="q-match__screen-legality__button"
|
||||
className={styles.screenLegalityButton}
|
||||
>
|
||||
<Alert variation={ban ? "ERROR" : "SUCCESS"}>
|
||||
<div className="stack xs horizontal items-center">
|
||||
|
|
@ -905,8 +949,8 @@ function ScreenLegalityInfo({ ban }: { ban: boolean }) {
|
|||
function InfoWithHeader({ header, value }: { header: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="q-match__info__header">{header}</div>
|
||||
<div className="q-match__info__value">{value}</div>
|
||||
<div className={styles.infoHeader}>{header}</div>
|
||||
<div className={styles.infoValue}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -937,7 +981,7 @@ function MapList({
|
|||
? data.match.mapList
|
||||
.filter((m) => m.winnerGroupId)
|
||||
.map((m) =>
|
||||
m.winnerGroupId === data.groupAlpha.id ? "ALPHA" : "BRAVO",
|
||||
m.winnerGroupId === data.match.groupAlpha.id ? "ALPHA" : "BRAVO",
|
||||
)
|
||||
: [];
|
||||
const [winners, setWinners] = React.useState<("ALPHA" | "BRAVO")[]>(
|
||||
|
|
@ -1016,7 +1060,11 @@ function MapList({
|
|||
{scoreCanBeReported ? (
|
||||
<div className="stack md items-center mt-4">
|
||||
<ResultSummary winners={winners} />
|
||||
<SubmitButton _action="REPORT_SCORE" state={fetcher.state}>
|
||||
<SubmitButton
|
||||
_action="REPORT_SCORE"
|
||||
state={fetcher.state}
|
||||
testId="submit-score-button"
|
||||
>
|
||||
{isResubmission
|
||||
? t("q:match.submitScores.adjusted")
|
||||
: t("q:match.submitScores")}
|
||||
|
|
@ -1093,25 +1141,31 @@ function MapListMap({
|
|||
);
|
||||
|
||||
const winnerSide =
|
||||
winnerId === data.match.alphaGroupId
|
||||
winnerId === data.match.groupAlpha.id
|
||||
? t("q:match.sides.alpha")
|
||||
: t("q:match.sides.bravo");
|
||||
|
||||
return <>• {t("q:match.won", { side: winnerSide })}</>;
|
||||
};
|
||||
|
||||
const relativeSideText = (side: "ALPHA" | "BRAVO") => {
|
||||
if (!data.groupMemberOf) return "";
|
||||
const groupMemberOf = resolveGroupMemberOf({
|
||||
groupAlpha: data.match.groupAlpha,
|
||||
groupBravo: data.match.groupBravo,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
return data.groupMemberOf === side ? " (us)" : " (them)";
|
||||
const relativeSideText = (side: "ALPHA" | "BRAVO") => {
|
||||
if (!groupMemberOf) return "";
|
||||
|
||||
return groupMemberOf === side ? " (us)" : " (them)";
|
||||
};
|
||||
|
||||
const modePreferences = data.match.memento?.modePreferences?.[map.mode];
|
||||
|
||||
const userIdToName = (userId: number) => {
|
||||
const member = [
|
||||
...data.groupAlpha.members,
|
||||
...data.groupBravo.members,
|
||||
...data.match.groupAlpha.members,
|
||||
...data.match.groupBravo.members,
|
||||
].find((m) => m.id === userId);
|
||||
|
||||
return member?.username ?? "";
|
||||
|
|
@ -1129,7 +1183,7 @@ function MapListMap({
|
|||
<SendouPopover
|
||||
popoverClassName="text-main-forced"
|
||||
trigger={
|
||||
<SendouButton className="q-match__mode-popover-button">
|
||||
<SendouButton className={styles.modePopoverButton}>
|
||||
<ModeImage mode={map.mode} size={18} />
|
||||
</SendouButton>
|
||||
}
|
||||
|
|
@ -1198,7 +1252,7 @@ function MapListMap({
|
|||
el.style.opacity = "1";
|
||||
}}
|
||||
>
|
||||
<div className="q-match__report-section">
|
||||
<div className={styles.reportSection}>
|
||||
<label className="mb-0 text-theme-secondary">
|
||||
{t("q:match.report.winnerLabel")}
|
||||
</label>
|
||||
|
|
@ -1216,7 +1270,7 @@ function MapListMap({
|
|||
{t("q:match.sides.alpha")}
|
||||
<span
|
||||
className={clsx({
|
||||
"text-success": data.groupMemberOf === "ALPHA",
|
||||
"text-success": groupMemberOf === "ALPHA",
|
||||
})}
|
||||
>
|
||||
{relativeSideText("ALPHA")}
|
||||
|
|
@ -1236,7 +1290,7 @@ function MapListMap({
|
|||
{t("q:match.sides.bravo")}
|
||||
<span
|
||||
className={clsx({
|
||||
"text-success": data.groupMemberOf === "BRAVO",
|
||||
"text-success": groupMemberOf === "BRAVO",
|
||||
})}
|
||||
>
|
||||
{relativeSideText("BRAVO")}
|
||||
|
|
@ -1316,8 +1370,8 @@ function MapListMapPickInfo({
|
|||
|
||||
const userIdToUser = (userId: number) => {
|
||||
const member = [
|
||||
...data.groupAlpha.members,
|
||||
...data.groupBravo.members,
|
||||
...data.match.groupAlpha.members,
|
||||
...data.match.groupBravo.members,
|
||||
].find((m) => m.id === userId);
|
||||
|
||||
return member;
|
||||
|
|
@ -1328,7 +1382,7 @@ function MapListMapPickInfo({
|
|||
|
||||
if (!data.match.memento?.pools) return result;
|
||||
|
||||
const pickerGroups = [data.groupAlpha, data.groupBravo].filter(
|
||||
const pickerGroups = [data.match.groupAlpha, data.match.groupBravo].filter(
|
||||
(g) => map.source === "BOTH" || String(g.id) === map.source,
|
||||
);
|
||||
if (pickerGroups.length === 0) return result;
|
||||
|
|
@ -1364,7 +1418,7 @@ function MapListMapPickInfo({
|
|||
<SendouPopover
|
||||
popoverClassName="text-main-forced"
|
||||
trigger={
|
||||
<SendouButton className="q-match__stage-popover-button">
|
||||
<SendouButton className={styles.stagePopoverButton}>
|
||||
{pickInfo(map.source)}
|
||||
</SendouButton>
|
||||
}
|
||||
|
|
@ -1419,7 +1473,7 @@ function ResultSummary({ winners }: { winners: ("ALPHA" | "BRAVO")[] }) {
|
|||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const ownSide = data.groupAlpha.members.some((m) => m.id === user?.id)
|
||||
const ownSide = data.match.groupAlpha.members.some((m) => m.id === user?.id)
|
||||
? "ALPHA"
|
||||
: "BRAVO";
|
||||
|
||||
|
|
|
|||
27
app/features/sendouq-streams/routes/q.streams.module.css
Normal file
27
app/features/sendouq-streams/routes/q.streams.module.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
.userContainer {
|
||||
font-size: var(--fonts-xs);
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.viewerCount {
|
||||
font-size: var(--fonts-xs);
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
margin-block-start: -5px;
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.viewerCount > svg {
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
.infoCircle {
|
||||
border-radius: 100%;
|
||||
background-color: var(--bg-lighter);
|
||||
padding: var(--s-1);
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ import { FAQ_PAGE, sendouQMatchPage, twitchUrl, userPage } from "~/utils/urls";
|
|||
import { loader } from "../loaders/q.streams.server";
|
||||
export { loader };
|
||||
|
||||
import "~/features/sendouq/q.css";
|
||||
import styles from "./q.streams.module.css";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["q"],
|
||||
|
|
@ -59,14 +59,14 @@ export default function SendouQStreamsPage() {
|
|||
<div className="stack horizontal justify-between items-end">
|
||||
<Link
|
||||
to={userPage(streamedMatch.user)}
|
||||
className="q-stream__stream__user-container"
|
||||
className={styles.userContainer}
|
||||
>
|
||||
<Avatar size="xxs" user={streamedMatch.user} />{" "}
|
||||
{streamedMatch.user.username}
|
||||
</Link>
|
||||
<div className="stack horizontal sm">
|
||||
{streamedMatch.weaponSplId ? (
|
||||
<div className="q-stream__info-circle">
|
||||
<div className={styles.infoCircle}>
|
||||
<WeaponImage
|
||||
weaponSplId={streamedMatch.weaponSplId}
|
||||
size={24}
|
||||
|
|
@ -75,7 +75,7 @@ export default function SendouQStreamsPage() {
|
|||
</div>
|
||||
) : null}
|
||||
{streamedMatch.tier ? (
|
||||
<div className="q-stream__info-circle">
|
||||
<div className={styles.infoCircle}>
|
||||
<TierImage tier={streamedMatch.tier} width={24} />
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -108,7 +108,7 @@ export default function SendouQStreamsPage() {
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="q-stream__stream__viewer-count">
|
||||
<div className={styles.viewerCount}>
|
||||
<UserIcon />
|
||||
{streamedMatch.stream.viewerCount}
|
||||
</div>
|
||||
|
|
|
|||
61
app/features/sendouq/PrivateUserNoteRepository.server.ts
Normal file
61
app/features/sendouq/PrivateUserNoteRepository.server.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { db } from "~/db/sql";
|
||||
import type { TablesInsertable } from "~/db/tables";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
|
||||
export function byAuthorUserId(
|
||||
authorId: number,
|
||||
/** Which users to get notes for, if omitted all notes for author are returned */
|
||||
targetUserIds: number[] = [],
|
||||
) {
|
||||
let query = db
|
||||
.selectFrom("PrivateUserNote")
|
||||
.select([
|
||||
"PrivateUserNote.sentiment",
|
||||
"PrivateUserNote.targetId as targetUserId",
|
||||
"PrivateUserNote.text",
|
||||
"PrivateUserNote.updatedAt",
|
||||
])
|
||||
.where("authorId", "=", authorId);
|
||||
|
||||
const targetUsersWithoutAuthor = targetUserIds.filter(
|
||||
(id) => id !== authorId,
|
||||
);
|
||||
if (targetUsersWithoutAuthor.length > 0) {
|
||||
query = query.where("targetId", "in", targetUsersWithoutAuthor);
|
||||
}
|
||||
|
||||
return query.execute();
|
||||
}
|
||||
|
||||
export function upsert(args: TablesInsertable["PrivateUserNote"]) {
|
||||
return db
|
||||
.insertInto("PrivateUserNote")
|
||||
.values({
|
||||
authorId: args.authorId,
|
||||
targetId: args.targetId,
|
||||
sentiment: args.sentiment,
|
||||
text: args.text,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.columns(["authorId", "targetId"]).doUpdateSet({
|
||||
sentiment: args.sentiment,
|
||||
text: args.text,
|
||||
updatedAt: databaseTimestampNow(),
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function del({
|
||||
authorId,
|
||||
targetId,
|
||||
}: {
|
||||
authorId: number;
|
||||
targetId: number;
|
||||
}) {
|
||||
return db
|
||||
.deleteFrom("PrivateUserNote")
|
||||
.where("authorId", "=", authorId)
|
||||
.where("targetId", "=", targetId)
|
||||
.execute();
|
||||
}
|
||||
|
|
@ -1,409 +0,0 @@
|
|||
import { sub } from "date-fns";
|
||||
import { type NotNull, sql } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type {
|
||||
Tables,
|
||||
TablesInsertable,
|
||||
UserMapModePreferences,
|
||||
} from "~/db/tables";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
|
||||
import { userIsBanned } from "../ban/core/banned.server";
|
||||
import type { LookingGroupWithInviteCode } from "./q-types";
|
||||
|
||||
export function mapModePreferencesByGroupId(groupId: number) {
|
||||
return db
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("User", "User.id", "GroupMember.userId")
|
||||
.select(["User.id as userId", "User.mapModePreferences as preferences"])
|
||||
.where("GroupMember.groupId", "=", groupId)
|
||||
.where("User.mapModePreferences", "is not", null)
|
||||
.$narrowType<{ preferences: NotNull }>()
|
||||
.execute();
|
||||
}
|
||||
|
||||
// groups visible for longer to make development easier
|
||||
const SECONDS_TILL_STALE =
|
||||
process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN ? 1_000_000 : 1_800;
|
||||
|
||||
export async function findLookingGroups({
|
||||
minGroupSize,
|
||||
maxGroupSize,
|
||||
ownGroupId,
|
||||
includeChatCode = false,
|
||||
includeMapModePreferences = false,
|
||||
loggedInUserId,
|
||||
}: {
|
||||
minGroupSize?: number;
|
||||
maxGroupSize?: number;
|
||||
ownGroupId?: number;
|
||||
includeChatCode?: boolean;
|
||||
includeMapModePreferences?: boolean;
|
||||
loggedInUserId?: number;
|
||||
}): Promise<LookingGroupWithInviteCode[]> {
|
||||
const rows = await db
|
||||
.selectFrom("Group")
|
||||
.leftJoin("GroupMatch", (join) =>
|
||||
join.on((eb) =>
|
||||
eb.or([
|
||||
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
|
||||
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.select((eb) => [
|
||||
"Group.id",
|
||||
"Group.createdAt",
|
||||
"Group.chatCode",
|
||||
"Group.inviteCode",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("User", "User.id", "GroupMember.userId")
|
||||
.leftJoin("PlusTier", "PlusTier.userId", "GroupMember.userId")
|
||||
.select((arrayEb) => [
|
||||
...COMMON_USER_FIELDS,
|
||||
"User.qWeaponPool as weapons",
|
||||
"PlusTier.tier as plusTier",
|
||||
"GroupMember.note",
|
||||
"GroupMember.role",
|
||||
"User.languages",
|
||||
"User.vc",
|
||||
"User.noScreen",
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("PrivateUserNote")
|
||||
.select([
|
||||
"PrivateUserNote.sentiment",
|
||||
"PrivateUserNote.text",
|
||||
"PrivateUserNote.updatedAt",
|
||||
])
|
||||
.where("authorId", "=", loggedInUserId ?? -1)
|
||||
.where("targetId", "=", arrayEb.ref("User.id")),
|
||||
).as("privateNote"),
|
||||
sql<
|
||||
string | null
|
||||
>`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null)`.as(
|
||||
"chatNameColor",
|
||||
),
|
||||
])
|
||||
.where("GroupMember.groupId", "=", eb.ref("Group.id"))
|
||||
.groupBy("GroupMember.userId"),
|
||||
).as("members"),
|
||||
])
|
||||
.$if(includeMapModePreferences, (qb) =>
|
||||
qb.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("User", "User.id", "GroupMember.userId")
|
||||
.select("User.mapModePreferences")
|
||||
.where("GroupMember.groupId", "=", eb.ref("Group.id"))
|
||||
.where("User.mapModePreferences", "is not", null),
|
||||
).as("mapModePreferences"),
|
||||
),
|
||||
)
|
||||
.where("Group.status", "=", "ACTIVE")
|
||||
.where("GroupMatch.id", "is", null)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb(
|
||||
"Group.latestActionAt",
|
||||
">",
|
||||
sql<number>`(unixepoch() - ${SECONDS_TILL_STALE})`,
|
||||
),
|
||||
eb("Group.id", "=", ownGroupId ?? -1),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// TODO: a bit weird we filter chatCode here but not inviteCode and do some logic about filtering
|
||||
return rows
|
||||
.map((row) => {
|
||||
return {
|
||||
...row,
|
||||
chatCode: includeChatCode ? row.chatCode : undefined,
|
||||
mapModePreferences: row.mapModePreferences?.map(
|
||||
(c) => c.mapModePreferences,
|
||||
) as NonNullable<Tables["User"]["mapModePreferences"]>[],
|
||||
members: row.members.map((member) => {
|
||||
return {
|
||||
...member,
|
||||
languages: member.languages ? member.languages.split(",") : [],
|
||||
} as LookingGroupWithInviteCode["members"][number];
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((group) => {
|
||||
if (group.id === ownGroupId) return true;
|
||||
if (maxGroupSize && group.members.length > maxGroupSize) return false;
|
||||
if (minGroupSize && group.members.length < minGroupSize) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function findActiveGroupMembers() {
|
||||
return db
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("Group", "Group.id", "GroupMember.groupId")
|
||||
.select("GroupMember.userId")
|
||||
.where("Group.status", "!=", "INACTIVE")
|
||||
.execute();
|
||||
}
|
||||
|
||||
type CreateGroupArgs = {
|
||||
status: Exclude<Tables["Group"]["status"], "INACTIVE">;
|
||||
userId: number;
|
||||
};
|
||||
export function createGroup(args: CreateGroupArgs) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const createdGroup = await trx
|
||||
.insertInto("Group")
|
||||
.values({
|
||||
inviteCode: shortNanoid(),
|
||||
chatCode: shortNanoid(),
|
||||
status: args.status,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.insertInto("GroupMember")
|
||||
.values({
|
||||
groupId: createdGroup.id,
|
||||
userId: args.userId,
|
||||
role: "OWNER",
|
||||
})
|
||||
.execute();
|
||||
|
||||
return createdGroup;
|
||||
});
|
||||
}
|
||||
|
||||
type CreateGroupFromPreviousGroupArgs = {
|
||||
previousGroupId: number;
|
||||
members: {
|
||||
id: number;
|
||||
role: Tables["GroupMember"]["role"];
|
||||
}[];
|
||||
};
|
||||
export async function createGroupFromPrevious(
|
||||
args: CreateGroupFromPreviousGroupArgs,
|
||||
) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const createdGroup = await trx
|
||||
.insertInto("Group")
|
||||
.columns(["teamId", "chatCode", "inviteCode", "status"])
|
||||
.expression((eb) =>
|
||||
eb
|
||||
.selectFrom("Group")
|
||||
.select((eb) => [
|
||||
"Group.teamId",
|
||||
"Group.chatCode",
|
||||
eb.val(shortNanoid()).as("inviteCode"),
|
||||
eb.val("PREPARING").as("status"),
|
||||
])
|
||||
.where("Group.id", "=", args.previousGroupId),
|
||||
)
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.insertInto("GroupMember")
|
||||
.values(
|
||||
args.members.map((member) => ({
|
||||
groupId: createdGroup.id,
|
||||
userId: member.id,
|
||||
role: member.role,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return createdGroup;
|
||||
});
|
||||
}
|
||||
|
||||
export function rechallenge({
|
||||
likerGroupId,
|
||||
targetGroupId,
|
||||
}: {
|
||||
likerGroupId: number;
|
||||
targetGroupId: number;
|
||||
}) {
|
||||
return db
|
||||
.updateTable("GroupLike")
|
||||
.set({ isRechallenge: 1 })
|
||||
.where("likerGroupId", "=", likerGroupId)
|
||||
.where("targetGroupId", "=", targetGroupId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function upsertPrivateUserNote(
|
||||
args: TablesInsertable["PrivateUserNote"],
|
||||
) {
|
||||
return db
|
||||
.insertInto("PrivateUserNote")
|
||||
.values({
|
||||
authorId: args.authorId,
|
||||
targetId: args.targetId,
|
||||
sentiment: args.sentiment,
|
||||
text: args.text,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.columns(["authorId", "targetId"]).doUpdateSet({
|
||||
sentiment: args.sentiment,
|
||||
text: args.text,
|
||||
updatedAt: dateToDatabaseTimestamp(new Date()),
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function deletePrivateUserNote({
|
||||
authorId,
|
||||
targetId,
|
||||
}: {
|
||||
authorId: number;
|
||||
targetId: number;
|
||||
}) {
|
||||
return db
|
||||
.deleteFrom("PrivateUserNote")
|
||||
.where("authorId", "=", authorId)
|
||||
.where("targetId", "=", targetId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves information about users who have trusted the specified user,
|
||||
* including their associated teams and explicit trust relationships. Banned users are excluded.
|
||||
*/
|
||||
export async function usersThatTrusted(userId: number) {
|
||||
const teams = await db
|
||||
.selectFrom("TeamMemberWithSecondary")
|
||||
.innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
|
||||
.select(["Team.id", "Team.name", "TeamMemberWithSecondary.isMainTeam"])
|
||||
.where("userId", "=", userId)
|
||||
.execute();
|
||||
|
||||
const rows = await db
|
||||
.selectFrom("TeamMemberWithSecondary")
|
||||
.innerJoin("User", "User.id", "TeamMemberWithSecondary.userId")
|
||||
.innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id")
|
||||
.select([
|
||||
...COMMON_USER_FIELDS,
|
||||
"User.inGameName",
|
||||
"TeamMemberWithSecondary.teamId",
|
||||
])
|
||||
.where(
|
||||
"TeamMemberWithSecondary.teamId",
|
||||
"in",
|
||||
teams.map((t) => t.id),
|
||||
)
|
||||
.union((eb) =>
|
||||
eb
|
||||
.selectFrom("TrustRelationship")
|
||||
.innerJoin("User", "User.id", "TrustRelationship.trustGiverUserId")
|
||||
.innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id")
|
||||
.select([
|
||||
...COMMON_USER_FIELDS,
|
||||
"User.inGameName",
|
||||
sql<any>`null`.as("teamId"),
|
||||
])
|
||||
.where("TrustRelationship.trustReceiverUserId", "=", userId),
|
||||
)
|
||||
.execute();
|
||||
|
||||
const rowsWithoutBanned = rows.filter((row) => !userIsBanned(row.id));
|
||||
|
||||
const teamMemberIds = rowsWithoutBanned
|
||||
.filter((row) => row.teamId)
|
||||
.map((row) => row.id);
|
||||
|
||||
// we want user to show twice if member of two different teams
|
||||
// but we don't want a user from the team to show in teamless section
|
||||
const deduplicatedRows = rowsWithoutBanned.filter(
|
||||
(row) => row.teamId || !teamMemberIds.includes(row.id),
|
||||
);
|
||||
|
||||
// done here at not sql just because it was easier to do here ignoring case
|
||||
deduplicatedRows.sort((a, b) => a.username.localeCompare(b.username));
|
||||
|
||||
return {
|
||||
teams: teams.sort((a, b) => b.isMainTeam - a.isMainTeam),
|
||||
trusters: deduplicatedRows,
|
||||
};
|
||||
}
|
||||
|
||||
/** Update the timestamp of the trust relationship, delaying its automatic deletion */
|
||||
export async function refreshTrust({
|
||||
trustGiverUserId,
|
||||
trustReceiverUserId,
|
||||
}: {
|
||||
trustGiverUserId: number;
|
||||
trustReceiverUserId: number;
|
||||
}) {
|
||||
return db
|
||||
.updateTable("TrustRelationship")
|
||||
.set({ lastUsedAt: databaseTimestampNow() })
|
||||
.where("trustGiverUserId", "=", trustGiverUserId)
|
||||
.where("trustReceiverUserId", "=", trustReceiverUserId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function deleteOldTrust() {
|
||||
const twoMonthsAgo = sub(new Date(), { months: 2 });
|
||||
|
||||
return db
|
||||
.deleteFrom("TrustRelationship")
|
||||
.where("lastUsedAt", "<", dateToDatabaseTimestamp(twoMonthsAgo))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
export async function setOldGroupsAsInactive() {
|
||||
const oneHourAgo = sub(new Date(), { hours: 1 });
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const groupsToSetInactive = await trx
|
||||
.selectFrom("Group")
|
||||
.leftJoin("GroupMatch", (join) =>
|
||||
join.on((eb) =>
|
||||
eb.or([
|
||||
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
|
||||
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.select(["Group.id"])
|
||||
.where("status", "!=", "INACTIVE")
|
||||
.where("GroupMatch.id", "is", null)
|
||||
.where("latestActionAt", "<", dateToDatabaseTimestamp(oneHourAgo))
|
||||
.execute();
|
||||
|
||||
return trx
|
||||
.updateTable("Group")
|
||||
.set({ status: "INACTIVE" })
|
||||
.where(
|
||||
"Group.id",
|
||||
"in",
|
||||
groupsToSetInactive.map((g) => g.id),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
});
|
||||
}
|
||||
|
||||
export async function mapModePreferencesBySeasonNth(seasonNth: number) {
|
||||
return db
|
||||
.selectFrom("Skill")
|
||||
.innerJoin("User", "User.id", "Skill.userId")
|
||||
.select("User.mapModePreferences")
|
||||
.where("Skill.season", "=", seasonNth)
|
||||
.where("Skill.userId", "is not", null)
|
||||
.where("User.mapModePreferences", "is not", null)
|
||||
.groupBy("Skill.userId")
|
||||
.$narrowType<{ mapModePreferences: UserMapModePreferences }>()
|
||||
.execute();
|
||||
}
|
||||
694
app/features/sendouq/SQGroupRepository.server.ts
Normal file
694
app/features/sendouq/SQGroupRepository.server.ts
Normal file
|
|
@ -0,0 +1,694 @@
|
|||
import { sub } from "date-fns";
|
||||
import { type NotNull, sql, type Transaction } from "kysely";
|
||||
import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables, UserMapModePreferences } from "~/db/tables";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
COMMON_USER_FIELDS,
|
||||
userChatNameColorForJson,
|
||||
} from "~/utils/kysely.server";
|
||||
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
|
||||
import { userIsBanned } from "../ban/core/banned.server";
|
||||
import { FULL_GROUP_SIZE } from "./q-constants";
|
||||
import { SendouQError } from "./q-utils.server";
|
||||
|
||||
export function mapModePreferencesByGroupId(groupId: number) {
|
||||
return db
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("User", "User.id", "GroupMember.userId")
|
||||
.select(["User.id as userId", "User.mapModePreferences as preferences"])
|
||||
.where("GroupMember.groupId", "=", groupId)
|
||||
.where("User.mapModePreferences", "is not", null)
|
||||
.$narrowType<{ preferences: NotNull }>()
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findCurrentGroups() {
|
||||
type SendouQMemberObject = {
|
||||
id: Tables["User"]["id"];
|
||||
username: Tables["User"]["username"];
|
||||
discordId: Tables["User"]["discordId"];
|
||||
discordAvatar: Tables["User"]["discordAvatar"];
|
||||
customUrl: Tables["User"]["customUrl"];
|
||||
mapModePreferences: Tables["User"]["mapModePreferences"];
|
||||
noScreen: Tables["User"]["noScreen"];
|
||||
languages: Tables["User"]["languages"];
|
||||
vc: Tables["User"]["vc"];
|
||||
role: Tables["GroupMember"]["role"];
|
||||
weapons: Tables["User"]["qWeaponPool"];
|
||||
chatNameColor: string | null;
|
||||
plusTier: Tables["PlusTier"]["tier"] | null;
|
||||
};
|
||||
|
||||
return db
|
||||
.selectFrom("Group")
|
||||
.innerJoin("GroupMember", "GroupMember.groupId", "Group.id")
|
||||
.innerJoin("User", "User.id", "GroupMember.userId")
|
||||
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
|
||||
.leftJoin("GroupMatch", (join) =>
|
||||
join.on((eb) =>
|
||||
eb.or([
|
||||
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
|
||||
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.select(({ fn, eb }) => [
|
||||
"Group.id",
|
||||
"Group.chatCode",
|
||||
"Group.inviteCode",
|
||||
"Group.latestActionAt",
|
||||
"Group.chatCode",
|
||||
"Group.inviteCode",
|
||||
"Group.status",
|
||||
"GroupMatch.id as matchId",
|
||||
fn
|
||||
.agg("json_group_array", [
|
||||
jsonBuildObject({
|
||||
id: eb.ref("User.id"),
|
||||
username: eb.ref("User.username"),
|
||||
discordId: eb.ref("User.discordId"),
|
||||
discordAvatar: eb.ref("User.discordAvatar"),
|
||||
customUrl: eb.ref("User.customUrl"),
|
||||
mapModePreferences: eb.ref("User.mapModePreferences"),
|
||||
noScreen: eb.ref("User.noScreen"),
|
||||
role: eb.ref("GroupMember.role"),
|
||||
weapons: eb.ref("User.qWeaponPool"),
|
||||
languages: eb.ref("User.languages"),
|
||||
plusTier: eb.ref("PlusTier.tier"),
|
||||
vc: eb.ref("User.vc"),
|
||||
chatNameColor: userChatNameColorForJson,
|
||||
}),
|
||||
])
|
||||
.$castTo<SendouQMemberObject[]>()
|
||||
.as("members"),
|
||||
])
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("Group.status", "=", "ACTIVE"),
|
||||
eb("Group.status", "=", "PREPARING"),
|
||||
]),
|
||||
)
|
||||
.groupBy("Group.id")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findActiveGroupMembers() {
|
||||
return db
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("Group", "Group.id", "GroupMember.groupId")
|
||||
.select("GroupMember.userId")
|
||||
.where("Group.status", "!=", "INACTIVE")
|
||||
.execute();
|
||||
}
|
||||
|
||||
type CreateGroupArgs = {
|
||||
status: Exclude<Tables["Group"]["status"], "INACTIVE">;
|
||||
userId: number;
|
||||
};
|
||||
export function createGroup(args: CreateGroupArgs) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const createdGroup = await trx
|
||||
.insertInto("Group")
|
||||
.values({
|
||||
inviteCode: shortNanoid(),
|
||||
chatCode: shortNanoid(),
|
||||
status: args.status,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.insertInto("GroupMember")
|
||||
.values({
|
||||
groupId: createdGroup.id,
|
||||
userId: args.userId,
|
||||
role: "OWNER",
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (!(await isGroupCorrect(createdGroup.id, trx))) {
|
||||
throw new SendouQError("Group has a member in multiple groups");
|
||||
}
|
||||
|
||||
return createdGroup;
|
||||
});
|
||||
}
|
||||
|
||||
type CreateGroupFromPreviousGroupArgs = {
|
||||
previousGroupId: number;
|
||||
members: {
|
||||
id: number;
|
||||
role: Tables["GroupMember"]["role"];
|
||||
}[];
|
||||
};
|
||||
export async function createGroupFromPrevious(
|
||||
args: CreateGroupFromPreviousGroupArgs,
|
||||
) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const createdGroup = await trx
|
||||
.insertInto("Group")
|
||||
.columns(["teamId", "chatCode", "inviteCode", "status"])
|
||||
.expression((eb) =>
|
||||
eb
|
||||
.selectFrom("Group")
|
||||
.select((eb) => [
|
||||
"Group.teamId",
|
||||
"Group.chatCode",
|
||||
eb.val(shortNanoid()).as("inviteCode"),
|
||||
eb.val("PREPARING").as("status"),
|
||||
])
|
||||
.where("Group.id", "=", args.previousGroupId),
|
||||
)
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.insertInto("GroupMember")
|
||||
.values(
|
||||
args.members.map((member) => ({
|
||||
groupId: createdGroup.id,
|
||||
userId: member.id,
|
||||
role: member.role,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
|
||||
if (!(await isGroupCorrect(createdGroup.id, trx))) {
|
||||
throw new SendouQError(
|
||||
"Group has too many members or member in multiple groups",
|
||||
);
|
||||
}
|
||||
|
||||
return createdGroup;
|
||||
});
|
||||
}
|
||||
|
||||
function deleteLikesByGroupId(groupId: number, trx: Transaction<DB>) {
|
||||
return trx
|
||||
.deleteFrom("GroupLike")
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("GroupLike.likerGroupId", "=", groupId),
|
||||
eb("GroupLike.targetGroupId", "=", groupId),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function morphGroups({
|
||||
survivingGroupId,
|
||||
otherGroupId,
|
||||
}: {
|
||||
survivingGroupId: number;
|
||||
otherGroupId: number;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
// reset chat code so previous messages are not visible
|
||||
await trx
|
||||
.updateTable("Group")
|
||||
.set({ chatCode: shortNanoid() })
|
||||
.where("Group.id", "=", survivingGroupId)
|
||||
.execute();
|
||||
|
||||
const otherGroupMembers = await trx
|
||||
.selectFrom("GroupMember")
|
||||
.select(["GroupMember.userId", "GroupMember.role"])
|
||||
.where("GroupMember.groupId", "=", otherGroupId)
|
||||
.execute();
|
||||
|
||||
for (const member of otherGroupMembers) {
|
||||
const oldRole = otherGroupMembers.find(
|
||||
(m) => m.userId === member.userId,
|
||||
)?.role;
|
||||
invariant(oldRole, "Member lacking a role");
|
||||
|
||||
await trx
|
||||
.updateTable("GroupMember")
|
||||
.set({
|
||||
role:
|
||||
oldRole === "OWNER"
|
||||
? "MANAGER"
|
||||
: oldRole === "MANAGER"
|
||||
? "MANAGER"
|
||||
: "REGULAR",
|
||||
groupId: survivingGroupId,
|
||||
})
|
||||
.where("GroupMember.groupId", "=", otherGroupId)
|
||||
.where("GroupMember.userId", "=", member.userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
await deleteLikesByGroupId(survivingGroupId, trx);
|
||||
await refreshGroup(survivingGroupId, trx);
|
||||
|
||||
await trx
|
||||
.deleteFrom("Group")
|
||||
.where("Group.id", "=", otherGroupId)
|
||||
.execute();
|
||||
|
||||
if (!(await isGroupCorrect(survivingGroupId, trx))) {
|
||||
throw new SendouQError(
|
||||
"Group has too many members or member in multiple groups",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Check that the group has at most FULL_GROUP_SIZE members and each member is only in this group */
|
||||
async function isGroupCorrect(
|
||||
groupId: number,
|
||||
trx: Transaction<DB>,
|
||||
): Promise<boolean> {
|
||||
const members = await trx
|
||||
.selectFrom("GroupMember")
|
||||
.select("GroupMember.userId")
|
||||
.where("GroupMember.groupId", "=", groupId)
|
||||
.execute();
|
||||
|
||||
if (members.length > FULL_GROUP_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
const otherGroup = await trx
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("Group", "Group.id", "GroupMember.groupId")
|
||||
.select(["Group.id"])
|
||||
.where("GroupMember.userId", "=", member.userId)
|
||||
.where("Group.status", "!=", "INACTIVE")
|
||||
.where("GroupMember.groupId", "!=", groupId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (otherGroup) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function addMember(
|
||||
groupId: number,
|
||||
{
|
||||
userId,
|
||||
role = "REGULAR",
|
||||
}: {
|
||||
userId: number;
|
||||
role?: Tables["GroupMember"]["role"];
|
||||
},
|
||||
) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.insertInto("GroupMember")
|
||||
.values({
|
||||
groupId,
|
||||
userId,
|
||||
role,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await deleteLikesByGroupId(groupId, trx);
|
||||
|
||||
if (!(await isGroupCorrect(groupId, trx))) {
|
||||
throw new SendouQError(
|
||||
"Group has too many members or member in multiple groups",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function allLikesByGroupId(groupId: number) {
|
||||
const rows = await db
|
||||
.selectFrom("GroupLike")
|
||||
.select([
|
||||
"GroupLike.likerGroupId",
|
||||
"GroupLike.targetGroupId",
|
||||
"GroupLike.isRechallenge",
|
||||
])
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("GroupLike.likerGroupId", "=", groupId),
|
||||
eb("GroupLike.targetGroupId", "=", groupId),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return {
|
||||
given: rows
|
||||
.filter((row) => row.likerGroupId === groupId)
|
||||
.map((row) => ({
|
||||
groupId: row.targetGroupId,
|
||||
isRechallenge: row.isRechallenge,
|
||||
})),
|
||||
received: rows
|
||||
.filter((row) => row.targetGroupId === groupId)
|
||||
.map((row) => ({
|
||||
groupId: row.likerGroupId,
|
||||
isRechallenge: row.isRechallenge,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function rechallenge({
|
||||
likerGroupId,
|
||||
targetGroupId,
|
||||
}: {
|
||||
likerGroupId: number;
|
||||
targetGroupId: number;
|
||||
}) {
|
||||
return db
|
||||
.updateTable("GroupLike")
|
||||
.set({ isRechallenge: 1 })
|
||||
.where("likerGroupId", "=", likerGroupId)
|
||||
.where("targetGroupId", "=", targetGroupId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves information about users who have trusted the specified user,
|
||||
* including their associated teams and explicit trust relationships. Banned users are excluded.
|
||||
*/
|
||||
export async function usersThatTrusted(userId: number) {
|
||||
const teams = await db
|
||||
.selectFrom("TeamMemberWithSecondary")
|
||||
.innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
|
||||
.select(["Team.id", "Team.name", "TeamMemberWithSecondary.isMainTeam"])
|
||||
.where("userId", "=", userId)
|
||||
.execute();
|
||||
|
||||
const rows = await db
|
||||
.selectFrom("TeamMemberWithSecondary")
|
||||
.innerJoin("User", "User.id", "TeamMemberWithSecondary.userId")
|
||||
.innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id")
|
||||
.select([
|
||||
...COMMON_USER_FIELDS,
|
||||
"User.inGameName",
|
||||
"TeamMemberWithSecondary.teamId",
|
||||
])
|
||||
.where(
|
||||
"TeamMemberWithSecondary.teamId",
|
||||
"in",
|
||||
teams.map((t) => t.id),
|
||||
)
|
||||
.union((eb) =>
|
||||
eb
|
||||
.selectFrom("TrustRelationship")
|
||||
.innerJoin("User", "User.id", "TrustRelationship.trustGiverUserId")
|
||||
.innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id")
|
||||
.select([
|
||||
...COMMON_USER_FIELDS,
|
||||
"User.inGameName",
|
||||
sql<any>`null`.as("teamId"),
|
||||
])
|
||||
.where("TrustRelationship.trustReceiverUserId", "=", userId),
|
||||
)
|
||||
.execute();
|
||||
|
||||
const rowsWithoutBanned = rows.filter((row) => !userIsBanned(row.id));
|
||||
|
||||
const teamMemberIds = rowsWithoutBanned
|
||||
.filter((row) => row.teamId)
|
||||
.map((row) => row.id);
|
||||
|
||||
// we want user to show twice if member of two different teams
|
||||
// but we don't want a user from the team to show in teamless section
|
||||
const deduplicatedRows = rowsWithoutBanned.filter(
|
||||
(row) => row.teamId || !teamMemberIds.includes(row.id),
|
||||
);
|
||||
|
||||
// done here at not sql just because it was easier to do here ignoring case
|
||||
deduplicatedRows.sort((a, b) => a.username.localeCompare(b.username));
|
||||
|
||||
return {
|
||||
teams: teams.sort((a, b) => b.isMainTeam - a.isMainTeam),
|
||||
trusters: deduplicatedRows,
|
||||
};
|
||||
}
|
||||
|
||||
/** Update the timestamp of the trust relationship, delaying its automatic deletion */
|
||||
export async function refreshTrust({
|
||||
trustGiverUserId,
|
||||
trustReceiverUserId,
|
||||
}: {
|
||||
trustGiverUserId: number;
|
||||
trustReceiverUserId: number;
|
||||
}) {
|
||||
return db
|
||||
.updateTable("TrustRelationship")
|
||||
.set({ lastUsedAt: databaseTimestampNow() })
|
||||
.where("trustGiverUserId", "=", trustGiverUserId)
|
||||
.where("trustReceiverUserId", "=", trustReceiverUserId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function deleteOldTrust() {
|
||||
const twoMonthsAgo = sub(new Date(), { months: 2 });
|
||||
|
||||
return db
|
||||
.deleteFrom("TrustRelationship")
|
||||
.where("lastUsedAt", "<", dateToDatabaseTimestamp(twoMonthsAgo))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
export async function setOldGroupsAsInactive() {
|
||||
const oneHourAgo = sub(new Date(), { hours: 1 });
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const groupsToSetInactive = await trx
|
||||
.selectFrom("Group")
|
||||
.leftJoin("GroupMatch", (join) =>
|
||||
join.on((eb) =>
|
||||
eb.or([
|
||||
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
|
||||
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.select(["Group.id"])
|
||||
.where("status", "!=", "INACTIVE")
|
||||
.where("GroupMatch.id", "is", null)
|
||||
.where("latestActionAt", "<", dateToDatabaseTimestamp(oneHourAgo))
|
||||
.execute();
|
||||
|
||||
return trx
|
||||
.updateTable("Group")
|
||||
.set({ status: "INACTIVE" })
|
||||
.where(
|
||||
"Group.id",
|
||||
"in",
|
||||
groupsToSetInactive.map((g) => g.id),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
});
|
||||
}
|
||||
|
||||
export async function mapModePreferencesBySeasonNth(seasonNth: number) {
|
||||
return db
|
||||
.selectFrom("Skill")
|
||||
.innerJoin("User", "User.id", "Skill.userId")
|
||||
.select("User.mapModePreferences")
|
||||
.where("Skill.season", "=", seasonNth)
|
||||
.where("Skill.userId", "is not", null)
|
||||
.where("User.mapModePreferences", "is not", null)
|
||||
.groupBy("Skill.userId")
|
||||
.$narrowType<{ mapModePreferences: UserMapModePreferences }>()
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findRecentlyFinishedMatches() {
|
||||
const twoHoursAgo = sub(new Date(), { hours: 2 });
|
||||
|
||||
const rows = await db
|
||||
.selectFrom("GroupMatch")
|
||||
.select((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("GroupMember")
|
||||
.select("GroupMember.userId")
|
||||
.whereRef("GroupMember.groupId", "=", "GroupMatch.alphaGroupId"),
|
||||
).as("groupAlphaMemberIds"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("GroupMember")
|
||||
.select("GroupMember.userId")
|
||||
.whereRef("GroupMember.groupId", "=", "GroupMatch.bravoGroupId"),
|
||||
).as("groupBravoMemberIds"),
|
||||
])
|
||||
.where("GroupMatch.reportedAt", "is not", null)
|
||||
.where("GroupMatch.reportedAt", ">", dateToDatabaseTimestamp(twoHoursAgo))
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => ({
|
||||
groupAlphaMemberIds: row.groupAlphaMemberIds.map((m) => m.userId),
|
||||
groupBravoMemberIds: row.groupBravoMemberIds.map((m) => m.userId),
|
||||
}));
|
||||
}
|
||||
|
||||
export function addLike({
|
||||
likerGroupId,
|
||||
targetGroupId,
|
||||
}: {
|
||||
likerGroupId: number;
|
||||
targetGroupId: number;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
try {
|
||||
await trx
|
||||
.insertInto("GroupLike")
|
||||
.values({ likerGroupId, targetGroupId })
|
||||
.onConflict((oc) =>
|
||||
oc.columns(["likerGroupId", "targetGroupId"]).doNothing(),
|
||||
)
|
||||
.execute();
|
||||
} catch (error) {
|
||||
if (errorIsSqliteForeignKeyConstraintFailure(error)) {
|
||||
throw new SendouQError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await refreshGroup(likerGroupId, trx);
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteLike({
|
||||
likerGroupId,
|
||||
targetGroupId,
|
||||
}: {
|
||||
likerGroupId: number;
|
||||
targetGroupId: number;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.deleteFrom("GroupLike")
|
||||
.where("likerGroupId", "=", likerGroupId)
|
||||
.where("targetGroupId", "=", targetGroupId)
|
||||
.execute();
|
||||
|
||||
await refreshGroup(likerGroupId, trx);
|
||||
});
|
||||
}
|
||||
|
||||
export function leaveGroup(userId: number) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const userGroup = await trx
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("Group", "Group.id", "GroupMember.groupId")
|
||||
.select(["Group.id", "GroupMember.role"])
|
||||
.where("userId", "=", userId)
|
||||
.where("Group.status", "!=", "INACTIVE")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.deleteFrom("GroupMember")
|
||||
.where("userId", "=", userId)
|
||||
.where("GroupMember.groupId", "=", userGroup.id)
|
||||
.execute();
|
||||
|
||||
const remainingMembers = await trx
|
||||
.selectFrom("GroupMember")
|
||||
.select(["userId", "role"])
|
||||
.where("groupId", "=", userGroup.id)
|
||||
.execute();
|
||||
|
||||
if (remainingMembers.length === 0) {
|
||||
await trx.deleteFrom("Group").where("id", "=", userGroup.id).execute();
|
||||
return;
|
||||
}
|
||||
|
||||
if (userGroup.role === "OWNER") {
|
||||
const newOwner =
|
||||
remainingMembers.find((m) => m.role === "MANAGER") ??
|
||||
remainingMembers[0];
|
||||
|
||||
await trx
|
||||
.updateTable("GroupMember")
|
||||
.set({ role: "OWNER" })
|
||||
.where("userId", "=", newOwner.userId)
|
||||
.where("groupId", "=", userGroup.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
const match = await trx
|
||||
.selectFrom("GroupMatch")
|
||||
.select(["GroupMatch.id"])
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("alphaGroupId", "=", userGroup.id),
|
||||
eb("bravoGroupId", "=", userGroup.id),
|
||||
]),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (match) {
|
||||
throw new SendouQError("Can't leave group when already in a match");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function refreshGroup(groupId: number, trx?: Transaction<DB>) {
|
||||
return (trx ?? db)
|
||||
.updateTable("Group")
|
||||
.set({ latestActionAt: databaseTimestampNow() })
|
||||
.where("Group.id", "=", groupId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function updateMemberNote({
|
||||
groupId,
|
||||
userId,
|
||||
value,
|
||||
}: {
|
||||
groupId: number;
|
||||
userId: number;
|
||||
value: string | null;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.updateTable("GroupMember")
|
||||
.set({ note: value })
|
||||
.where("groupId", "=", groupId)
|
||||
.where("userId", "=", userId)
|
||||
.execute();
|
||||
|
||||
await refreshGroup(groupId, trx);
|
||||
});
|
||||
}
|
||||
|
||||
export function updateMemberRole({
|
||||
userId,
|
||||
groupId,
|
||||
role,
|
||||
}: {
|
||||
userId: number;
|
||||
groupId: number;
|
||||
role: Tables["GroupMember"]["role"];
|
||||
}) {
|
||||
if (role === "OWNER") {
|
||||
throw new Error("Can't set role to OWNER with this function");
|
||||
}
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.updateTable("GroupMember")
|
||||
.set({ role })
|
||||
.where("userId", "=", userId)
|
||||
.where("groupId", "=", groupId)
|
||||
.execute();
|
||||
|
||||
await refreshGroup(groupId, trx);
|
||||
});
|
||||
}
|
||||
|
||||
export function setPreparingGroupAsActive(groupId: number) {
|
||||
return db
|
||||
.updateTable("Group")
|
||||
.set({ status: "ACTIVE", latestActionAt: databaseTimestampNow() })
|
||||
.where("id", "=", groupId)
|
||||
.where("status", "=", "PREPARING")
|
||||
.execute();
|
||||
}
|
||||
|
|
@ -3,41 +3,20 @@ import { redirect } from "@remix-run/node";
|
|||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import type { LookingGroupWithInviteCode } from "~/features/sendouq/q-types";
|
||||
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
||||
import {
|
||||
createMatchMemento,
|
||||
matchMapList,
|
||||
} from "~/features/sendouq-match/core/match.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
errorToast,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
|
||||
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
|
||||
import { groupAfterMorph } from "../core/groups";
|
||||
import { membersNeededForFull } from "../core/groups.server";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
|
||||
import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server";
|
||||
import { lookingSchema } from "../q-schemas.server";
|
||||
import { addLike } from "../queries/addLike.server";
|
||||
import { addManagerRole } from "../queries/addManagerRole.server";
|
||||
import { chatCodeByGroupId } from "../queries/chatCodeByGroupId.server";
|
||||
import { createMatch } from "../queries/createMatch.server";
|
||||
import { deleteLike } from "../queries/deleteLike.server";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { groupHasMatch } from "../queries/groupHasMatch.server";
|
||||
import { groupSize } from "../queries/groupSize.server";
|
||||
import { groupSuccessorOwner } from "../queries/groupSuccessorOwner";
|
||||
import { leaveGroup } from "../queries/leaveGroup.server";
|
||||
import { likeExists } from "../queries/likeExists.server";
|
||||
import { morphGroups } from "../queries/morphGroups.server";
|
||||
import { refreshGroup } from "../queries/refreshGroup.server";
|
||||
import { removeManagerRole } from "../queries/removeManagerRole.server";
|
||||
import { updateNote } from "../queries/updateNote.server";
|
||||
import { SendouQError } from "../q-utils.server";
|
||||
|
||||
// this function doesn't throw normally because we are assuming
|
||||
// if there is a validation error the user saw stale data
|
||||
|
|
@ -48,362 +27,278 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
request,
|
||||
schema: lookingSchema,
|
||||
});
|
||||
const currentGroup = findCurrentGroupByUserId(user.id);
|
||||
const currentGroup = SendouQ.findOwnGroup(user.id);
|
||||
if (!currentGroup) return null;
|
||||
|
||||
// this throws because there should normally be no way user loses ownership by the action of some other user
|
||||
const validateIsGroupOwner = () =>
|
||||
errorToastIfFalsy(currentGroup.role === "OWNER", "Not owner");
|
||||
const isGroupManager = () =>
|
||||
currentGroup.role === "MANAGER" || currentGroup.role === "OWNER";
|
||||
try {
|
||||
// this throws because there should normally be no way user loses ownership by the action of some other user
|
||||
const validateIsGroupOwner = () =>
|
||||
errorToastIfFalsy(currentGroup.usersRole === "OWNER", "Not owner");
|
||||
const isGroupManager = () =>
|
||||
currentGroup.usersRole === "MANAGER" ||
|
||||
currentGroup.usersRole === "OWNER";
|
||||
|
||||
switch (data._action) {
|
||||
case "LIKE": {
|
||||
if (!isGroupManager()) return null;
|
||||
switch (data._action) {
|
||||
case "LIKE": {
|
||||
if (!isGroupManager()) return null;
|
||||
|
||||
try {
|
||||
addLike({
|
||||
await SQGroupRepository.addLike({
|
||||
likerGroupId: currentGroup.id,
|
||||
targetGroupId: data.targetGroupId,
|
||||
});
|
||||
} catch (e) {
|
||||
// the group disbanded before we could like it
|
||||
if (errorIsSqliteForeignKeyConstraintFailure(e)) return null;
|
||||
|
||||
throw e;
|
||||
}
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
const targetChatCode = chatCodeByGroupId(data.targetGroupId);
|
||||
if (targetChatCode) {
|
||||
ChatSystemMessage.send({
|
||||
room: targetChatCode,
|
||||
type: "LIKE_RECEIVED",
|
||||
revalidateOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "RECHALLENGE": {
|
||||
if (!isGroupManager()) return null;
|
||||
|
||||
await QRepository.rechallenge({
|
||||
likerGroupId: currentGroup.id,
|
||||
targetGroupId: data.targetGroupId,
|
||||
});
|
||||
|
||||
const targetChatCode = chatCodeByGroupId(data.targetGroupId);
|
||||
if (targetChatCode) {
|
||||
ChatSystemMessage.send({
|
||||
room: targetChatCode,
|
||||
type: "LIKE_RECEIVED",
|
||||
revalidateOnly: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "UNLIKE": {
|
||||
if (!isGroupManager()) return null;
|
||||
|
||||
deleteLike({
|
||||
likerGroupId: currentGroup.id,
|
||||
targetGroupId: data.targetGroupId,
|
||||
});
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "GROUP_UP": {
|
||||
if (!isGroupManager()) return null;
|
||||
if (
|
||||
!likeExists({
|
||||
targetGroupId: currentGroup.id,
|
||||
likerGroupId: data.targetGroupId,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lookingGroups = await QRepository.findLookingGroups({
|
||||
maxGroupSize: membersNeededForFull(groupSize(currentGroup.id)),
|
||||
ownGroupId: currentGroup.id,
|
||||
includeChatCode: true,
|
||||
});
|
||||
|
||||
const ourGroup = lookingGroups.find(
|
||||
(group) => group.id === currentGroup.id,
|
||||
);
|
||||
if (!ourGroup) return null;
|
||||
const theirGroup = lookingGroups.find(
|
||||
(group) => group.id === data.targetGroupId,
|
||||
);
|
||||
if (!theirGroup) return null;
|
||||
|
||||
const { id: survivingGroupId } = groupAfterMorph({
|
||||
liker: "THEM",
|
||||
ourGroup,
|
||||
theirGroup,
|
||||
});
|
||||
|
||||
const otherGroup =
|
||||
ourGroup.id === survivingGroupId ? theirGroup : ourGroup;
|
||||
|
||||
invariant(ourGroup.members, "our group has no members");
|
||||
invariant(otherGroup.members, "other group has no members");
|
||||
|
||||
morphGroups({
|
||||
survivingGroupId,
|
||||
otherGroupId: otherGroup.id,
|
||||
newMembers: otherGroup.members.map((m) => m.id),
|
||||
});
|
||||
refreshGroup(survivingGroupId);
|
||||
|
||||
if (ourGroup.chatCode && theirGroup.chatCode) {
|
||||
ChatSystemMessage.send([
|
||||
{
|
||||
room: ourGroup.chatCode,
|
||||
type: "NEW_GROUP",
|
||||
const targetChatCode = SendouQ.findUncensoredGroupById(
|
||||
data.targetGroupId,
|
||||
)?.chatCode;
|
||||
if (targetChatCode) {
|
||||
ChatSystemMessage.send({
|
||||
room: targetChatCode,
|
||||
type: "LIKE_RECEIVED",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
{
|
||||
room: theirGroup.chatCode,
|
||||
type: "NEW_GROUP",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "RECHALLENGE": {
|
||||
if (!isGroupManager()) return null;
|
||||
|
||||
break;
|
||||
}
|
||||
case "MATCH_UP_RECHALLENGE":
|
||||
case "MATCH_UP": {
|
||||
if (!isGroupManager()) return null;
|
||||
if (
|
||||
!likeExists({
|
||||
targetGroupId: currentGroup.id,
|
||||
likerGroupId: data.targetGroupId,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lookingGroups = await QRepository.findLookingGroups({
|
||||
minGroupSize: FULL_GROUP_SIZE,
|
||||
ownGroupId: currentGroup.id,
|
||||
includeChatCode: true,
|
||||
});
|
||||
|
||||
const ourGroup = lookingGroups.find(
|
||||
(group) => group.id === currentGroup.id,
|
||||
);
|
||||
if (!ourGroup) return null;
|
||||
const theirGroup = lookingGroups.find(
|
||||
(group) => group.id === data.targetGroupId,
|
||||
);
|
||||
if (!theirGroup) return null;
|
||||
|
||||
errorToastIfFalsy(
|
||||
ourGroup.members.length === FULL_GROUP_SIZE,
|
||||
"Our group is not full",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
theirGroup.members.length === FULL_GROUP_SIZE,
|
||||
"Their group is not full",
|
||||
);
|
||||
|
||||
errorToastIfFalsy(
|
||||
!groupHasMatch(ourGroup.id),
|
||||
"Our group already has a match",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
!groupHasMatch(theirGroup.id),
|
||||
"Their group already has a match",
|
||||
);
|
||||
|
||||
const ourGroupPreferences = await QRepository.mapModePreferencesByGroupId(
|
||||
ourGroup.id,
|
||||
);
|
||||
const theirGroupPreferences =
|
||||
await QRepository.mapModePreferencesByGroupId(theirGroup.id);
|
||||
const mapList = await matchMapList(
|
||||
{
|
||||
id: ourGroup.id,
|
||||
preferences: ourGroupPreferences,
|
||||
},
|
||||
{
|
||||
id: theirGroup.id,
|
||||
preferences: theirGroupPreferences,
|
||||
ignoreModePreferences: data._action === "MATCH_UP_RECHALLENGE",
|
||||
},
|
||||
);
|
||||
|
||||
const memberInManyGroups = verifyNoMemberInTwoGroups(
|
||||
[...ourGroup.members, ...theirGroup.members],
|
||||
lookingGroups,
|
||||
);
|
||||
if (memberInManyGroups) {
|
||||
logger.error("User in two groups preventing match creation", {
|
||||
userId: memberInManyGroups.id,
|
||||
await SQGroupRepository.rechallenge({
|
||||
likerGroupId: currentGroup.id,
|
||||
targetGroupId: data.targetGroupId,
|
||||
});
|
||||
|
||||
errorToast(
|
||||
`${memberInManyGroups.username} is in two groups so match can't be started`,
|
||||
const targetChatCode = SendouQ.findUncensoredGroupById(
|
||||
data.targetGroupId,
|
||||
)?.chatCode;
|
||||
if (targetChatCode) {
|
||||
ChatSystemMessage.send({
|
||||
room: targetChatCode,
|
||||
type: "LIKE_RECEIVED",
|
||||
revalidateOnly: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "UNLIKE": {
|
||||
if (!isGroupManager()) return null;
|
||||
|
||||
await SQGroupRepository.deleteLike({
|
||||
likerGroupId: currentGroup.id,
|
||||
targetGroupId: data.targetGroupId,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "GROUP_UP": {
|
||||
if (!isGroupManager()) return null;
|
||||
|
||||
const allLikes = await SQGroupRepository.allLikesByGroupId(
|
||||
data.targetGroupId,
|
||||
);
|
||||
}
|
||||
if (!allLikes.given.some((like) => like.groupId === currentGroup.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createdMatch = createMatch({
|
||||
alphaGroupId: ourGroup.id,
|
||||
bravoGroupId: theirGroup.id,
|
||||
mapList,
|
||||
memento: createMatchMemento({
|
||||
own: { group: ourGroup, preferences: ourGroupPreferences },
|
||||
their: { group: theirGroup, preferences: theirGroupPreferences },
|
||||
mapList,
|
||||
}),
|
||||
});
|
||||
const ourGroup = SendouQ.findOwnGroup(user.id);
|
||||
const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId);
|
||||
if (!ourGroup || !theirGroup) return null;
|
||||
|
||||
if (ourGroup.chatCode && theirGroup.chatCode) {
|
||||
ChatSystemMessage.send([
|
||||
{
|
||||
room: ourGroup.chatCode,
|
||||
type: "MATCH_STARTED",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
{
|
||||
room: theirGroup.chatCode,
|
||||
type: "MATCH_STARTED",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
notify({
|
||||
userIds: [
|
||||
...ourGroup.members.map((m) => m.id),
|
||||
...theirGroup.members.map((m) => m.id),
|
||||
],
|
||||
defaultSeenUserIds: [user.id],
|
||||
notification: {
|
||||
type: "SQ_NEW_MATCH",
|
||||
meta: {
|
||||
matchId: createdMatch.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
throw redirect(sendouQMatchPage(createdMatch.id));
|
||||
}
|
||||
case "GIVE_MANAGER": {
|
||||
validateIsGroupOwner();
|
||||
|
||||
addManagerRole({
|
||||
groupId: currentGroup.id,
|
||||
userId: data.userId,
|
||||
});
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "REMOVE_MANAGER": {
|
||||
validateIsGroupOwner();
|
||||
|
||||
removeManagerRole({
|
||||
groupId: currentGroup.id,
|
||||
userId: data.userId,
|
||||
});
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "LEAVE_GROUP": {
|
||||
errorToastIfFalsy(
|
||||
!currentGroup.matchId,
|
||||
"Can't leave group while in a match",
|
||||
);
|
||||
let newOwnerId: number | null = null;
|
||||
if (currentGroup.role === "OWNER") {
|
||||
newOwnerId = groupSuccessorOwner(currentGroup.id);
|
||||
}
|
||||
|
||||
leaveGroup({
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
newOwnerId,
|
||||
wasOwner: currentGroup.role === "OWNER",
|
||||
});
|
||||
|
||||
const targetChatCode = chatCodeByGroupId(currentGroup.id);
|
||||
if (targetChatCode) {
|
||||
ChatSystemMessage.send({
|
||||
room: targetChatCode,
|
||||
type: "USER_LEFT",
|
||||
context: { name: user.username },
|
||||
const { id: survivingGroupId } = groupAfterMorph({
|
||||
liker: "THEM",
|
||||
ourGroup,
|
||||
theirGroup,
|
||||
});
|
||||
|
||||
const otherGroup =
|
||||
ourGroup.id === survivingGroupId ? theirGroup : ourGroup;
|
||||
|
||||
await SQGroupRepository.morphGroups({
|
||||
survivingGroupId,
|
||||
otherGroupId: otherGroup.id,
|
||||
});
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
if (ourGroup.chatCode && theirGroup.chatCode) {
|
||||
ChatSystemMessage.send([
|
||||
{
|
||||
room: ourGroup.chatCode,
|
||||
type: "NEW_GROUP",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
{
|
||||
room: theirGroup.chatCode,
|
||||
type: "NEW_GROUP",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "MATCH_UP_RECHALLENGE":
|
||||
case "MATCH_UP": {
|
||||
if (!isGroupManager()) return null;
|
||||
|
||||
throw redirect(SENDOUQ_PAGE);
|
||||
}
|
||||
case "KICK_FROM_GROUP": {
|
||||
validateIsGroupOwner();
|
||||
errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself");
|
||||
const ourGroup = SendouQ.findOwnGroup(user.id);
|
||||
const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId);
|
||||
if (!ourGroup || !theirGroup) return null;
|
||||
|
||||
leaveGroup({
|
||||
groupId: currentGroup.id,
|
||||
userId: data.userId,
|
||||
newOwnerId: null,
|
||||
wasOwner: false,
|
||||
});
|
||||
const ourGroupPreferences =
|
||||
await SQGroupRepository.mapModePreferencesByGroupId(ourGroup.id);
|
||||
const theirGroupPreferences =
|
||||
await SQGroupRepository.mapModePreferencesByGroupId(theirGroup.id);
|
||||
const mapList = await matchMapList(
|
||||
{
|
||||
id: ourGroup.id,
|
||||
preferences: ourGroupPreferences,
|
||||
},
|
||||
{
|
||||
id: theirGroup.id,
|
||||
preferences: theirGroupPreferences,
|
||||
ignoreModePreferences: data._action === "MATCH_UP_RECHALLENGE",
|
||||
},
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case "REFRESH_GROUP": {
|
||||
refreshGroup(currentGroup.id);
|
||||
const createdMatch = await SQMatchRepository.create({
|
||||
alphaGroupId: ourGroup.id,
|
||||
bravoGroupId: theirGroup.id,
|
||||
mapList,
|
||||
memento: createMatchMemento({
|
||||
own: { group: ourGroup, preferences: ourGroupPreferences },
|
||||
their: { group: theirGroup, preferences: theirGroupPreferences },
|
||||
mapList,
|
||||
}),
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "UPDATE_NOTE": {
|
||||
updateNote({
|
||||
note: data.value,
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
});
|
||||
refreshGroup(currentGroup.id);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
break;
|
||||
}
|
||||
case "DELETE_PRIVATE_USER_NOTE": {
|
||||
await QRepository.deletePrivateUserNote({
|
||||
authorId: user.id,
|
||||
targetId: data.targetId,
|
||||
});
|
||||
if (ourGroup.chatCode && theirGroup.chatCode) {
|
||||
ChatSystemMessage.send([
|
||||
{
|
||||
room: ourGroup.chatCode,
|
||||
type: "MATCH_STARTED",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
{
|
||||
room: theirGroup.chatCode,
|
||||
type: "MATCH_STARTED",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
break;
|
||||
notify({
|
||||
userIds: [
|
||||
...ourGroup.members.map((m) => m.id),
|
||||
...theirGroup.members.map((m) => m.id),
|
||||
],
|
||||
defaultSeenUserIds: [user.id],
|
||||
notification: {
|
||||
type: "SQ_NEW_MATCH",
|
||||
meta: {
|
||||
matchId: createdMatch.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
throw redirect(sendouQMatchPage(createdMatch.id));
|
||||
}
|
||||
case "GIVE_MANAGER": {
|
||||
validateIsGroupOwner();
|
||||
|
||||
await SQGroupRepository.updateMemberRole({
|
||||
groupId: currentGroup.id,
|
||||
userId: data.userId,
|
||||
role: "MANAGER",
|
||||
});
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
break;
|
||||
}
|
||||
case "REMOVE_MANAGER": {
|
||||
validateIsGroupOwner();
|
||||
|
||||
await SQGroupRepository.updateMemberRole({
|
||||
groupId: currentGroup.id,
|
||||
userId: data.userId,
|
||||
role: "REGULAR",
|
||||
});
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
break;
|
||||
}
|
||||
case "LEAVE_GROUP": {
|
||||
await SQGroupRepository.leaveGroup(user.id);
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const targetChatCode = SendouQ.findUncensoredGroupById(
|
||||
currentGroup.id,
|
||||
)?.chatCode;
|
||||
if (targetChatCode) {
|
||||
ChatSystemMessage.send({
|
||||
room: targetChatCode,
|
||||
type: "USER_LEFT",
|
||||
context: { name: user.username },
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(SENDOUQ_PAGE);
|
||||
}
|
||||
case "KICK_FROM_GROUP": {
|
||||
validateIsGroupOwner();
|
||||
errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself");
|
||||
|
||||
await SQGroupRepository.leaveGroup(data.userId);
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
break;
|
||||
}
|
||||
case "REFRESH_GROUP": {
|
||||
await SQGroupRepository.refreshGroup(currentGroup.id);
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
break;
|
||||
}
|
||||
case "UPDATE_NOTE": {
|
||||
await SQGroupRepository.updateMemberNote({
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
value: data.value,
|
||||
});
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
break;
|
||||
}
|
||||
case "DELETE_PRIVATE_USER_NOTE": {
|
||||
await PrivateUserNoteRepository.del({
|
||||
authorId: user.id,
|
||||
targetId: data.targetId,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
// some errors are expected to happen, for example they might request two groups at the same time
|
||||
// then after morphing one group the other request fails because the group no longer exists
|
||||
// return null causes loaders to run and they see the fresh state again instead of error page
|
||||
if (error instanceof SendouQError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Sanity check that no member is in two groups due to a bug or race condition.
|
||||
*
|
||||
* @returns null if no member is in two groups, otherwise return the problematic member
|
||||
*/
|
||||
function verifyNoMemberInTwoGroups(
|
||||
members: LookingGroupWithInviteCode["members"],
|
||||
allGroups: LookingGroupWithInviteCode[],
|
||||
) {
|
||||
for (const member of members) {
|
||||
if (
|
||||
allGroups.filter((group) => group.members.some((m) => m.id === member.id))
|
||||
.length > 1
|
||||
) {
|
||||
return member;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,12 @@ import { redirect } from "@remix-run/node";
|
|||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
|
||||
import { hasGroupManagerPerms } from "../core/groups";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
|
||||
import { preparingSchema } from "../q-schemas.server";
|
||||
import { addMember } from "../queries/addMember.server";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { refreshGroup } from "../queries/refreshGroup.server";
|
||||
import { setGroupAsActive } from "../queries/setGroupAsActive.server";
|
||||
|
||||
export type SendouQPreparingAction = typeof action;
|
||||
|
||||
|
|
@ -26,10 +19,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
schema: preparingSchema,
|
||||
});
|
||||
|
||||
const currentGroup = findCurrentGroupByUserId(user.id);
|
||||
errorToastIfFalsy(currentGroup, "No group found");
|
||||
const ownGroup = SendouQ.findOwnGroup(user.id);
|
||||
errorToastIfFalsy(ownGroup, "No group found");
|
||||
|
||||
if (!hasGroupManagerPerms(currentGroup.role)) {
|
||||
// no perms, possibly just lost them so no more graceful degradation
|
||||
if (ownGroup.usersRole === "REGULAR") {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -38,48 +32,37 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
|
||||
switch (data._action) {
|
||||
case "JOIN_QUEUE": {
|
||||
if (currentGroup.status !== "PREPARING") {
|
||||
return null;
|
||||
}
|
||||
await SQGroupRepository.setPreparingGroupAsActive(ownGroup.id);
|
||||
|
||||
setGroupAsActive(currentGroup.id);
|
||||
refreshGroup(currentGroup.id);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
return redirect(SENDOUQ_LOOKING_PAGE);
|
||||
}
|
||||
case "ADD_TRUSTED": {
|
||||
const available = await QRepository.findActiveGroupMembers();
|
||||
const available = await SQGroupRepository.findActiveGroupMembers();
|
||||
if (available.some(({ userId }) => userId === data.id)) {
|
||||
return { error: "taken" } as const;
|
||||
}
|
||||
|
||||
errorToastIfFalsy(
|
||||
(await QRepository.usersThatTrusted(user.id)).trusters.some(
|
||||
(await SQGroupRepository.usersThatTrusted(user.id)).trusters.some(
|
||||
(trusterUser) => trusterUser.id === data.id,
|
||||
),
|
||||
"Not trusted",
|
||||
);
|
||||
|
||||
const ownGroupWithMembers = await QMatchRepository.findGroupById({
|
||||
groupId: currentGroup.id,
|
||||
});
|
||||
invariant(ownGroupWithMembers, "No own group found");
|
||||
errorToastIfFalsy(
|
||||
ownGroupWithMembers.members.length < FULL_GROUP_SIZE,
|
||||
"Group is full",
|
||||
);
|
||||
|
||||
addMember({
|
||||
groupId: currentGroup.id,
|
||||
await SQGroupRepository.addMember(ownGroup.id, {
|
||||
userId: data.id,
|
||||
role: "MANAGER",
|
||||
});
|
||||
|
||||
await QRepository.refreshTrust({
|
||||
await SQGroupRepository.refreshTrust({
|
||||
trustGiverUserId: data.id,
|
||||
trustReceiverUserId: user.id,
|
||||
});
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
notify({
|
||||
userIds: [data.id],
|
||||
notification: {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { sql } from "~/db/sql";
|
||||
import * as AdminRepository from "~/features/admin/AdminRepository.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { refreshBannedCache } from "~/features/ban/core/banned.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
||||
import { giveTrust } from "~/features/tournament/queries/giveTrust.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
|
@ -16,13 +15,10 @@ import {
|
|||
SENDOUQ_PREPARING_PAGE,
|
||||
SUSPENDED_PAGE,
|
||||
} from "~/utils/urls";
|
||||
import { FULL_GROUP_SIZE, JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
|
||||
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
|
||||
import { JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
|
||||
import { frontPageSchema } from "../q-schemas.server";
|
||||
import { userCanJoinQueueAt } from "../q-utils";
|
||||
import { addMember } from "../queries/addMember.server";
|
||||
import { deleteLikesByGroupId } from "../queries/deleteLikesByGroupId.server";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findGroupByInviteCode } from "../queries/findGroupByInviteCode.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
|
|
@ -35,11 +31,13 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
case "JOIN_QUEUE": {
|
||||
await validateCanJoinQ(user);
|
||||
|
||||
await QRepository.createGroup({
|
||||
await SQGroupRepository.createGroup({
|
||||
status: data.direct === "true" ? "ACTIVE" : "PREPARING",
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
return redirect(
|
||||
data.direct === "true" ? SENDOUQ_LOOKING_PAGE : SENDOUQ_PREPARING_PAGE,
|
||||
);
|
||||
|
|
@ -53,34 +51,28 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
);
|
||||
|
||||
const groupInvitedTo =
|
||||
code && user ? findGroupByInviteCode(code) : undefined;
|
||||
code && user ? SendouQ.findGroupByInviteCode(code) : undefined;
|
||||
errorToastIfFalsy(
|
||||
groupInvitedTo,
|
||||
"Invite code doesn't match any active team",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
groupInvitedTo.members.length < FULL_GROUP_SIZE,
|
||||
"Team is full",
|
||||
);
|
||||
|
||||
sql.transaction(() => {
|
||||
addMember({
|
||||
groupId: groupInvitedTo.id,
|
||||
userId: user.id,
|
||||
role: "MANAGER",
|
||||
await SQGroupRepository.addMember(groupInvitedTo.id, {
|
||||
userId: user.id,
|
||||
role: "MANAGER",
|
||||
});
|
||||
|
||||
if (data._action === "JOIN_TEAM_WITH_TRUST") {
|
||||
const owner = groupInvitedTo.members.find((m) => m.role === "OWNER");
|
||||
invariant(owner, "Owner not found");
|
||||
|
||||
giveTrust({
|
||||
trustGiverUserId: user.id,
|
||||
trustReceiverUserId: owner.id,
|
||||
});
|
||||
deleteLikesByGroupId(groupInvitedTo.id);
|
||||
}
|
||||
|
||||
if (data._action === "JOIN_TEAM_WITH_TRUST") {
|
||||
const owner = groupInvitedTo.members.find((m) => m.role === "OWNER");
|
||||
invariant(owner, "Owner not found");
|
||||
|
||||
giveTrust({
|
||||
trustGiverUserId: user.id,
|
||||
trustReceiverUserId: owner.id,
|
||||
});
|
||||
}
|
||||
})();
|
||||
await refreshSendouQInstance();
|
||||
|
||||
return redirect(
|
||||
groupInvitedTo.status === "PREPARING"
|
||||
|
|
@ -132,6 +124,5 @@ async function validateCanJoinQ(user: { id: number; discordId: string }) {
|
|||
const canJoinQueue = userCanJoinQueueAt(user, friendCode) === "NOW";
|
||||
|
||||
errorToastIfFalsy(Seasons.current(), "Season is not active");
|
||||
errorToastIfFalsy(!findCurrentGroupByUserId(user.id), "Already in a group");
|
||||
errorToastIfFalsy(canJoinQueue, "Can't join queue right now");
|
||||
}
|
||||
|
|
|
|||
174
app/features/sendouq/components/GroupCard.module.css
Normal file
174
app/features/sendouq/components/GroupCard.module.css
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
.group {
|
||||
background-color: var(--bg-lighter-solid);
|
||||
width: 100%;
|
||||
border-radius: var(--rounded);
|
||||
padding: var(--s-2-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
position: relative;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.noScreen {
|
||||
background-color: var(--theme-error-transparent);
|
||||
border-radius: 100%;
|
||||
padding: var(--s-1);
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.displayOnly {
|
||||
height: 100%;
|
||||
padding-block-end: var(--s-10);
|
||||
}
|
||||
|
||||
.member {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
background-color: var(--bg-darker);
|
||||
border-radius: var(--rounded);
|
||||
font-size: var(--fonts-xsm);
|
||||
font-weight: var(--semi-bold);
|
||||
padding-inline-end: var(--s-2-5);
|
||||
}
|
||||
|
||||
.name {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-width: 7.5rem;
|
||||
font-size: var(--fonts-xs);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.avatarPositive {
|
||||
outline: 2px solid var(--theme-success-transparent);
|
||||
}
|
||||
|
||||
.avatarNeutral {
|
||||
outline: 2px solid var(--theme-warning-transparent);
|
||||
}
|
||||
|
||||
.avatarNegative {
|
||||
outline: 2px solid var(--theme-error-transparent);
|
||||
}
|
||||
|
||||
.tier {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.tierPlaceholder {
|
||||
min-width: 26.58px;
|
||||
}
|
||||
|
||||
.extraInfo {
|
||||
font-size: var(--fonts-xs);
|
||||
background-color: var(--bg-darker);
|
||||
border-radius: var(--rounded);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
width: max-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
font-weight: var(--semi-bold);
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.extraInfoButton {
|
||||
font-size: var(--fonts-xs);
|
||||
background-color: var(--bg-darker);
|
||||
border-radius: var(--rounded);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
width: max-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.addNoteButton {
|
||||
border: none;
|
||||
padding: 0 var(--s-1-5);
|
||||
color: var(--body);
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--semi-bold);
|
||||
background-color: var(--bg-darker);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.addNoteButtonEdit > svg {
|
||||
color: var(--theme);
|
||||
}
|
||||
|
||||
.addNoteButton > svg {
|
||||
width: 14px;
|
||||
margin-inline-end: var(--s-1);
|
||||
}
|
||||
|
||||
.noteTextarea {
|
||||
height: 4rem !important;
|
||||
}
|
||||
|
||||
.futureMatchMode {
|
||||
border-radius: 100%;
|
||||
background-color: var(--bg-lightest);
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--s-1-5);
|
||||
}
|
||||
|
||||
.vcIcon {
|
||||
height: 15px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.star {
|
||||
min-width: 18px;
|
||||
max-width: 18px;
|
||||
color: var(--theme-secondary);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.starInactive {
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.displayTier {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-darker);
|
||||
padding: var(--s-0-5) var(--s-2-5);
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
bottom: -36px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.popoverButton {
|
||||
background-color: transparent;
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
padding: 0;
|
||||
border: none;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
font-weight: var(--bold);
|
||||
height: 19.8281px;
|
||||
}
|
||||
|
|
@ -17,14 +17,13 @@ import { StarIcon } from "~/components/icons/Star";
|
|||
import { StarFilledIcon } from "~/components/icons/StarFilled";
|
||||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import type { ParsedMemento, Tables } from "~/db/tables";
|
||||
import type { ParsedMemento } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
|
||||
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import type { ModeShort } from "~/modules/in-game-lists/types";
|
||||
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
|
||||
|
|
@ -36,35 +35,37 @@ import {
|
|||
tierImageUrl,
|
||||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import type {
|
||||
SQGroup,
|
||||
SQGroupMember,
|
||||
SQMatchGroup,
|
||||
SQMatchGroupMember,
|
||||
SQOwnGroup,
|
||||
} from "../core/SendouQ.server";
|
||||
import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants";
|
||||
import type { LookingGroup } from "../q-types";
|
||||
import { resolveFutureMatchModes } from "../q-utils";
|
||||
import styles from "./GroupCard.module.css";
|
||||
|
||||
export function GroupCard({
|
||||
group,
|
||||
action,
|
||||
ownRole,
|
||||
ownGroup = false,
|
||||
isExpired = false,
|
||||
displayOnly = false,
|
||||
hideVc = false,
|
||||
hideWeapons = false,
|
||||
hideNote: _hidenote = false,
|
||||
enableKicking,
|
||||
showAddNote,
|
||||
showNote = false,
|
||||
ownGroup,
|
||||
}: {
|
||||
group: Omit<LookingGroup, "createdAt" | "chatCode">;
|
||||
group: SQGroup | SQOwnGroup | SQMatchGroup;
|
||||
action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP" | "MATCH_UP_RECHALLENGE";
|
||||
ownRole?: Tables["GroupMember"]["role"] | "PREVIEWER";
|
||||
ownGroup?: boolean;
|
||||
isExpired?: boolean;
|
||||
displayOnly?: boolean;
|
||||
hideVc?: SqlBool;
|
||||
hideWeapons?: SqlBool;
|
||||
hideNote?: boolean;
|
||||
enableKicking?: boolean;
|
||||
showAddNote?: SqlBool;
|
||||
showNote?: boolean;
|
||||
ownGroup?: SQOwnGroup;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const user = useUser();
|
||||
|
|
@ -76,10 +77,22 @@ export function GroupCard({
|
|||
group.members.length === FULL_GROUP_SIZE ||
|
||||
_hidenote;
|
||||
|
||||
const isOwnGroup = group.id === ownGroup?.id;
|
||||
|
||||
const futureMatchModes = ownGroup
|
||||
? resolveFutureMatchModes({
|
||||
ownGroup,
|
||||
theirGroup: group,
|
||||
})
|
||||
: null;
|
||||
|
||||
const enableKicking = group.usersRole === "OWNER" && !displayOnly;
|
||||
|
||||
return (
|
||||
<GroupCardContainer groupId={group.id} ownGroup={ownGroup}>
|
||||
<GroupCardContainer groupId={group.id} isOwnGroup={isOwnGroup}>
|
||||
<section
|
||||
className={clsx("q__group", { "q__group__display-only": displayOnly })}
|
||||
className={clsx(styles.group, { [styles.displayOnly]: displayOnly })}
|
||||
data-testid="sendouq-group-card"
|
||||
>
|
||||
{group.members ? (
|
||||
<div className="stack md">
|
||||
|
|
@ -87,7 +100,7 @@ export function GroupCard({
|
|||
return (
|
||||
<GroupMember
|
||||
member={member}
|
||||
showActions={ownGroup && ownRole === "OWNER"}
|
||||
showActions={group.usersRole === "OWNER"}
|
||||
key={member.discordId}
|
||||
displayOnly={displayOnly}
|
||||
hideVc={hideVc}
|
||||
|
|
@ -101,30 +114,24 @@ export function GroupCard({
|
|||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{group.futureMatchModes && !group.members ? (
|
||||
{futureMatchModes && !group.members ? (
|
||||
<div
|
||||
className={clsx("stack horizontal", {
|
||||
"justify-between": group.isNoScreen,
|
||||
"justify-center": !group.isNoScreen,
|
||||
"justify-between": group.noScreen,
|
||||
"justify-center": !group.noScreen,
|
||||
})}
|
||||
>
|
||||
<div className="stack horizontal sm justify-center">
|
||||
{group.futureMatchModes.map((mode) => {
|
||||
{futureMatchModes.map((mode) => {
|
||||
return (
|
||||
<div
|
||||
key={mode}
|
||||
className={clsx("q__group__future-match-mode", {
|
||||
"q__group__future-match-mode__rechallenge":
|
||||
group.isRechallenge,
|
||||
})}
|
||||
>
|
||||
<div key={mode} className={styles.futureMatchMode}>
|
||||
<ModeImage mode={mode} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{group.isNoScreen ? (
|
||||
<div className="q__group__no-screen">
|
||||
{group.noScreen ? (
|
||||
<div className={styles.noScreen}>
|
||||
<Image
|
||||
path={specialWeaponImageUrl(SPLATTERCOLOR_SCREEN_ID)}
|
||||
width={22}
|
||||
|
|
@ -135,7 +142,7 @@ export function GroupCard({
|
|||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{group.tier && !displayOnly ? (
|
||||
{group.tier && !displayOnly && !group.members ? (
|
||||
<div className="stack xs text-lighter font-bold items-center justify-center text-xs">
|
||||
<TierImage tier={group.tier} width={100} />
|
||||
<div>
|
||||
|
|
@ -152,26 +159,26 @@ export function GroupCard({
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{group.tier && displayOnly ? (
|
||||
<div className="q__group__display-group-tier">
|
||||
{group.tier && displayOnly && !group.members ? (
|
||||
<div className={styles.displayTier}>
|
||||
<TierImage tier={group.tier} width={38} />
|
||||
{group.tier.name}
|
||||
{group.tier.isPlus ? "+" : ""}
|
||||
</div>
|
||||
) : null}
|
||||
{group.tierRange?.range ? (
|
||||
{group.tierRange ? (
|
||||
<div className="stack md items-center">
|
||||
<div className="stack sm horizontal items-center justify-center">
|
||||
<div className="stack xs items-center">
|
||||
<TierImage tier={group.tierRange.range[0]} width={80} />
|
||||
<div className="text-lighter text-sm font-bold">
|
||||
(-{group.tierRange.diff})
|
||||
({group.tierRange.diff[0]})
|
||||
</div>
|
||||
</div>
|
||||
<SendouPopover
|
||||
popoverClassName="text-main-forced"
|
||||
trigger={
|
||||
<SendouButton className="q__group__or-popover-button">
|
||||
<SendouButton className={styles.popoverButton}>
|
||||
{t("q:looking.range.or")}
|
||||
</SendouButton>
|
||||
}
|
||||
|
|
@ -181,7 +188,7 @@ export function GroupCard({
|
|||
<div className="stack xs items-center">
|
||||
<TierImage tier={group.tierRange.range[1]} width={80} />
|
||||
<div className="text-lighter text-sm font-bold">
|
||||
(+{group.tierRange.diff})
|
||||
(+{group.tierRange.diff[1]})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -196,8 +203,8 @@ export function GroupCard({
|
|||
<GroupSkillDifference skillDifference={group.skillDifference} />
|
||||
) : null}
|
||||
{action &&
|
||||
(ownRole === "OWNER" || ownRole === "MANAGER") &&
|
||||
!isExpired ? (
|
||||
(ownGroup?.usersRole === "OWNER" ||
|
||||
ownGroup?.usersRole === "MANAGER") ? (
|
||||
<fetcher.Form className="stack items-center" method="post">
|
||||
<input type="hidden" name="targetGroupId" value={group.id} />
|
||||
<SubmitButton
|
||||
|
|
@ -218,31 +225,22 @@ export function GroupCard({
|
|||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
) : null}
|
||||
{!group.isRechallenge &&
|
||||
group.rechallengeMatchModes &&
|
||||
(ownRole === "OWNER" || ownRole === "MANAGER") &&
|
||||
!isExpired ? (
|
||||
<RechallengeForm
|
||||
modes={group.rechallengeMatchModes}
|
||||
targetGroupId={group.id}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
</GroupCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupCardContainer({
|
||||
ownGroup,
|
||||
isOwnGroup,
|
||||
groupId,
|
||||
children,
|
||||
}: {
|
||||
ownGroup: boolean;
|
||||
isOwnGroup: boolean;
|
||||
groupId: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// we don't want it to animate
|
||||
if (ownGroup) return <>{children}</>;
|
||||
if (isOwnGroup) return <>{children}</>;
|
||||
|
||||
return <Flipped flipId={groupId}>{children}</Flipped>;
|
||||
}
|
||||
|
|
@ -258,7 +256,7 @@ function GroupMember({
|
|||
showAddNote,
|
||||
showNote,
|
||||
}: {
|
||||
member: NonNullable<LookingGroup["members"]>[number];
|
||||
member: SQGroupMember | SQMatchGroupMember;
|
||||
showActions: boolean;
|
||||
displayOnly?: boolean;
|
||||
hideVc?: SqlBool;
|
||||
|
|
@ -273,8 +271,8 @@ function GroupMember({
|
|||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<div className="stack xxs">
|
||||
<div className="q__group-member">
|
||||
<div className="stack xxs" data-testid="sendouq-group-card-member">
|
||||
<div className={styles.member}>
|
||||
<div className="text-main-forced stack xs horizontal items-center">
|
||||
{showNote && member.privateNote ? (
|
||||
<SendouPopover
|
||||
|
|
@ -284,8 +282,10 @@ function GroupMember({
|
|||
user={member}
|
||||
size="xs"
|
||||
className={clsx(
|
||||
"q__group-member__avatar",
|
||||
`q__group-member__avatar__${member.privateNote.sentiment}`,
|
||||
styles.avatar,
|
||||
styles[
|
||||
`avatar${member.privateNote.sentiment.charAt(0).toUpperCase() + member.privateNote.sentiment.slice(1).toLowerCase()}`
|
||||
],
|
||||
)}
|
||||
/>
|
||||
</SendouButton>
|
||||
|
|
@ -319,7 +319,7 @@ function GroupMember({
|
|||
) : (
|
||||
<Avatar user={member} size="xs" />
|
||||
)}
|
||||
<Link to={userPage(member)} className="q__group-member__name">
|
||||
<Link to={userPage(member)} className={styles.name}>
|
||||
{member.inGameName ? (
|
||||
<>
|
||||
<span className="text-lighter font-bold text-xxxs">
|
||||
|
|
@ -346,12 +346,12 @@ function GroupMember({
|
|||
<div className="stack horizontal justify-between">
|
||||
<div className="stack horizontal items-center xxs">
|
||||
{member.vc && !hideVc ? (
|
||||
<div className="q__group-member__extra-info">
|
||||
<div className={styles.extraInfo}>
|
||||
<VoiceChatInfo member={member} />
|
||||
</div>
|
||||
) : null}
|
||||
{member.plusTier ? (
|
||||
<div className="q__group-member__extra-info">
|
||||
<div className={styles.extraInfo}>
|
||||
<Image path={navIconUrl("plus")} width={20} height={20} alt="" />
|
||||
{member.plusTier}
|
||||
</div>
|
||||
|
|
@ -359,7 +359,7 @@ function GroupMember({
|
|||
{member.friendCode ? (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton className="q__group-member__extra-info-button">
|
||||
<SendouButton className={styles.extraInfoButton}>
|
||||
FC
|
||||
</SendouButton>
|
||||
}
|
||||
|
|
@ -371,8 +371,8 @@ function GroupMember({
|
|||
<LinkButton
|
||||
to={`?note=${member.id}`}
|
||||
icon={<EditIcon />}
|
||||
className={clsx("q__group-member__add-note-button", {
|
||||
"q__group-member__add-note-button__edit": member.privateNote,
|
||||
className={clsx(styles.addNoteButton, {
|
||||
[styles.addNoteButtonEdit]: member.privateNote,
|
||||
})}
|
||||
>
|
||||
{member.privateNote
|
||||
|
|
@ -382,7 +382,7 @@ function GroupMember({
|
|||
) : null}
|
||||
</div>
|
||||
{member.weapons && member.weapons.length > 0 && !hideWeapons ? (
|
||||
<div className="q__group-member__extra-info">
|
||||
<div className={styles.extraInfo}>
|
||||
{member.weapons?.map((weapon) => {
|
||||
return (
|
||||
<WeaponImage
|
||||
|
|
@ -400,7 +400,10 @@ function GroupMember({
|
|||
) : null}
|
||||
</div>
|
||||
{!hideNote ? (
|
||||
<MemberNote note={member.note} editable={user?.id === member.id} />
|
||||
<MemberNote
|
||||
note={member.privateNote?.text}
|
||||
editable={user?.id === member.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -485,7 +488,7 @@ function AddPrivateNoteForm({
|
|||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
rows={2}
|
||||
className="q__group-member__note-textarea mt-1"
|
||||
className={`${styles.noteTextarea} mt-1`}
|
||||
name="value"
|
||||
ref={textareaRef}
|
||||
/>
|
||||
|
|
@ -515,36 +518,6 @@ function AddPrivateNoteForm({
|
|||
);
|
||||
}
|
||||
|
||||
function RechallengeForm({
|
||||
modes,
|
||||
targetGroupId,
|
||||
}: {
|
||||
modes: ModeShort[];
|
||||
targetGroupId: number;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form method="post" className="stack sm justify-center horizontal">
|
||||
<input type="hidden" name="targetGroupId" value={targetGroupId} />
|
||||
<SubmitButton
|
||||
_action="RECHALLENGE"
|
||||
state={fetcher.state}
|
||||
size="miniscule"
|
||||
variant="minimal"
|
||||
>
|
||||
{t("q:looking.groups.actions.rechallenge")}
|
||||
<div className="stack xs items-center horizontal ml-2 -mt-1px">
|
||||
{modes.map((mode) => (
|
||||
<ModeImage key={mode} mode={mode} size={18} />
|
||||
))}
|
||||
</div>
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeletePrivateNoteForm({
|
||||
targetId,
|
||||
name,
|
||||
|
|
@ -622,7 +595,7 @@ function MemberSkillDifference({
|
|||
<span className="text-warning">▼</span>
|
||||
);
|
||||
return (
|
||||
<div className="q__group-member__extra-info">
|
||||
<div className={styles.extraInfo}>
|
||||
{symbol}
|
||||
{Math.abs(skillDifference.spDiff)}SP
|
||||
</div>
|
||||
|
|
@ -631,7 +604,7 @@ function MemberSkillDifference({
|
|||
|
||||
if (skillDifference.matchesCount === skillDifference.matchesCountNeeded) {
|
||||
return (
|
||||
<div className="q__group-member__extra-info">
|
||||
<div className={styles.extraInfo}>
|
||||
<span className="text-lighter">{t("q:looking.sp.calculated")}:</span>{" "}
|
||||
{skillDifference.newSp ? <>{skillDifference.newSp}SP</> : null}
|
||||
</div>
|
||||
|
|
@ -639,7 +612,7 @@ function MemberSkillDifference({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="q__group-member__extra-info">
|
||||
<div className={styles.extraInfo}>
|
||||
<span className="text-lighter">{t("q:looking.sp.calculating")}</span> (
|
||||
{skillDifference.matchesCount}/{skillDifference.matchesCountNeeded})
|
||||
</div>
|
||||
|
|
@ -651,7 +624,7 @@ function MemberRoleManager({
|
|||
displayOnly,
|
||||
enableKicking,
|
||||
}: {
|
||||
member: NonNullable<LookingGroup["members"]>[number];
|
||||
member: Pick<SQGroupMember, "id" | "role">;
|
||||
displayOnly?: boolean;
|
||||
enableKicking?: boolean;
|
||||
}) {
|
||||
|
|
@ -669,8 +642,8 @@ function MemberRoleManager({
|
|||
variant="minimal"
|
||||
icon={
|
||||
<Icon
|
||||
className={clsx("q__group-member__star", {
|
||||
"q__group-member__star__inactive": member.role === "REGULAR",
|
||||
className={clsx(styles.star, {
|
||||
[styles.starInactive]: member.role === "REGULAR",
|
||||
})}
|
||||
/>
|
||||
}
|
||||
|
|
@ -728,7 +701,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
|
|||
|
||||
if (skill === "CALCULATING") {
|
||||
return (
|
||||
<div className="q__group-member__tier">
|
||||
<div className={styles.tier}>
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton variant="minimal">
|
||||
|
|
@ -736,7 +709,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
|
|||
path={tierImageUrl("CALCULATING")}
|
||||
alt=""
|
||||
height={32.965}
|
||||
className="q__group-member__tier__placeholder"
|
||||
className={styles.tierPlaceholder}
|
||||
/>
|
||||
</SendouButton>
|
||||
}
|
||||
|
|
@ -750,7 +723,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="q__group-member__tier">
|
||||
<div className={styles.tier}>
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton variant="minimal">
|
||||
|
|
@ -785,7 +758,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
|
|||
function VoiceChatInfo({
|
||||
member,
|
||||
}: {
|
||||
member: NonNullable<LookingGroup["members"]>[number];
|
||||
member: Pick<SQMatchGroupMember, "id" | "vc" | "languages">;
|
||||
}) {
|
||||
const user = useUser();
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
|
@ -830,7 +803,7 @@ function VoiceChatInfo({
|
|||
<SendouButton
|
||||
variant="minimal"
|
||||
size="small"
|
||||
icon={<Icon className={clsx("q__group-member-vc-icon", color())} />}
|
||||
icon={<Icon className={clsx(styles.vcIcon, color())} />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
4
app/features/sendouq/components/MemberAdder.module.css
Normal file
4
app/features/sendouq/components/MemberAdder.module.css
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.input {
|
||||
--input-width: 11rem;
|
||||
width: 11rem;
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
sendouQInviteLink,
|
||||
} from "~/utils/urls";
|
||||
import type { SendouQPreparingAction } from "../actions/q.preparing.server";
|
||||
import styles from "./MemberAdder.module.css";
|
||||
|
||||
export function MemberAdder({
|
||||
inviteCode,
|
||||
|
|
@ -56,7 +57,7 @@ export function MemberAdder({
|
|||
value={inviteLink}
|
||||
readOnly
|
||||
id="invite"
|
||||
className="q__member-adder__input"
|
||||
className={styles.input}
|
||||
/>
|
||||
<SendouButton
|
||||
variant={copySuccess ? "outlined-success" : "outlined"}
|
||||
|
|
@ -101,14 +102,7 @@ function TrusterDropdown({
|
|||
const { trusters, teams } = useTrusted();
|
||||
|
||||
if (!trusters || trusters.length === 0) {
|
||||
return (
|
||||
<select
|
||||
name="id"
|
||||
id="players"
|
||||
disabled
|
||||
className="q__member-adder__input"
|
||||
/>
|
||||
);
|
||||
return <select name="id" id="players" disabled className={styles.input} />;
|
||||
}
|
||||
|
||||
const trustersNotInGroup = trusters.filter(
|
||||
|
|
@ -132,7 +126,7 @@ function TrusterDropdown({
|
|||
onChange={(e) =>
|
||||
setTruster(e.target.value ? Number(e.target.value) : undefined)
|
||||
}
|
||||
className="q__member-adder__input"
|
||||
className={styles.input}
|
||||
>
|
||||
<option value="">{t("q:looking.groups.adder.select")}</option>
|
||||
{teams?.map((team) => {
|
||||
|
|
|
|||
821
app/features/sendouq/core/SendouQ.server.test.ts
Normal file
821
app/features/sendouq/core/SendouQ.server.test.ts
Normal file
|
|
@ -0,0 +1,821 @@
|
|||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { db } from "~/db/sql";
|
||||
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
|
||||
import { dbInsertUsers, dbReset } from "~/utils/Test";
|
||||
import * as SQGroupRepository from "../SQGroupRepository.server";
|
||||
import { refreshSendouQInstance, SendouQ } from "./SendouQ.server";
|
||||
|
||||
const { mockSeasonCurrentOrPrevious } = vi.hoisted(() => ({
|
||||
mockSeasonCurrentOrPrevious: vi.fn(() => ({
|
||||
nth: 1,
|
||||
starts: new Date("2023-01-01"),
|
||||
ends: new Date("2030-12-31"),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("~/features/mmr/core/Seasons", () => ({
|
||||
currentOrPrevious: mockSeasonCurrentOrPrevious,
|
||||
}));
|
||||
|
||||
const createGroup = async (
|
||||
userIds: number[],
|
||||
options: {
|
||||
status?: "PREPARING" | "ACTIVE";
|
||||
inviteCode?: string;
|
||||
} = {},
|
||||
) => {
|
||||
const { status = "ACTIVE", inviteCode } = options;
|
||||
|
||||
const groupResult = await SQGroupRepository.createGroup({
|
||||
status,
|
||||
userId: userIds[0],
|
||||
});
|
||||
|
||||
if (inviteCode) {
|
||||
await db
|
||||
.updateTable("Group")
|
||||
.set({ inviteCode })
|
||||
.where("id", "=", groupResult.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
for (let i = 1; i < userIds.length; i++) {
|
||||
await SQGroupRepository.addMember(groupResult.id, {
|
||||
userId: userIds[i],
|
||||
role: "REGULAR",
|
||||
});
|
||||
}
|
||||
|
||||
return groupResult.id;
|
||||
};
|
||||
|
||||
const createMatch = async (
|
||||
alphaGroupId: number,
|
||||
bravoGroupId: number,
|
||||
options: { reportedAt?: number } = {},
|
||||
) => {
|
||||
const { reportedAt = Date.now() } = options;
|
||||
|
||||
await db
|
||||
.insertInto("GroupMatch")
|
||||
.values({
|
||||
alphaGroupId,
|
||||
bravoGroupId,
|
||||
reportedAt,
|
||||
})
|
||||
.execute();
|
||||
};
|
||||
|
||||
const createPrivateNote = async (
|
||||
authorId: number,
|
||||
targetId: number,
|
||||
sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE",
|
||||
text = "test note",
|
||||
) => {
|
||||
await PrivateUserNoteRepository.upsert({
|
||||
authorId,
|
||||
targetId,
|
||||
sentiment,
|
||||
text,
|
||||
});
|
||||
};
|
||||
|
||||
const insertSkill = async (userId: number, ordinal: number, season = 1) => {
|
||||
await db
|
||||
.insertInto("Skill")
|
||||
.values({
|
||||
userId,
|
||||
season,
|
||||
mu: 25,
|
||||
sigma: 8.333,
|
||||
ordinal,
|
||||
matchesCount: 10,
|
||||
})
|
||||
.execute();
|
||||
};
|
||||
|
||||
describe("SendouQ", () => {
|
||||
describe("currentViewByUserId", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(4);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("returns 'default' when user not in any group", async () => {
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const view = SendouQ.currentViewByUserId(1);
|
||||
|
||||
expect(view).toBe("default");
|
||||
});
|
||||
|
||||
test("returns 'preparing' when user in PREPARING group", async () => {
|
||||
await createGroup([1], { status: "PREPARING" });
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const view = SendouQ.currentViewByUserId(1);
|
||||
|
||||
expect(view).toBe("preparing");
|
||||
});
|
||||
|
||||
test("returns 'match' when user in ACTIVE group with matchId", async () => {
|
||||
const groupId1 = await createGroup([1]);
|
||||
const groupId2 = await createGroup([2]);
|
||||
|
||||
await createMatch(groupId1, groupId2);
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const view = SendouQ.currentViewByUserId(1);
|
||||
|
||||
expect(view).toBe("match");
|
||||
});
|
||||
|
||||
test("returns 'looking' when user in ACTIVE group without matchId", async () => {
|
||||
await createGroup([1], { status: "ACTIVE" });
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const view = SendouQ.currentViewByUserId(1);
|
||||
|
||||
expect(view).toBe("looking");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOwnGroup", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(8);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("returns group when user is a member", async () => {
|
||||
await createGroup([1, 2, 3]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const group = SendouQ.findOwnGroup(1);
|
||||
|
||||
expect(group).toBeDefined();
|
||||
expect(group?.members.some((m) => m.id === 1)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns undefined when user not in any group", async () => {
|
||||
await createGroup([1, 2, 3]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const group = SendouQ.findOwnGroup(4);
|
||||
|
||||
expect(group).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns group with correct role when user is OWNER", async () => {
|
||||
await createGroup([1, 2]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const group = SendouQ.findOwnGroup(1);
|
||||
|
||||
expect(group).toBeDefined();
|
||||
const member = group?.members.find((m) => m.id === 1);
|
||||
expect(member?.role).toBe("OWNER");
|
||||
});
|
||||
|
||||
test("returns group with correct role when user is REGULAR member", async () => {
|
||||
await createGroup([1, 2]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const group = SendouQ.findOwnGroup(2);
|
||||
|
||||
expect(group).toBeDefined();
|
||||
const member = group?.members.find((m) => m.id === 2);
|
||||
expect(member?.role).toBe("REGULAR");
|
||||
});
|
||||
|
||||
test("returns correct group when multiple groups exist", async () => {
|
||||
await createGroup([1, 2]);
|
||||
await createGroup([3, 4]);
|
||||
await createGroup([5, 6]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const group = SendouQ.findOwnGroup(5);
|
||||
|
||||
expect(group).toBeDefined();
|
||||
expect(group?.members.some((m) => m.id === 5)).toBe(true);
|
||||
expect(group?.members.some((m) => m.id === 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findGroupByInviteCode", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(4);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("returns group when invite code is valid", async () => {
|
||||
await createGroup([1], { inviteCode: "ABC123" });
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const group = SendouQ.findGroupByInviteCode("ABC123");
|
||||
|
||||
expect(group).toBeDefined();
|
||||
expect(group?.inviteCode).toBe("ABC123");
|
||||
});
|
||||
|
||||
test("returns undefined when invite code is invalid", async () => {
|
||||
await createGroup([1], { inviteCode: "ABC123" });
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const group = SendouQ.findGroupByInviteCode("INVALID");
|
||||
|
||||
expect(group).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns correct group when multiple groups exist", async () => {
|
||||
await createGroup([1], { inviteCode: "CODE1" });
|
||||
await createGroup([2], { inviteCode: "CODE2" });
|
||||
await createGroup([3], { inviteCode: "CODE3" });
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const group = SendouQ.findGroupByInviteCode("CODE2");
|
||||
|
||||
expect(group).toBeDefined();
|
||||
expect(group?.members[0].id).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewGroups", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(12);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("returns empty array when no groups exist", async () => {
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.previewGroups(notes);
|
||||
|
||||
expect(groups).toEqual([]);
|
||||
});
|
||||
|
||||
test("censors members for full groups", async () => {
|
||||
await createGroup([1, 2, 3, 4]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.previewGroups(notes);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].members).toBeUndefined();
|
||||
});
|
||||
|
||||
test("shows members for partial groups", async () => {
|
||||
await createGroup([1, 2]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.previewGroups(notes);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].members).toBeDefined();
|
||||
expect(groups[0].members).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("attaches private notes to members", async () => {
|
||||
await createGroup([1, 2]);
|
||||
await createPrivateNote(3, 2, "POSITIVE", "Great player");
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(3);
|
||||
const groups = SendouQ.previewGroups(notes);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
const member = groups[0].members?.find((m) => m.id === 2);
|
||||
expect(member?.privateNote).toBeDefined();
|
||||
expect(member?.privateNote?.sentiment).toBe("POSITIVE");
|
||||
});
|
||||
|
||||
test("removes inviteCode and chatCode from all groups", async () => {
|
||||
await createGroup([1, 2], { inviteCode: "CODE1" });
|
||||
await createGroup([3, 4, 5, 6], { inviteCode: "CODE2" });
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.previewGroups(notes);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
for (const group of groups) {
|
||||
expect(group).not.toHaveProperty("inviteCode");
|
||||
expect(group).not.toHaveProperty("chatCode");
|
||||
}
|
||||
});
|
||||
|
||||
test("applies correct censoring for mix of full and partial groups", async () => {
|
||||
await createGroup([1, 2]);
|
||||
await createGroup([3, 4, 5, 6]);
|
||||
await createGroup([7, 8, 9]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.previewGroups(notes);
|
||||
|
||||
expect(groups).toHaveLength(3);
|
||||
|
||||
const partialGroups = groups.filter((g) => g.members !== undefined);
|
||||
const fullGroups = groups.filter((g) => g.members === undefined);
|
||||
|
||||
expect(partialGroups).toHaveLength(2);
|
||||
expect(fullGroups).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("censors tier and sets tier range for full groups", async () => {
|
||||
await createGroup([1, 2, 3, 4]);
|
||||
await createGroup([5, 6]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.previewGroups(notes);
|
||||
|
||||
const fullGroup = groups.find((g) => g.members === undefined);
|
||||
const partialGroup = groups.find((g) => g.members !== undefined);
|
||||
|
||||
expect(fullGroup?.tier).toBeNull();
|
||||
expect(fullGroup?.tierRange).toBeDefined();
|
||||
|
||||
expect(fullGroup?.tierRange?.range[0].name).toBe("IRON");
|
||||
expect(fullGroup?.tierRange?.range[1].name).toBe("LEVIATHAN");
|
||||
|
||||
expect(partialGroup?.tier).toBeDefined();
|
||||
expect(partialGroup?.tierRange).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lookingGroups", () => {
|
||||
describe("filtering", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(20);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("returns empty array when user not in a group", async () => {
|
||||
await createGroup([1, 2, 3, 4]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(5);
|
||||
const groups = SendouQ.lookingGroups(5, notes);
|
||||
|
||||
expect(groups).toEqual([]);
|
||||
});
|
||||
|
||||
test("only returns ACTIVE groups", async () => {
|
||||
await createGroup([1]);
|
||||
await createGroup([2], { status: "PREPARING" });
|
||||
const group3 = await createGroup([3]);
|
||||
await db
|
||||
.updateTable("Group")
|
||||
.set({ status: "INACTIVE" })
|
||||
.where("id", "=", group3)
|
||||
.execute();
|
||||
await createGroup([4], { status: "ACTIVE" });
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].members![0].id).toBe(4);
|
||||
});
|
||||
|
||||
test("only returns groups without matchId", async () => {
|
||||
await createGroup([1]);
|
||||
await createGroup([2]);
|
||||
await createGroup([3]);
|
||||
|
||||
await createMatch(1, 2);
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].members![0].id).toBe(3);
|
||||
});
|
||||
|
||||
test("excludes own group from results", async () => {
|
||||
await createGroup([1, 2]);
|
||||
await createGroup([3, 4]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].members?.some((m) => m.id === 1)).toBe(false);
|
||||
});
|
||||
|
||||
test("own group size 4 only shows size 4 groups", async () => {
|
||||
await createGroup([1, 2, 3, 4]);
|
||||
await createGroup([5]);
|
||||
await createGroup([6, 7]);
|
||||
await createGroup([8, 9, 10]);
|
||||
await createGroup([11, 12, 13, 14]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].members).toBeUndefined();
|
||||
});
|
||||
|
||||
test("own group size 3 only shows size 1 groups", async () => {
|
||||
await createGroup([1, 2, 3]);
|
||||
await createGroup([4]);
|
||||
await createGroup([5, 6]);
|
||||
await createGroup([7, 8, 9]);
|
||||
await createGroup([10, 11, 12, 13]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].members).toHaveLength(1);
|
||||
expect(groups[0].members![0].id).toBe(4);
|
||||
});
|
||||
|
||||
test("own group size 2 shows size 1 and 2 groups", async () => {
|
||||
await createGroup([1, 2]);
|
||||
await createGroup([3]);
|
||||
await createGroup([4, 5]);
|
||||
await createGroup([6, 7, 8]);
|
||||
await createGroup([9, 10, 11, 12]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
const groupSizes = groups.map((g) => g.members!.length);
|
||||
expect(groupSizes).toContain(1);
|
||||
expect(groupSizes).toContain(2);
|
||||
});
|
||||
|
||||
test("own group size 1 shows size 1, 2, and 3 groups", async () => {
|
||||
await createGroup([1]);
|
||||
await createGroup([2]);
|
||||
await createGroup([3, 4]);
|
||||
await createGroup([5, 6, 7]);
|
||||
await createGroup([8, 9, 10, 11]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups).toHaveLength(3);
|
||||
const groupSizes = groups.map((g) => g.members!.length);
|
||||
expect(groupSizes).toContain(1);
|
||||
expect(groupSizes).toContain(2);
|
||||
expect(groupSizes).toContain(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replay detection", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(12);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("marks group as replay when 3+ members overlap", async () => {
|
||||
const group1 = await createGroup([1, 2, 3, 4]);
|
||||
const group2 = await createGroup([5, 6, 7, 8]);
|
||||
|
||||
await createMatch(group1, group2);
|
||||
|
||||
await db
|
||||
.updateTable("Group")
|
||||
.set({ status: "INACTIVE" })
|
||||
.where("id", "in", [group1, group2])
|
||||
.execute();
|
||||
|
||||
await createGroup([1, 2, 3, 4]);
|
||||
await createGroup([5, 6, 7, 8]);
|
||||
await createGroup([9, 10, 11, 12]);
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
const replayGroup = groups.find((g) => g.members === undefined);
|
||||
expect(replayGroup?.isReplay).toBe(true);
|
||||
});
|
||||
|
||||
test("does not mark as replay when less than 3 members overlap", async () => {
|
||||
const group1 = await createGroup([1, 2, 3, 4]);
|
||||
const group2 = await createGroup([5, 6, 7, 8]);
|
||||
|
||||
await createMatch(group1, group2);
|
||||
|
||||
await db
|
||||
.updateTable("Group")
|
||||
.set({ status: "INACTIVE" })
|
||||
.where("id", "in", [group1, group2])
|
||||
.execute();
|
||||
|
||||
await createGroup([1, 2, 3, 4]);
|
||||
await createGroup([5, 6, 9, 10]);
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
for (const group of groups) {
|
||||
expect(group.isReplay).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("all groups have isReplay false when no recent matches", async () => {
|
||||
await createGroup([1]);
|
||||
await createGroup([2]);
|
||||
await createGroup([3]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
for (const group of groups) {
|
||||
expect(group.isReplay).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("non-full groups do not have isReplay even with 3+ overlapping members", async () => {
|
||||
const group1 = await createGroup([1, 2, 3, 4]);
|
||||
const group2 = await createGroup([5, 6, 7, 8]);
|
||||
|
||||
await createMatch(group1, group2);
|
||||
|
||||
await db
|
||||
.updateTable("Group")
|
||||
.set({ status: "INACTIVE" })
|
||||
.where("id", "in", [group1, group2])
|
||||
.execute();
|
||||
|
||||
await createGroup([1]);
|
||||
await createGroup([5, 6, 7]);
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
const partialGroup = groups.find((g) =>
|
||||
g.members?.some((m) => m.id === 5),
|
||||
);
|
||||
expect(partialGroup?.isReplay).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("censoring", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(12);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("full groups have members undefined", async () => {
|
||||
await createGroup([1, 2, 3, 4]);
|
||||
await createGroup([5, 6, 7, 8]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
const fullGroup = groups.find((g) => g.members === undefined);
|
||||
expect(fullGroup).toBeDefined();
|
||||
});
|
||||
|
||||
test("partial groups have members visible", async () => {
|
||||
await createGroup([1]);
|
||||
await createGroup([2, 3]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
const partialGroup = groups.find((g) => g.members?.length === 2);
|
||||
expect(partialGroup).toBeDefined();
|
||||
expect(partialGroup?.members).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("inviteCode and chatCode removed from all groups", async () => {
|
||||
await createGroup([1]);
|
||||
await createGroup([2]);
|
||||
await createGroup([3, 4, 5, 6]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
for (const group of groups) {
|
||||
expect(group).not.toHaveProperty("inviteCode");
|
||||
expect(group).not.toHaveProperty("chatCode");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("private note sorting", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(8);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("users with positive note sorted first", async () => {
|
||||
await createGroup([1]);
|
||||
await createGroup([2]);
|
||||
await createGroup([3]);
|
||||
await createGroup([4]);
|
||||
await createGroup([5]);
|
||||
await createGroup([6, 7]);
|
||||
await createGroup([8]);
|
||||
|
||||
await createPrivateNote(1, 5, "POSITIVE");
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups[0].members![0].id).toBe(5);
|
||||
});
|
||||
|
||||
test("users with negative note sorted last", async () => {
|
||||
await createGroup([1]);
|
||||
await createGroup([2]);
|
||||
await createGroup([3]);
|
||||
await createGroup([4]);
|
||||
await createGroup([5]);
|
||||
await createGroup([6, 7]);
|
||||
await createGroup([8]);
|
||||
|
||||
await createPrivateNote(1, 5, "NEGATIVE");
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups[groups.length - 1].members![0].id).toBe(5);
|
||||
});
|
||||
|
||||
test("group with both negative and positive sentiment sorted last", async () => {
|
||||
await createGroup([1]);
|
||||
await createGroup([2]);
|
||||
await createGroup([3]);
|
||||
await createGroup([4]);
|
||||
await createGroup([5]);
|
||||
await createGroup([6, 7]);
|
||||
await createGroup([8]);
|
||||
|
||||
await createPrivateNote(1, 6, "POSITIVE");
|
||||
await createPrivateNote(1, 7, "NEGATIVE");
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups[groups.length - 1].members?.some((m) => m.id === 6)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill-based sorting", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(10);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("sentiment still takes priority over skill", async () => {
|
||||
await insertSkill(1, 1000);
|
||||
await insertSkill(2, 500);
|
||||
await insertSkill(4, 2000);
|
||||
|
||||
await createGroup([1]);
|
||||
await createGroup([2]);
|
||||
await createGroup([4]);
|
||||
|
||||
await createPrivateNote(1, 4, "POSITIVE");
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups[0].members![0].id).toBe(4);
|
||||
});
|
||||
|
||||
test("groups with closer skill sorted first within same sentiment", async () => {
|
||||
await insertSkill(1, 1000);
|
||||
await insertSkill(2, 1050);
|
||||
await insertSkill(3, 500);
|
||||
await insertSkill(4, 2000);
|
||||
|
||||
await createGroup([1]);
|
||||
await createGroup([2]);
|
||||
await createGroup([3]);
|
||||
await createGroup([4]);
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups[0].members![0].id).toBe(2);
|
||||
});
|
||||
|
||||
test("full groups sorted by average skill", async () => {
|
||||
await insertSkill(1, 1000);
|
||||
await insertSkill(2, 1000);
|
||||
await insertSkill(3, 1000);
|
||||
await insertSkill(4, 1000);
|
||||
await insertSkill(5, 1100);
|
||||
await insertSkill(6, 1100);
|
||||
await insertSkill(7, 1100);
|
||||
await insertSkill(8, 1100);
|
||||
await insertSkill(9, 500);
|
||||
await insertSkill(10, 500);
|
||||
|
||||
await createGroup([1, 2, 3, 4]);
|
||||
const closerGroup = await createGroup([5, 6, 7, 8]);
|
||||
await createGroup([9, 10]);
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups.length).toBeGreaterThan(0);
|
||||
expect(groups[0].id).toBe(closerGroup);
|
||||
});
|
||||
|
||||
test("newer groups sorted first when skill is equal", async () => {
|
||||
await insertSkill(1, 1000);
|
||||
await insertSkill(2, 1000);
|
||||
await insertSkill(3, 1000);
|
||||
|
||||
const group1Id = await createGroup([2]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const group2Id = await createGroup([3]);
|
||||
|
||||
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
|
||||
await db
|
||||
.updateTable("Group")
|
||||
.set({ latestActionAt: currentTimeInSeconds - 100 })
|
||||
.where("id", "=", group1Id)
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.updateTable("Group")
|
||||
.set({ latestActionAt: currentTimeInSeconds - 50 })
|
||||
.where("id", "=", group2Id)
|
||||
.execute();
|
||||
|
||||
await createGroup([1]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups[0].members![0].id).toBe(3);
|
||||
expect(groups[1].members![0].id).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
547
app/features/sendouq/core/SendouQ.server.ts
Normal file
547
app/features/sendouq/core/SendouQ.server.ts
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
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 SkillTierInterval,
|
||||
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 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];
|
||||
export type SQMatchGroupMember = SQMatchGroup["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;
|
||||
/** 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.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,
|
||||
userSkills: calculatedUserSkills,
|
||||
intervals,
|
||||
}) 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 isTeamAlphaMember = match.groupAlpha.members.some(
|
||||
(m) => m.id === user?.id,
|
||||
);
|
||||
const isTeamBravoMember = match.groupBravo.members.some(
|
||||
(m) => m.id === user?.id,
|
||||
);
|
||||
const isMatchInsider =
|
||||
isTeamAlphaMember || isTeamBravoMember || 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,
|
||||
isReplay: false,
|
||||
tierRange: null as TierRange | null,
|
||||
chatCode: isTeamMember ? group.chatCode : undefined,
|
||||
noScreen: this.#groupNoScreen(group),
|
||||
tier: match.memento?.groups[group.id]?.tier,
|
||||
skillDifference: match.memento?.groups[group.id]?.skillDifference,
|
||||
modePreferences: this.#groupModePreferences(group),
|
||||
usersRole: null as Tables["GroupMember"]["role"] | null,
|
||||
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,
|
||||
languages: member.languages?.split(",") || [],
|
||||
friendCode:
|
||||
isMatchInsider && happenedInLastMonth
|
||||
? member.friendCode
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
...match,
|
||||
chatCode: isMatchInsider ? match.chatCode : undefined,
|
||||
groupAlpha: this.#getAddMemberPrivateNoteMapper(notes)(
|
||||
matchGroupCensorer(match.groupAlpha, isTeamAlphaMember),
|
||||
),
|
||||
groupBravo: this.#getAddMemberPrivateNoteMapper(notes)(
|
||||
matchGroupCensorer(match.groupBravo, isTeamBravoMember),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
/** Array of private user notes to include */
|
||||
notes: DBPrivateNoteRow[],
|
||||
) {
|
||||
return this.groups
|
||||
.map((group) => this.#addPreviewTierRange(group))
|
||||
.map(this.#getAddMemberPrivateNoteMapper(notes))
|
||||
.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];
|
||||
|
||||
const staleThreshold = sub(new Date(), { seconds: SECONDS_TILL_STALE });
|
||||
return this.groups
|
||||
.filter((group) => {
|
||||
const groupLastAction = databaseTimestampToDate(group.latestActionAt);
|
||||
return (
|
||||
group.status === "ACTIVE" &&
|
||||
!group.matchId &&
|
||||
group.id !== ownGroup.id &&
|
||||
currentMemberCountOptions.includes(group.members.length) &&
|
||||
groupLastAction >= staleThreshold
|
||||
);
|
||||
})
|
||||
.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 baseGroup = R.omit(group, ["inviteCode", "chatCode", "members"]);
|
||||
|
||||
if (this.#groupIsFull(group)) {
|
||||
return {
|
||||
...baseGroup,
|
||||
members: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...baseGroup,
|
||||
members: group.members,
|
||||
};
|
||||
}
|
||||
|
||||
#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 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;
|
||||
}
|
||||
|
||||
#groupTier({
|
||||
group,
|
||||
userSkills,
|
||||
intervals,
|
||||
}: {
|
||||
group: DBGroupRow | DBMatch["groupAlpha"] | DBMatch["groupBravo"];
|
||||
userSkills: Record<string, TieredSkill>;
|
||||
intervals: SkillTierInterval[];
|
||||
}): TieredSkill["tier"] | undefined {
|
||||
if (!group.members) return;
|
||||
|
||||
const skills = group.members.map(
|
||||
(m) => userSkills[String(m.id)] ?? { ordinal: defaultOrdinal() },
|
||||
);
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
|
@ -3,8 +3,8 @@ import * as MapList from "~/features/map-list-generator/core/MapList";
|
|||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { logger } from "~/utils/logger";
|
||||
import * as QRepository from "../QRepository.server";
|
||||
import { SENDOUQ_BEST_OF } from "../q-constants";
|
||||
import * as SQGroupRepository from "../SQGroupRepository.server";
|
||||
|
||||
let cachedDefaults: Map<string, number> | null = null;
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ async function calculateSeasonDefaultMaps(
|
|||
seasonNth: number,
|
||||
): Promise<Map<string, number>> {
|
||||
const activeUsersWithPreferences =
|
||||
await QRepository.mapModePreferencesBySeasonNth(seasonNth);
|
||||
await SQGroupRepository.mapModePreferencesBySeasonNth(seasonNth);
|
||||
|
||||
const mapModeCounts = new Map<string, number>();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const paramsToExpected = new Map<
|
|||
Parameters<typeof tierDifferenceToRangeOrExact>[0]["ourTier"],
|
||||
Parameters<typeof tierDifferenceToRangeOrExact>[0]["theirTier"],
|
||||
],
|
||||
ReturnType<typeof tierDifferenceToRangeOrExact>["tier"]
|
||||
ReturnType<typeof tierDifferenceToRangeOrExact>
|
||||
>()
|
||||
// exact
|
||||
.set(
|
||||
|
|
@ -14,7 +14,7 @@ const paramsToExpected = new Map<
|
|||
{ isPlus: false, name: "GOLD" },
|
||||
{ isPlus: false, name: "GOLD" },
|
||||
],
|
||||
{ isPlus: false, name: "GOLD" },
|
||||
{ type: "exact", diff: 0, tier: { isPlus: false, name: "GOLD" } },
|
||||
)
|
||||
// 1 place difference
|
||||
.set(
|
||||
|
|
@ -22,10 +22,14 @@ const paramsToExpected = new Map<
|
|||
{ isPlus: false, name: "GOLD" },
|
||||
{ isPlus: true, name: "GOLD" },
|
||||
],
|
||||
[
|
||||
{ isPlus: true, name: "SILVER" },
|
||||
{ isPlus: true, name: "GOLD" },
|
||||
],
|
||||
{
|
||||
type: "range",
|
||||
diff: [-1, 1],
|
||||
range: [
|
||||
{ isPlus: true, name: "SILVER" },
|
||||
{ isPlus: true, name: "GOLD" },
|
||||
],
|
||||
},
|
||||
)
|
||||
// 2 places difference
|
||||
.set(
|
||||
|
|
@ -33,10 +37,14 @@ const paramsToExpected = new Map<
|
|||
{ isPlus: false, name: "GOLD" },
|
||||
{ isPlus: false, name: "PLATINUM" },
|
||||
],
|
||||
[
|
||||
{ isPlus: false, name: "SILVER" },
|
||||
{ isPlus: false, name: "PLATINUM" },
|
||||
],
|
||||
{
|
||||
type: "range",
|
||||
diff: [-2, 2],
|
||||
range: [
|
||||
{ isPlus: false, name: "SILVER" },
|
||||
{ isPlus: false, name: "PLATINUM" },
|
||||
],
|
||||
},
|
||||
)
|
||||
// too high, has to be exact
|
||||
.set(
|
||||
|
|
@ -44,7 +52,7 @@ const paramsToExpected = new Map<
|
|||
{ isPlus: true, name: "LEVIATHAN" },
|
||||
{ isPlus: false, name: "LEVIATHAN" },
|
||||
],
|
||||
{ isPlus: false, name: "LEVIATHAN" },
|
||||
{ type: "exact", diff: 1, tier: { isPlus: false, name: "LEVIATHAN" } },
|
||||
)
|
||||
// too low, has to be exact
|
||||
.set(
|
||||
|
|
@ -52,7 +60,7 @@ const paramsToExpected = new Map<
|
|||
{ isPlus: false, name: "IRON" },
|
||||
{ isPlus: true, name: "IRON" },
|
||||
],
|
||||
{ isPlus: true, name: "IRON" },
|
||||
{ type: "exact", diff: 1, tier: { isPlus: true, name: "IRON" } },
|
||||
)
|
||||
// not max rank but still too high
|
||||
.set(
|
||||
|
|
@ -60,7 +68,7 @@ const paramsToExpected = new Map<
|
|||
{ isPlus: false, name: "LEVIATHAN" },
|
||||
{ isPlus: false, name: "DIAMOND" },
|
||||
],
|
||||
{ isPlus: false, name: "DIAMOND" },
|
||||
{ type: "exact", diff: 2, tier: { isPlus: false, name: "DIAMOND" } },
|
||||
);
|
||||
|
||||
describe("tierDifferenceToRangeOrExact()", () => {
|
||||
|
|
@ -70,7 +78,7 @@ describe("tierDifferenceToRangeOrExact()", () => {
|
|||
ourTier: input[0],
|
||||
theirTier: input[1],
|
||||
hasLeviathan: true,
|
||||
}).tier;
|
||||
});
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
}
|
||||
|
|
@ -80,7 +88,11 @@ describe("tierDifferenceToRangeOrExact()", () => {
|
|||
ourTier: { isPlus: true, name: "DIAMOND" },
|
||||
theirTier: { isPlus: false, name: "DIAMOND" },
|
||||
hasLeviathan: false,
|
||||
}).tier;
|
||||
expect(result).toEqual({ isPlus: false, name: "DIAMOND" });
|
||||
});
|
||||
expect(result).toEqual({
|
||||
type: "exact",
|
||||
diff: 1,
|
||||
tier: { isPlus: false, name: "DIAMOND" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,509 +1,7 @@
|
|||
import type { Tables } from "~/db/tables";
|
||||
import { TIERS } from "~/features/mmr/mmr-constants";
|
||||
import { defaultOrdinal } from "~/features/mmr/mmr-utils";
|
||||
import type {
|
||||
SkillTierInterval,
|
||||
TieredSkill,
|
||||
} from "~/features/mmr/tiered.server";
|
||||
import { mapModePreferencesToModeList } from "~/features/sendouq-match/core/match.server";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
import type {
|
||||
DividedGroups,
|
||||
DividedGroupsUncensored,
|
||||
GroupExpiryStatus,
|
||||
LookingGroup,
|
||||
LookingGroupWithInviteCode,
|
||||
} from "../q-types";
|
||||
import type { RecentMatchPlayer } from "../queries/findRecentMatchPlayersByUserId.server";
|
||||
|
||||
export function divideGroups({
|
||||
groups,
|
||||
ownGroupId,
|
||||
likes,
|
||||
}: {
|
||||
groups: LookingGroupWithInviteCode[];
|
||||
ownGroupId?: number;
|
||||
likes: Pick<
|
||||
Tables["GroupLike"],
|
||||
"likerGroupId" | "targetGroupId" | "isRechallenge"
|
||||
>[];
|
||||
}): DividedGroupsUncensored {
|
||||
let own: LookingGroupWithInviteCode | undefined;
|
||||
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);
|
||||
if (like.isRechallenge) {
|
||||
group.isRechallenge = true;
|
||||
}
|
||||
|
||||
unneutralGroupIds.add(group.id);
|
||||
break;
|
||||
}
|
||||
if (like.targetGroupId === group.id) {
|
||||
group.isLiked = true;
|
||||
if (like.isRechallenge) {
|
||||
group.isRechallenge = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
if (group.id === ownGroupId) {
|
||||
own = group;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (unneutralGroupIds.has(group.id)) continue;
|
||||
|
||||
neutral.push(group);
|
||||
}
|
||||
|
||||
return {
|
||||
own,
|
||||
neutral,
|
||||
likesReceived,
|
||||
};
|
||||
}
|
||||
|
||||
export function addNoScreenIndicator(
|
||||
groups: DividedGroupsUncensored,
|
||||
): DividedGroupsUncensored {
|
||||
const ownGroupFull = groups.own?.members.length === FULL_GROUP_SIZE;
|
||||
const ownGroupNoScreen = groups.own?.members.some((m) => m.noScreen);
|
||||
|
||||
const addNoScreenIndicatorIfNeeded = (group: LookingGroupWithInviteCode) => {
|
||||
const theirGroupNoScreen = group.members.some((m) => m.noScreen);
|
||||
|
||||
return {
|
||||
...group,
|
||||
isNoScreen: ownGroupFull && (ownGroupNoScreen || theirGroupNoScreen),
|
||||
members: group.members.map((m) => ({ ...m, noScreen: undefined })),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
own: groups.own
|
||||
? {
|
||||
...groups.own,
|
||||
members: groups.own.members.map((m) => ({
|
||||
...m,
|
||||
noScreen: undefined,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
likesReceived: groups.likesReceived.map(addNoScreenIndicatorIfNeeded),
|
||||
neutral: groups.neutral.map(addNoScreenIndicatorIfNeeded),
|
||||
};
|
||||
}
|
||||
|
||||
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) ?? [];
|
||||
|
||||
const combinedMatchModes = (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));
|
||||
};
|
||||
|
||||
const oneGroupMatchModes = (group: LookingGroupWithInviteCode) => {
|
||||
const modePreferences = group.mapModePreferences?.map((p) => p.modes);
|
||||
if (!modePreferences) return;
|
||||
|
||||
return mapModePreferencesToModeList(modePreferences, []).sort(
|
||||
(a, b) => modesShort.indexOf(a) - modesShort.indexOf(b),
|
||||
);
|
||||
};
|
||||
|
||||
const removeRechallengeIfIdentical = (group: LookingGroupWithInviteCode) => {
|
||||
if (!group.futureMatchModes || !group.rechallengeMatchModes) return group;
|
||||
|
||||
return {
|
||||
...group,
|
||||
rechallengeMatchModes:
|
||||
group.futureMatchModes.length === group.rechallengeMatchModes.length &&
|
||||
group.futureMatchModes.every(
|
||||
(m, i) => m === group.rechallengeMatchModes![i],
|
||||
)
|
||||
? undefined
|
||||
: group.rechallengeMatchModes,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
own: groups.own,
|
||||
likesReceived: groups.likesReceived.map((g) => ({
|
||||
...g,
|
||||
futureMatchModes:
|
||||
g.isRechallenge && groups.own
|
||||
? oneGroupMatchModes(groups.own)
|
||||
: combinedMatchModes(g),
|
||||
})),
|
||||
neutral: groups.neutral
|
||||
.map((g) => ({
|
||||
...g,
|
||||
futureMatchModes: g.isRechallenge
|
||||
? oneGroupMatchModes(g)
|
||||
: combinedMatchModes(g),
|
||||
rechallengeMatchModes: g.isLiked ? oneGroupMatchModes(g) : undefined,
|
||||
}))
|
||||
.map(removeRechallengeIfIdentical),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
showInviteCode,
|
||||
}: {
|
||||
groups: DividedGroupsUncensored;
|
||||
showInviteCode: boolean;
|
||||
}): DividedGroups {
|
||||
return {
|
||||
own:
|
||||
showInviteCode || !groups.own
|
||||
? groups.own
|
||||
: censorGroupPartly(groups.own),
|
||||
neutral: groups.neutral.map((g) =>
|
||||
g.members.length === FULL_GROUP_SIZE
|
||||
? censorGroupFully(g)
|
||||
: censorGroupPartly(g),
|
||||
),
|
||||
likesReceived: groups.likesReceived.map((g) =>
|
||||
g.members.length === FULL_GROUP_SIZE
|
||||
? censorGroupFully(g)
|
||||
: censorGroupPartly(g),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function sortGroupsBySkillAndSentiment({
|
||||
groups,
|
||||
userSkills,
|
||||
intervals,
|
||||
userId,
|
||||
}: {
|
||||
groups: DividedGroups;
|
||||
userSkills: Record<string, TieredSkill>;
|
||||
intervals: SkillTierInterval[];
|
||||
userId?: number;
|
||||
}): DividedGroups {
|
||||
const ownGroupTier = () => {
|
||||
if (groups.own?.tier?.name) return groups.own.tier.name;
|
||||
if (groups.own) {
|
||||
return resolveGroupSkill({
|
||||
group: groups.own as LookingGroupWithInviteCode,
|
||||
userSkills,
|
||||
intervals,
|
||||
})?.name;
|
||||
}
|
||||
|
||||
// preview mode, BRONZE as some kind of sensible defaults for unranked folks
|
||||
return userSkills[String(userId)]?.tier?.name ?? "BRONZE";
|
||||
};
|
||||
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 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 aDiff = a.tierRange?.diff ?? 0;
|
||||
const bDiff = b.tierRange?.diff ?? 0;
|
||||
|
||||
if (aDiff || bDiff) {
|
||||
return aDiff - bDiff;
|
||||
}
|
||||
|
||||
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 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: groups.own ? addSkill(groups.own) : undefined,
|
||||
neutral: groups.neutral.map(addSkill),
|
||||
likesReceived: groups.likesReceived.map(addSkill),
|
||||
};
|
||||
}
|
||||
|
||||
const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const;
|
||||
export function addSkillRangeToGroups({
|
||||
groups,
|
||||
hasLeviathan,
|
||||
isPreview,
|
||||
}: {
|
||||
groups: DividedGroups;
|
||||
hasLeviathan: boolean;
|
||||
isPreview: boolean;
|
||||
}): DividedGroups {
|
||||
const addRange = (group: LookingGroup) => {
|
||||
if (group.members && group.members.length !== FULL_GROUP_SIZE) return group;
|
||||
|
||||
if (isPreview) {
|
||||
return {
|
||||
...group,
|
||||
tierRange: {
|
||||
range: [
|
||||
{ name: "IRON", isPlus: false },
|
||||
{ name: "LEVIATHAN", isPlus: true },
|
||||
] as [TieredSkill["tier"], TieredSkill["tier"]],
|
||||
diff: 0,
|
||||
},
|
||||
tier: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const range = tierDifferenceToRangeOrExact({
|
||||
ourTier: groups.own?.tier ?? FALLBACK_TIER,
|
||||
theirTier: group.tier ?? FALLBACK_TIER,
|
||||
hasLeviathan,
|
||||
});
|
||||
|
||||
if (!Array.isArray(range.tier)) {
|
||||
return {
|
||||
...group,
|
||||
tierRange: { diff: range.diff },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...group,
|
||||
tierRange: { range: range.tier, diff: range.diff },
|
||||
tier: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
own: groups.own,
|
||||
neutral: groups.neutral.map(addRange),
|
||||
likesReceived: groups.likesReceived.map(addRange),
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!group.members) return;
|
||||
|
||||
const skills = group.members.map(
|
||||
(m) => userSkills[String(m.id)] ?? { ordinal: defaultOrdinal() },
|
||||
);
|
||||
|
||||
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" }
|
||||
);
|
||||
}
|
||||
|
||||
export function groupExpiryStatus(
|
||||
group?: Pick<Tables["Group"], "latestActionAt">,
|
||||
): null | GroupExpiryStatus {
|
||||
if (!group) return null;
|
||||
|
||||
// group expires in 30min without actions performed
|
||||
const groupExpiresAt =
|
||||
databaseTimestampToDate(group.latestActionAt).getTime() + 30 * 60 * 1000;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (now > groupExpiresAt) {
|
||||
return "EXPIRED";
|
||||
}
|
||||
|
||||
const tenMinutesFromNow = now + 10 * 60 * 1000;
|
||||
|
||||
if (tenMinutesFromNow > groupExpiresAt) {
|
||||
return "EXPIRING_SOON";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function censorGroupsIfOwnExpired({
|
||||
groups,
|
||||
ownGroupExpiryStatus,
|
||||
}: {
|
||||
groups: DividedGroups;
|
||||
ownGroupExpiryStatus: GroupExpiryStatus | null;
|
||||
}): DividedGroups {
|
||||
if (ownGroupExpiryStatus !== "EXPIRED") return groups;
|
||||
|
||||
return {
|
||||
own: groups.own,
|
||||
likesReceived: [],
|
||||
neutral: [],
|
||||
};
|
||||
}
|
||||
import type { TierDifference } from "../q-types";
|
||||
|
||||
const allTiersOrdered = TIERS.flatMap((tier) => [
|
||||
{ name: tier.name, isPlus: true },
|
||||
|
|
@ -517,12 +15,9 @@ export function tierDifferenceToRangeOrExact({
|
|||
ourTier: TieredSkill["tier"];
|
||||
theirTier: TieredSkill["tier"];
|
||||
hasLeviathan: boolean;
|
||||
}): {
|
||||
diff: number;
|
||||
tier: TieredSkill["tier"] | [TieredSkill["tier"], TieredSkill["tier"]];
|
||||
} {
|
||||
}): TierDifference {
|
||||
if (ourTier.name === theirTier.name && ourTier.isPlus === theirTier.isPlus) {
|
||||
return { diff: 0, tier: structuredClone(ourTier) };
|
||||
return { type: "exact", diff: 0, tier: structuredClone(ourTier) };
|
||||
}
|
||||
|
||||
const tiers = hasLeviathan
|
||||
|
|
@ -544,14 +39,15 @@ export function tierDifferenceToRangeOrExact({
|
|||
const upperBound = tier1Idx + idxDiff;
|
||||
|
||||
if (lowerBound < 0 || upperBound >= tiers.length) {
|
||||
return { diff: idxDiff, tier: structuredClone(theirTier) };
|
||||
return { type: "exact", diff: idxDiff, tier: structuredClone(theirTier) };
|
||||
}
|
||||
|
||||
const lowerTier = tiers[lowerBound];
|
||||
const upperTier = tiers[upperBound];
|
||||
|
||||
return {
|
||||
diff: idxDiff,
|
||||
tier: [structuredClone(lowerTier), structuredClone(upperTier)],
|
||||
type: "range",
|
||||
diff: [-idxDiff, idxDiff],
|
||||
range: [structuredClone(lowerTier), structuredClone(upperTier)],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Tables } from "~/db/tables";
|
||||
import type { LookingGroup } from "../q-types";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { GroupExpiryStatus } from "../q-types";
|
||||
import type { SQGroup } from "./SendouQ.server";
|
||||
|
||||
// logic is that team who is bigger decides the settings
|
||||
// but if groups are the same size then the one who liked
|
||||
|
|
@ -9,8 +10,8 @@ export function groupAfterMorph({
|
|||
theirGroup,
|
||||
liker,
|
||||
}: {
|
||||
ourGroup: LookingGroup;
|
||||
theirGroup: LookingGroup;
|
||||
ourGroup: SQGroup;
|
||||
theirGroup: SQGroup;
|
||||
liker: "US" | "THEM";
|
||||
}) {
|
||||
const ourMembers = ourGroup.members ?? [];
|
||||
|
|
@ -31,6 +32,24 @@ export function groupAfterMorph({
|
|||
return ourGroup;
|
||||
}
|
||||
|
||||
export function hasGroupManagerPerms(role: Tables["GroupMember"]["role"]) {
|
||||
return role === "OWNER" || role === "MANAGER";
|
||||
export function groupExpiryStatus(
|
||||
latestActionAt: number,
|
||||
): GroupExpiryStatus | null {
|
||||
// group expires in 30min without actions performed
|
||||
const groupExpiresAt =
|
||||
databaseTimestampToDate(latestActionAt).getTime() + 30 * 60 * 1000;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (now > groupExpiresAt) {
|
||||
return "EXPIRED";
|
||||
}
|
||||
|
||||
const tenMinutesFromNow = now + 10 * 60 * 1000;
|
||||
|
||||
if (tenMinutesFromNow > groupExpiresAt) {
|
||||
return "EXPIRING_SOON";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,131 +1,47 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
||||
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { hasGroupManagerPerms } from "../core/groups";
|
||||
import {
|
||||
addFutureMatchModes,
|
||||
addNoScreenIndicator,
|
||||
addReplayIndicator,
|
||||
addSkillRangeToGroups,
|
||||
addSkillsToGroups,
|
||||
censorGroups,
|
||||
censorGroupsIfOwnExpired,
|
||||
divideGroups,
|
||||
groupExpiryStatus,
|
||||
membersNeededForFull,
|
||||
sortGroupsBySkillAndSentiment,
|
||||
} from "../core/groups.server";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
import { groupRedirectLocationByCurrentLocation } from "../q-utils";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findLikes } from "../queries/findLikes";
|
||||
import { findRecentMatchPlayersByUserId } from "../queries/findRecentMatchPlayersByUserId.server";
|
||||
import { groupSize } from "../queries/groupSize.server";
|
||||
import { groupExpiryStatus } from "../core/groups";
|
||||
import { SendouQ } from "../core/SendouQ.server";
|
||||
import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server";
|
||||
import { sqRedirectIfNeeded } from "../q-utils.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUser(request);
|
||||
const user = await requireUser(request);
|
||||
|
||||
const isPreview = Boolean(
|
||||
const isPreview =
|
||||
new URL(request.url).searchParams.get("preview") === "true" &&
|
||||
user?.roles.includes("SUPPORTER"),
|
||||
user.roles.includes("SUPPORTER");
|
||||
|
||||
const privateNotes = await PrivateUserNoteRepository.byAuthorUserId(
|
||||
user.id,
|
||||
SendouQ.usersInQueue,
|
||||
);
|
||||
|
||||
const currentGroup =
|
||||
user && !isPreview ? findCurrentGroupByUserId(user.id) : undefined;
|
||||
const redirectLocation = isPreview
|
||||
? undefined
|
||||
: groupRedirectLocationByCurrentLocation({
|
||||
group: currentGroup,
|
||||
currentLocation: "looking",
|
||||
});
|
||||
const groups = isPreview
|
||||
? SendouQ.previewGroups(privateNotes)
|
||||
: SendouQ.lookingGroups(user.id, privateNotes);
|
||||
const ownGroup = SendouQ.findOwnGroup(user.id);
|
||||
|
||||
if (redirectLocation) {
|
||||
throw redirect(redirectLocation);
|
||||
}
|
||||
|
||||
invariant(currentGroup || isPreview, "currentGroup is undefined");
|
||||
|
||||
const currentGroupSize = currentGroup ? groupSize(currentGroup.id) : 1;
|
||||
const groupIsFull = currentGroupSize === FULL_GROUP_SIZE;
|
||||
|
||||
const dividedGroups = divideGroups({
|
||||
groups: await QRepository.findLookingGroups({
|
||||
maxGroupSize:
|
||||
groupIsFull || isPreview
|
||||
? undefined
|
||||
: membersNeededForFull(currentGroupSize),
|
||||
minGroupSize: groupIsFull && !isPreview ? FULL_GROUP_SIZE : undefined,
|
||||
ownGroupId: currentGroup?.id,
|
||||
includeMapModePreferences: Boolean(groupIsFull || isPreview),
|
||||
loggedInUserId: user?.id,
|
||||
}),
|
||||
ownGroupId: currentGroup?.id,
|
||||
likes: currentGroup ? findLikes(currentGroup.id) : [],
|
||||
sqRedirectIfNeeded({
|
||||
ownGroup,
|
||||
currentLocation: "looking",
|
||||
});
|
||||
|
||||
const season = Seasons.currentOrPrevious();
|
||||
|
||||
const {
|
||||
intervals,
|
||||
userSkills: calculatedUserSkills,
|
||||
isAccurateTiers,
|
||||
} = userSkills(season!.nth);
|
||||
const groupsWithSkills = addSkillsToGroups({
|
||||
groups: dividedGroups,
|
||||
intervals,
|
||||
userSkills: calculatedUserSkills,
|
||||
});
|
||||
|
||||
const groupsWithFutureMatchModes = addFutureMatchModes(groupsWithSkills);
|
||||
|
||||
const groupsWithNoScreenIndicator = addNoScreenIndicator(
|
||||
groupsWithFutureMatchModes,
|
||||
);
|
||||
|
||||
const groupsWithReplayIndicator = groupIsFull
|
||||
? addReplayIndicator({
|
||||
groups: groupsWithNoScreenIndicator,
|
||||
recentMatchPlayers: findRecentMatchPlayersByUserId(user!.id),
|
||||
userId: user!.id,
|
||||
})
|
||||
: groupsWithNoScreenIndicator;
|
||||
|
||||
const censoredGroups = censorGroups({
|
||||
groups: groupsWithReplayIndicator,
|
||||
showInviteCode: currentGroup
|
||||
? hasGroupManagerPerms(currentGroup.role) && !groupIsFull
|
||||
: false,
|
||||
});
|
||||
|
||||
const rangedGroups = addSkillRangeToGroups({
|
||||
groups: censoredGroups,
|
||||
hasLeviathan: isAccurateTiers,
|
||||
isPreview,
|
||||
});
|
||||
|
||||
const sortedGroups = sortGroupsBySkillAndSentiment({
|
||||
groups: rangedGroups,
|
||||
intervals,
|
||||
userSkills: calculatedUserSkills,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
const expiryStatus = groupExpiryStatus(currentGroup);
|
||||
|
||||
return {
|
||||
groups: censorGroupsIfOwnExpired({
|
||||
groups: sortedGroups,
|
||||
ownGroupExpiryStatus: expiryStatus,
|
||||
}),
|
||||
role: currentGroup ? currentGroup.role : ("PREVIEWER" as const),
|
||||
chatCode: currentGroup?.chatCode,
|
||||
groups:
|
||||
ownGroup && groupExpiryStatus(ownGroup.latestActionAt) === "EXPIRED"
|
||||
? []
|
||||
: groups,
|
||||
ownGroup,
|
||||
likes: ownGroup
|
||||
? await SQGroupRepository.allLikesByGroupId(ownGroup.id)
|
||||
: {
|
||||
given: [],
|
||||
received: [],
|
||||
},
|
||||
lastUpdated: Date.now(),
|
||||
streamsCount: (await cachedStreams()).length,
|
||||
expiryStatus: groupExpiryStatus(currentGroup),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,30 +1,20 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { groupRedirectLocationByCurrentLocation } from "../q-utils";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findPreparingGroup } from "../queries/findPreparingGroup.server";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { SendouQ } from "../core/SendouQ.server";
|
||||
import { sqRedirectIfNeeded } from "../q-utils.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUser(request);
|
||||
const user = await requireUserId(request);
|
||||
|
||||
const currentGroup = user ? findCurrentGroupByUserId(user.id) : undefined;
|
||||
const redirectLocation = groupRedirectLocationByCurrentLocation({
|
||||
group: currentGroup,
|
||||
const ownGroup = SendouQ.findOwnGroup(user.id);
|
||||
|
||||
sqRedirectIfNeeded({
|
||||
ownGroup,
|
||||
currentLocation: "preparing",
|
||||
});
|
||||
|
||||
if (redirectLocation) {
|
||||
throw redirect(redirectLocation);
|
||||
}
|
||||
|
||||
const ownGroup = findPreparingGroup(currentGroup!.id);
|
||||
invariant(ownGroup, "No own group found");
|
||||
|
||||
return {
|
||||
lastUpdated: Date.now(),
|
||||
group: ownGroup,
|
||||
role: currentGroup!.role,
|
||||
group: ownGroup!,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { getUserId } from "~/features/auth/core/user.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { SendouQ } from "../core/SendouQ.server";
|
||||
import { JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
|
||||
import { groupRedirectLocationByCurrentLocation } from "../q-utils";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findGroupByInviteCode } from "../queries/findGroupByInviteCode.server";
|
||||
import { sqRedirectIfNeeded } from "../q-utils.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUserId(request);
|
||||
|
|
@ -15,16 +13,15 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||
JOIN_CODE_SEARCH_PARAM_KEY,
|
||||
);
|
||||
|
||||
const redirectLocation = groupRedirectLocationByCurrentLocation({
|
||||
group: user ? findCurrentGroupByUserId(user.id) : undefined,
|
||||
const ownGroup = user ? SendouQ.findOwnGroup(user.id) : undefined;
|
||||
|
||||
sqRedirectIfNeeded({
|
||||
ownGroup,
|
||||
currentLocation: "default",
|
||||
});
|
||||
|
||||
if (redirectLocation) {
|
||||
throw redirect(`${redirectLocation}${code ? "?joining=true" : ""}`);
|
||||
}
|
||||
|
||||
const groupInvitedTo = code && user ? findGroupByInviteCode(code) : undefined;
|
||||
const groupInvitedTo =
|
||||
code && user ? SendouQ.findGroupByInviteCode(code) : undefined;
|
||||
|
||||
const season = Seasons.current();
|
||||
const upcomingSeason = !season ? Seasons.next() : undefined;
|
||||
|
|
|
|||
|
|
@ -1,66 +1,16 @@
|
|||
import type { ParsedMemento, QWeaponPool, Tables } from "~/db/tables";
|
||||
import type { ModeShort } from "~/modules/in-game-lists/types";
|
||||
import type { TieredSkill } from "../mmr/tiered.server";
|
||||
import type { GroupForMatch } from "../sendouq-match/QMatchRepository.server";
|
||||
|
||||
export type LookingGroup = {
|
||||
id: number;
|
||||
createdAt: Tables["Group"]["createdAt"];
|
||||
tier?: TieredSkill["tier"];
|
||||
tierRange?: {
|
||||
range?: [TieredSkill["tier"], TieredSkill["tier"]];
|
||||
diff: number;
|
||||
};
|
||||
isReplay?: boolean;
|
||||
isNoScreen?: boolean;
|
||||
isLiked?: boolean;
|
||||
isRechallenge?: boolean;
|
||||
team?: GroupForMatch["team"];
|
||||
chatCode?: Tables["Group"]["chatCode"];
|
||||
mapModePreferences?: Array<NonNullable<Tables["User"]["mapModePreferences"]>>;
|
||||
futureMatchModes?: Array<ModeShort>;
|
||||
rechallengeMatchModes?: Array<ModeShort>;
|
||||
skillDifference?: ParsedMemento["groups"][number]["skillDifference"];
|
||||
members?: {
|
||||
id: number;
|
||||
discordId: string;
|
||||
username: string;
|
||||
discordAvatar: string | null;
|
||||
noScreen?: number;
|
||||
customUrl?: Tables["User"]["customUrl"];
|
||||
plusTier?: Tables["PlusTier"]["tier"];
|
||||
role: Tables["GroupMember"]["role"];
|
||||
note?: Tables["GroupMember"]["note"];
|
||||
weapons?: QWeaponPool[];
|
||||
skill?: TieredSkill | "CALCULATING";
|
||||
vc?: Tables["User"]["vc"];
|
||||
inGameName?: Tables["User"]["inGameName"];
|
||||
languages: string[];
|
||||
chatNameColor: string | null;
|
||||
skillDifference?: ParsedMemento["users"][number]["skillDifference"];
|
||||
friendCode?: string;
|
||||
privateNote: Pick<
|
||||
Tables["PrivateUserNote"],
|
||||
"sentiment" | "text" | "updatedAt"
|
||||
> | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type LookingGroupWithInviteCode = LookingGroup & {
|
||||
inviteCode: Tables["Group"]["inviteCode"];
|
||||
members: NonNullable<LookingGroup["members"]>;
|
||||
};
|
||||
|
||||
export interface DividedGroups {
|
||||
own?: LookingGroup | LookingGroupWithInviteCode;
|
||||
neutral: LookingGroup[];
|
||||
likesReceived: LookingGroup[];
|
||||
}
|
||||
|
||||
export interface DividedGroupsUncensored {
|
||||
own?: LookingGroupWithInviteCode;
|
||||
neutral: LookingGroupWithInviteCode[];
|
||||
likesReceived: LookingGroupWithInviteCode[];
|
||||
}
|
||||
|
||||
export type GroupExpiryStatus = "EXPIRING_SOON" | "EXPIRED";
|
||||
|
||||
export type TierDifference =
|
||||
| { type: "exact"; diff: number; tier: TieredSkill["tier"] }
|
||||
| {
|
||||
type: "range";
|
||||
diff: [number, number];
|
||||
range: [TieredSkill["tier"], TieredSkill["tier"]];
|
||||
};
|
||||
|
||||
export type TierRange = Omit<
|
||||
Extract<TierDifference, { type: "range" }>,
|
||||
"type"
|
||||
>;
|
||||
|
|
|
|||
72
app/features/sendouq/q-utils.server.ts
Normal file
72
app/features/sendouq/q-utils.server.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { redirect } from "@remix-run/node";
|
||||
import { TIERS } from "~/features/mmr/mmr-constants";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import {
|
||||
SENDOUQ_LOOKING_PAGE,
|
||||
SENDOUQ_PAGE,
|
||||
SENDOUQ_PREPARING_PAGE,
|
||||
sendouQMatchPage,
|
||||
} from "~/utils/urls";
|
||||
import type { SQOwnGroup } from "./core/SendouQ.server";
|
||||
|
||||
/** Error class for SendouQ (expected) errors */
|
||||
export class SendouQError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SendouQError";
|
||||
}
|
||||
}
|
||||
|
||||
function groupRedirectLocation(group?: SQOwnGroup) {
|
||||
if (group?.status === "PREPARING") return SENDOUQ_PREPARING_PAGE;
|
||||
if (group?.matchId) return sendouQMatchPage(group.matchId);
|
||||
if (group) return SENDOUQ_LOOKING_PAGE;
|
||||
|
||||
return SENDOUQ_PAGE;
|
||||
}
|
||||
|
||||
/** User needs to be on certain page depending on their SendouQ group status. This functions throws a `Redirect` if they are trying to load the wrong page. */
|
||||
export function sqRedirectIfNeeded({
|
||||
ownGroup,
|
||||
currentLocation,
|
||||
}: {
|
||||
ownGroup?: SQOwnGroup;
|
||||
currentLocation: "default" | "preparing" | "looking" | "match";
|
||||
}) {
|
||||
const newLocation = groupRedirectLocation(ownGroup);
|
||||
|
||||
// we are already in the correct location, don't redirect
|
||||
if (currentLocation === "default" && newLocation === SENDOUQ_PAGE) return;
|
||||
if (currentLocation === "preparing" && newLocation === SENDOUQ_PREPARING_PAGE)
|
||||
return;
|
||||
if (currentLocation === "looking" && newLocation === SENDOUQ_LOOKING_PAGE)
|
||||
return;
|
||||
if (currentLocation === "match" && newLocation.includes("match")) return;
|
||||
|
||||
throw redirect(newLocation);
|
||||
}
|
||||
|
||||
const allTiersOrdered = TIERS.flatMap((t) => [
|
||||
{ name: t.name, isPlus: true },
|
||||
{ name: t.name, isPlus: false },
|
||||
]).reverse();
|
||||
const allTiersOrderedWithLeviathan = allTiersOrdered.filter(
|
||||
(t) => t.name !== "LEVIATHAN",
|
||||
);
|
||||
|
||||
export function getTierIndex(
|
||||
tier: TieredSkill["tier"] | null | undefined,
|
||||
isAccurateTiers: boolean,
|
||||
) {
|
||||
if (!tier) return null;
|
||||
|
||||
const tiers = isAccurateTiers
|
||||
? allTiersOrdered
|
||||
: allTiersOrderedWithLeviathan;
|
||||
|
||||
const index = tiers.findIndex(
|
||||
(t) => t.name === tier.name && t.isPlus === tier.isPlus,
|
||||
);
|
||||
|
||||
return index === -1 ? null : index;
|
||||
}
|
||||
|
|
@ -1,75 +1,7 @@
|
|||
import type { Tables } from "~/db/tables";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import {
|
||||
SENDOUQ_LOOKING_PAGE,
|
||||
SENDOUQ_PAGE,
|
||||
SENDOUQ_PREPARING_PAGE,
|
||||
sendouQMatchPage,
|
||||
} from "~/utils/urls";
|
||||
import { accountCreatedInTheLastSixMonths } from "~/utils/users";
|
||||
import type { MapPool } from "../map-list-generator/core/map-pool";
|
||||
import { SENDOUQ } from "./q-constants";
|
||||
|
||||
function groupRedirectLocation(
|
||||
group?: Pick<Tables["Group"], "status"> & { matchId?: number },
|
||||
) {
|
||||
if (group?.status === "PREPARING") return SENDOUQ_PREPARING_PAGE;
|
||||
if (group?.matchId) return sendouQMatchPage(group.matchId);
|
||||
if (group) return SENDOUQ_LOOKING_PAGE;
|
||||
|
||||
return SENDOUQ_PAGE;
|
||||
}
|
||||
|
||||
export function groupRedirectLocationByCurrentLocation({
|
||||
group,
|
||||
currentLocation,
|
||||
}: {
|
||||
group?: Pick<Tables["Group"], "status"> & { matchId?: number };
|
||||
currentLocation: "default" | "preparing" | "looking" | "match";
|
||||
}) {
|
||||
const newLocation = groupRedirectLocation(group);
|
||||
|
||||
// we are already in the correct location, don't redirect
|
||||
if (currentLocation === "default" && newLocation === SENDOUQ_PAGE) return;
|
||||
if (currentLocation === "preparing" && newLocation === SENDOUQ_PREPARING_PAGE)
|
||||
return;
|
||||
if (currentLocation === "looking" && newLocation === SENDOUQ_LOOKING_PAGE)
|
||||
return;
|
||||
if (currentLocation === "match" && newLocation.includes("match")) return;
|
||||
|
||||
return newLocation;
|
||||
}
|
||||
|
||||
export function mapPoolOk(mapPool: MapPool) {
|
||||
for (const modeShort of rankedModesShort) {
|
||||
if (
|
||||
modeShort === "SZ" &&
|
||||
mapPool.countMapsByMode(modeShort) !== SENDOUQ.SZ_MAP_COUNT
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
modeShort !== "SZ" &&
|
||||
mapPool.countMapsByMode(modeShort) !== SENDOUQ.OTHER_MODE_MAP_COUNT
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const stageId of stageIds) {
|
||||
if (
|
||||
mapPool.stageModePairs.filter((pair) => pair.stageId === stageId).length >
|
||||
SENDOUQ.MAX_STAGE_REPEAT_COUNT
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
import type { SQGroup } from "./core/SendouQ.server";
|
||||
|
||||
export function userCanJoinQueueAt(
|
||||
user: { id: number; discordId: string },
|
||||
|
|
@ -94,3 +26,25 @@ export function userCanJoinQueueAt(
|
|||
|
||||
return canJoinQueueAt;
|
||||
}
|
||||
|
||||
export function resolveFutureMatchModes({
|
||||
ownGroup,
|
||||
theirGroup,
|
||||
}: {
|
||||
ownGroup: Pick<SQGroup, "modePreferences">;
|
||||
theirGroup: Pick<SQGroup, "modePreferences">;
|
||||
}) {
|
||||
const ourModes = ownGroup.modePreferences;
|
||||
const theirModes = theirGroup.modePreferences;
|
||||
|
||||
const overlap = ourModes.filter((mode) => theirModes.includes(mode));
|
||||
if (overlap.length > 0) {
|
||||
return overlap;
|
||||
}
|
||||
|
||||
const union = modesShort.filter(
|
||||
(mode) => ourModes.includes(mode) || theirModes.includes(mode),
|
||||
);
|
||||
|
||||
return union;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,560 +0,0 @@
|
|||
.twf {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.q__clocks-container {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.q__clock {
|
||||
font-size: var(--fonts-sm);
|
||||
font-weight: var(--bold);
|
||||
color: var(--text-lighter);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
flex: 1 1 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.q__clock-country {
|
||||
color: var(--text) !important;
|
||||
white-space: nowrap;
|
||||
font-size: var(--fonts-xs);
|
||||
}
|
||||
|
||||
.q__front-page-link {
|
||||
background-color: var(--bg-lighter);
|
||||
border-radius: var(--rounded-sm);
|
||||
padding: var(--s-2);
|
||||
font-size: var(--fonts-sm);
|
||||
color: var(--text);
|
||||
font-weight: var(--bold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2-5);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.q__front-page-link:hover {
|
||||
background-color: var(--theme-transparent);
|
||||
}
|
||||
|
||||
.q__front-page-link__sub-text {
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--body);
|
||||
}
|
||||
|
||||
.q__tab-button {
|
||||
width: 100px;
|
||||
background-color: var(--bg-lighter-solid);
|
||||
color: var(--text);
|
||||
font-size: var(--fonts-sm);
|
||||
border-color: transparent;
|
||||
gap: var(--s-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.q__tab-button__icon {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.q__tab-button__badge {
|
||||
font-size: var(--fonts-xxs);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background-color: var(--theme);
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -1px;
|
||||
border-radius: 100%;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: var(--button-text);
|
||||
}
|
||||
|
||||
.q__top-container {
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.q__top-container__divider {
|
||||
min-width: 3px;
|
||||
background-color: var(--border);
|
||||
height: 100%;
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.q__chat-container {
|
||||
top: var(--sticky-top);
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.q__chat-messages-container {
|
||||
height: 350px;
|
||||
}
|
||||
|
||||
.q__groups-container {
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--s-7);
|
||||
}
|
||||
|
||||
.q__groups-container__mobile {
|
||||
grid-template-columns: 1fr;
|
||||
min-width: 300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.q__groups-inner-container {
|
||||
max-width: 94vw;
|
||||
}
|
||||
|
||||
.q__column-header {
|
||||
text-align: center;
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--semi-bold);
|
||||
text-transform: uppercase;
|
||||
color: var(--theme);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.q__column-header::before,
|
||||
.q__column-header::after {
|
||||
flex: 1;
|
||||
content: "";
|
||||
padding: 2px;
|
||||
background-color: var(--theme-transparent);
|
||||
margin: 5px;
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.q__group {
|
||||
background-color: var(--bg-lighter-solid);
|
||||
width: 100%;
|
||||
border-radius: var(--rounded);
|
||||
padding: var(--s-2-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
position: relative;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.q__group__no-screen {
|
||||
background-color: var(--theme-error-transparent);
|
||||
border-radius: 100%;
|
||||
padding: var(--s-1);
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.q__group__display-only {
|
||||
height: 100%;
|
||||
padding-block-end: var(--s-10);
|
||||
}
|
||||
|
||||
.q__group-member {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
background-color: var(--bg-darker);
|
||||
border-radius: var(--rounded);
|
||||
font-size: var(--fonts-xsm);
|
||||
font-weight: var(--semi-bold);
|
||||
padding-inline-end: var(--s-2-5);
|
||||
}
|
||||
|
||||
.q__group-member__name {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-width: 7.5rem;
|
||||
font-size: var(--fonts-xs);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.q__group-member__avatar {
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.q__group-member__avatar__POSITIVE {
|
||||
outline: 2px solid var(--theme-success-transparent);
|
||||
}
|
||||
|
||||
.q__group-member__avatar__NEUTRAL {
|
||||
outline: 2px solid var(--theme-warning-transparent);
|
||||
}
|
||||
|
||||
.q__group-member__avatar__NEGATIVE {
|
||||
outline: 2px solid var(--theme-error-transparent);
|
||||
}
|
||||
|
||||
.q__group-member__tier {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.q__group-member__tier__placeholder {
|
||||
min-width: 26.58px;
|
||||
}
|
||||
|
||||
.q__group-member__extra-info {
|
||||
font-size: var(--fonts-xs);
|
||||
background-color: var(--bg-darker);
|
||||
border-radius: var(--rounded);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
width: max-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
font-weight: var(--semi-bold);
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.q__group-member__extra-info-button {
|
||||
font-size: var(--fonts-xs);
|
||||
background-color: var(--bg-darker);
|
||||
border-radius: var(--rounded);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
width: max-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.q__group-member__add-note-button {
|
||||
border: none;
|
||||
padding: 0 var(--s-1-5);
|
||||
color: var(--body);
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--semi-bold);
|
||||
background-color: var(--bg-darker);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.q__group-member__add-note-button__edit > svg {
|
||||
color: var(--theme);
|
||||
}
|
||||
|
||||
.q__group-member__add-note-button > svg {
|
||||
width: 14px;
|
||||
margin-inline-end: var(--s-1);
|
||||
}
|
||||
|
||||
.q__group-member__note-textarea {
|
||||
height: 4rem !important;
|
||||
}
|
||||
|
||||
.q__group__future-match-mode {
|
||||
border-radius: 100%;
|
||||
background-color: var(--bg-lightest);
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--s-1-5);
|
||||
}
|
||||
|
||||
.q__group__future-match-mode__rechallenge {
|
||||
outline: 2px solid var(--theme-secondary);
|
||||
}
|
||||
|
||||
.q__group-member-weapons {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
margin-block-start: -2px;
|
||||
}
|
||||
|
||||
.q__group-member-weapon {
|
||||
background-color: var(--bg);
|
||||
border-radius: 100%;
|
||||
padding: var(--s-1);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.q__group-member-vc-icon {
|
||||
height: 15px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.q__group-member__star {
|
||||
min-width: 18px;
|
||||
max-width: 18px;
|
||||
color: var(--theme-secondary);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.q__group-member__star__inactive {
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.q__group__display-group-tier {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-darker);
|
||||
padding: var(--s-0-5) var(--s-2-5);
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
bottom: -36px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.q__group__or-popover-button {
|
||||
background-color: transparent;
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
padding: 0;
|
||||
border: none;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
font-weight: var(--bold);
|
||||
height: 19.8281px;
|
||||
}
|
||||
|
||||
.q__member-adder__input {
|
||||
--input-width: 11rem;
|
||||
width: 11rem;
|
||||
}
|
||||
|
||||
.q-preparing__card-container {
|
||||
min-width: 250px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.q-match__stage-popover-button {
|
||||
background-color: transparent;
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
padding: 0;
|
||||
border: none;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
font-weight: var(--body);
|
||||
height: 19.8281px;
|
||||
}
|
||||
|
||||
.q-match__stage-popover-button:focus {
|
||||
outline: none;
|
||||
color: var(--theme);
|
||||
}
|
||||
|
||||
.q-match__mode-popover-button {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.q-match__mode-popover-button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.q-match__join-discord-section {
|
||||
border-left: 4px solid var(--theme);
|
||||
padding-inline-start: var(--s-4);
|
||||
font-size: var(--fonts-sm);
|
||||
color: var(--text-lighter);
|
||||
margin-block-start: var(--s-1);
|
||||
}
|
||||
|
||||
.q-match__join-discord-section__highlighted {
|
||||
font-size: var(--fonts-md);
|
||||
letter-spacing: 1px;
|
||||
color: var(--text);
|
||||
font-weight: var(--semi-bold);
|
||||
}
|
||||
|
||||
.q-match__container {
|
||||
/** Push footer down to avoid it "flashing" when the score reporter animates */
|
||||
padding-bottom: 14rem;
|
||||
}
|
||||
|
||||
.q-match__header {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.q-match__teams-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.q-match__map-list-chat-container {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 2fr;
|
||||
place-items: center;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.q-match__report__user-name-container {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
width: 175px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.q-match__report-section {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
row-gap: var(--s-2);
|
||||
column-gap: var(--s-2-5);
|
||||
align-items: center;
|
||||
font-size: var(--fonts-xs);
|
||||
}
|
||||
|
||||
.q-match__pool-pass-container {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
flex-direction: column;
|
||||
max-width: max-content;
|
||||
}
|
||||
|
||||
.q-match__sentiment-emoji {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.q-match__chat-container {
|
||||
align-self: flex-start;
|
||||
top: var(--sticky-top);
|
||||
position: sticky;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.q-match__bottom-mid-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: flex-start;
|
||||
top: var(--sticky-top);
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.q-match__info__header {
|
||||
text-transform: uppercase;
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.q-match__info__value {
|
||||
font-size: var(--fonts-xl);
|
||||
font-weight: var(--semi-bold);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.q-match__screen-legality svg {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.q-match__screen-legality .alert {
|
||||
padding-block: var(--s-1);
|
||||
padding-inline: var(--s-2-5);
|
||||
}
|
||||
|
||||
.q-match__screen-legality__button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.q-match__screen-legality__button:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.q-match__screen-legality__button:focus-visible .alert {
|
||||
background-color: var(--bg-lighter);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 640px) {
|
||||
.q-match__teams-container {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.q-stream__stream__user-container {
|
||||
font-size: var(--fonts-xs);
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.q-stream__stream__team-name {
|
||||
color: var(--theme-secondary);
|
||||
}
|
||||
|
||||
.q-stream__stream__viewer-count {
|
||||
font-size: var(--fonts-xs);
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
margin-block-start: -5px;
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.q-stream__stream__viewer-count > svg {
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
.q-stream__info-circle {
|
||||
border-radius: 100%;
|
||||
background-color: var(--bg-lighter);
|
||||
padding: var(--s-1);
|
||||
}
|
||||
|
||||
.q-info__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
font-size: var(--fonts-md);
|
||||
}
|
||||
|
||||
.q-info__container h2 {
|
||||
font-size: var(--fonts-lg);
|
||||
margin-block-end: var(--s-2);
|
||||
color: var(--theme-secondary);
|
||||
}
|
||||
|
||||
.q-info__container h3 {
|
||||
font-size: var(--fonts-md);
|
||||
margin-block-end: var(--s-3);
|
||||
}
|
||||
|
||||
.q-info__container p {
|
||||
margin-block: var(--s-2);
|
||||
}
|
||||
|
||||
.q-info__table-of-contents ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.q-info__table-of-contents li {
|
||||
list-style: none;
|
||||
font-size: var(--fonts-sm);
|
||||
font-weight: var(--semi-bold);
|
||||
}
|
||||
|
||||
.q-info__table-of-contents li:has(button) {
|
||||
margin-block-start: var(--s-2);
|
||||
}
|
||||
|
||||
.q-info__table-of-contents li:not(:has(button)) {
|
||||
margin-inline-start: var(--s-2);
|
||||
font-size: var(--fonts-xs);
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
insert into "GroupLike" ("likerGroupId", "targetGroupId")
|
||||
values (@likerGroupId, @targetGroupId)
|
||||
on conflict ("likerGroupId", "targetGroupId") do nothing
|
||||
`);
|
||||
|
||||
export function addLike({
|
||||
likerGroupId,
|
||||
targetGroupId,
|
||||
}: {
|
||||
likerGroupId: number;
|
||||
targetGroupId: number;
|
||||
}) {
|
||||
stm.run({ likerGroupId, targetGroupId });
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
update "GroupMember"
|
||||
set "role" = 'MANAGER'
|
||||
where "userId" = @userId
|
||||
and "groupId" = @groupId
|
||||
`);
|
||||
|
||||
export function addManagerRole({
|
||||
userId,
|
||||
groupId,
|
||||
}: {
|
||||
userId: number;
|
||||
groupId: number;
|
||||
}) {
|
||||
stm.run({ userId, groupId });
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
insert into "GroupMember" (
|
||||
"groupId",
|
||||
"userId",
|
||||
"role"
|
||||
) values (
|
||||
@groupId,
|
||||
@userId,
|
||||
@role
|
||||
)
|
||||
`);
|
||||
|
||||
export function addMember({
|
||||
groupId,
|
||||
userId,
|
||||
role = "REGULAR",
|
||||
}: {
|
||||
groupId: number;
|
||||
userId: number;
|
||||
role?: Tables["GroupMember"]["role"];
|
||||
}) {
|
||||
stm.run({ groupId, userId, role });
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
select
|
||||
"chatCode"
|
||||
from "Group"
|
||||
where "id" = @id
|
||||
`);
|
||||
|
||||
export function chatCodeByGroupId(id: number) {
|
||||
return (stm.get({ id }) as any)?.chatCode as string | undefined;
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { ParsedMemento, Tables } from "~/db/tables";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import { syncGroupTeamId } from "./syncGroupTeamId.server";
|
||||
|
||||
const createMatchStm = sql.prepare(/* sql */ `
|
||||
insert into "GroupMatch" (
|
||||
"alphaGroupId",
|
||||
"bravoGroupId",
|
||||
"chatCode",
|
||||
"memento"
|
||||
) values (
|
||||
@alphaGroupId,
|
||||
@bravoGroupId,
|
||||
@chatCode,
|
||||
@memento
|
||||
)
|
||||
returning *
|
||||
`);
|
||||
|
||||
const createMatchMapStm = sql.prepare(/* sql */ `
|
||||
insert into "GroupMatchMap" (
|
||||
"matchId",
|
||||
"index",
|
||||
"mode",
|
||||
"stageId",
|
||||
"source"
|
||||
) values (
|
||||
@matchId,
|
||||
@index,
|
||||
@mode,
|
||||
@stageId,
|
||||
@source
|
||||
)
|
||||
`);
|
||||
|
||||
export const createMatch = sql.transaction(
|
||||
({
|
||||
alphaGroupId,
|
||||
bravoGroupId,
|
||||
mapList,
|
||||
memento,
|
||||
}: {
|
||||
alphaGroupId: number;
|
||||
bravoGroupId: number;
|
||||
mapList: TournamentMapListMap[];
|
||||
memento: ParsedMemento;
|
||||
}) => {
|
||||
const match = createMatchStm.get({
|
||||
alphaGroupId,
|
||||
bravoGroupId,
|
||||
chatCode: shortNanoid(),
|
||||
memento: JSON.stringify(memento),
|
||||
}) as Tables["GroupMatch"];
|
||||
|
||||
for (const [i, { mode, source, stageId }] of mapList.entries()) {
|
||||
createMatchMapStm.run({
|
||||
matchId: match.id,
|
||||
index: i,
|
||||
mode,
|
||||
stageId,
|
||||
source: String(source),
|
||||
});
|
||||
}
|
||||
|
||||
syncGroupTeamId(alphaGroupId);
|
||||
syncGroupTeamId(bravoGroupId);
|
||||
|
||||
return match;
|
||||
},
|
||||
);
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
delete from "GroupLike"
|
||||
where "likerGroupId" = @likerGroupId
|
||||
and "targetGroupId" = @targetGroupId
|
||||
`);
|
||||
|
||||
export function deleteLike({
|
||||
likerGroupId,
|
||||
targetGroupId,
|
||||
}: {
|
||||
likerGroupId: number;
|
||||
targetGroupId: number;
|
||||
}) {
|
||||
stm.run({ likerGroupId, targetGroupId });
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
delete from "GroupLike"
|
||||
where "likerGroupId" = @groupId
|
||||
or "targetGroupId" = @groupId
|
||||
`);
|
||||
|
||||
export function deleteLikesByGroupId(groupId: number) {
|
||||
stm.run({ groupId });
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
select
|
||||
"Group"."id",
|
||||
"Group"."status",
|
||||
"Group"."latestActionAt",
|
||||
"Group"."chatCode",
|
||||
"GroupMatch"."id" as "matchId",
|
||||
"GroupMember"."role"
|
||||
from
|
||||
"Group"
|
||||
left join "GroupMember" on "GroupMember"."groupId" = "Group"."id"
|
||||
left join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id"
|
||||
or "GroupMatch"."bravoGroupId" = "Group"."id"
|
||||
where
|
||||
"Group"."status" != 'INACTIVE'
|
||||
and "GroupMember"."userId" = @userId
|
||||
`);
|
||||
|
||||
type ActiveGroup = Pick<
|
||||
Tables["Group"],
|
||||
"id" | "status" | "latestActionAt" | "chatCode"
|
||||
> & {
|
||||
matchId?: number;
|
||||
role: Tables["GroupMember"]["role"];
|
||||
};
|
||||
|
||||
export function findCurrentGroupByUserId(
|
||||
userId: number,
|
||||
): ActiveGroup | undefined {
|
||||
const groups = stm.all({ userId }) as any;
|
||||
|
||||
invariant(groups.length <= 1, "User can't be in more than one group");
|
||||
|
||||
return groups[0];
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { parseDBJsonArray } from "~/utils/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
select
|
||||
"Group"."id",
|
||||
"Group"."status",
|
||||
json_group_array(
|
||||
json_object(
|
||||
'id', "User"."id",
|
||||
'username', "User"."username",
|
||||
'role', "GroupMember"."role"
|
||||
)
|
||||
) as "members"
|
||||
from
|
||||
"Group"
|
||||
left join "GroupMember" on "GroupMember"."groupId" = "Group"."id"
|
||||
left join "User" on "User"."id" = "GroupMember"."userId"
|
||||
where
|
||||
"Group"."inviteCode" = @inviteCode
|
||||
and "Group"."status" != 'INACTIVE'
|
||||
group by "Group"."id"
|
||||
`);
|
||||
|
||||
export function findGroupByInviteCode(inviteCode: string): {
|
||||
id: number;
|
||||
status: Tables["Group"]["status"];
|
||||
members: {
|
||||
id: number;
|
||||
username: string;
|
||||
role: Tables["GroupMember"]["role"];
|
||||
}[];
|
||||
} | null {
|
||||
const row = stm.get({ inviteCode }) as any;
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
members: parseDBJsonArray(row.members),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
select
|
||||
"GroupLike"."likerGroupId",
|
||||
"GroupLike"."targetGroupId",
|
||||
"GroupLike"."isRechallenge"
|
||||
from
|
||||
"GroupLike"
|
||||
where
|
||||
"GroupLike"."likerGroupId" = @groupId
|
||||
or "GroupLike"."targetGroupId" = @groupId
|
||||
order by
|
||||
"GroupLike"."createdAt" desc
|
||||
`);
|
||||
|
||||
export function findLikes(
|
||||
groupId: number,
|
||||
): Pick<
|
||||
Tables["GroupLike"],
|
||||
"likerGroupId" | "targetGroupId" | "isRechallenge"
|
||||
>[] {
|
||||
return stm.all({ groupId }) as any;
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import { parseDBJsonArray } from "~/utils/sql";
|
||||
import type { LookingGroupWithInviteCode } from "../q-types";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
with "q1" as (
|
||||
select
|
||||
"Group"."id",
|
||||
"Group"."createdAt",
|
||||
"Group"."inviteCode",
|
||||
"User"."id" as "userId",
|
||||
"User"."discordId",
|
||||
"User"."username",
|
||||
"User"."discordAvatar",
|
||||
"User"."qWeaponPool",
|
||||
"GroupMember"."role",
|
||||
"GroupMember"."note"
|
||||
from
|
||||
"Group"
|
||||
left join "GroupMember" on "GroupMember"."groupId" = "Group"."id"
|
||||
left join "User" on "User"."id" = "GroupMember"."userId"
|
||||
left join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id"
|
||||
or "GroupMatch"."bravoGroupId" = "Group"."id"
|
||||
where
|
||||
"Group"."id" = @ownGroupId
|
||||
and "Group"."status" = 'PREPARING'
|
||||
)
|
||||
select
|
||||
"q1"."id",
|
||||
"q1"."inviteCode",
|
||||
"q1"."createdAt",
|
||||
json_group_array(
|
||||
json_object(
|
||||
'id', "q1"."userId",
|
||||
'discordId', "q1"."discordId",
|
||||
'username', "q1"."username",
|
||||
'discordAvatar', "q1"."discordAvatar",
|
||||
'role', "q1"."role",
|
||||
'note', "q1"."note",
|
||||
'qWeaponPool', "q1"."qWeaponPool"
|
||||
)
|
||||
) as "members"
|
||||
from "q1"
|
||||
group by "q1"."id"
|
||||
order by "q1"."createdAt" desc
|
||||
`);
|
||||
|
||||
export function findPreparingGroup(
|
||||
ownGroupId: number,
|
||||
): LookingGroupWithInviteCode {
|
||||
const row = stm.get({ ownGroupId }) as any;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
createdAt: row.createdAt,
|
||||
chatCode: null,
|
||||
inviteCode: row.inviteCode,
|
||||
members: parseDBJsonArray(row.members).map((member: any) => {
|
||||
const weapons = member.qWeaponPool ? JSON.parse(member.qWeaponPool) : [];
|
||||
|
||||
return {
|
||||
...member,
|
||||
weapons: weapons.length > 0 ? weapons : undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
||||
const stm = sql.prepare(/* sql*/ `
|
||||
with "MostRecentGroupMatch" as (
|
||||
select
|
||||
"GroupMatch".*
|
||||
from "GroupMember"
|
||||
left join "Group" on "Group"."id" = "GroupMember"."groupId"
|
||||
inner join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id"
|
||||
or "GroupMatch"."bravoGroupId" = "Group"."id"
|
||||
where
|
||||
"GroupMember"."userId" = @userId
|
||||
order by "GroupMatch"."createdAt" desc
|
||||
limit 1
|
||||
)
|
||||
select
|
||||
"GroupMember"."groupId",
|
||||
"GroupMember"."userId"
|
||||
from "MostRecentGroupMatch"
|
||||
left join "Group" on "Group"."id" = "MostRecentGroupMatch"."alphaGroupId"
|
||||
or "Group"."id" = "MostRecentGroupMatch"."bravoGroupId"
|
||||
left join "GroupMember" on "GroupMember"."groupId" = "Group"."id"
|
||||
where
|
||||
"MostRecentGroupMatch"."createdAt" > unixepoch() - 60 * 60 * 2
|
||||
`);
|
||||
|
||||
export type RecentMatchPlayer = Pick<
|
||||
Tables["GroupMember"],
|
||||
"groupId" | "userId"
|
||||
>;
|
||||
|
||||
export function findRecentMatchPlayersByUserId(userId: number) {
|
||||
return stm.all({ userId }) as Array<RecentMatchPlayer>;
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
select 1
|
||||
from "GroupMatch"
|
||||
where
|
||||
"alphaGroupId" = @groupId
|
||||
or "bravoGroupId" = @groupId
|
||||
`);
|
||||
|
||||
export function groupHasMatch(groupId: number) {
|
||||
return Boolean(stm.get({ groupId }));
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
select
|
||||
count(*) as "count"
|
||||
from
|
||||
"GroupMember"
|
||||
where
|
||||
"GroupMember"."groupId" = @groupId
|
||||
`);
|
||||
|
||||
export function groupSize(groupId: number) {
|
||||
return (stm.get({ groupId }) as any).count as number;
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
select
|
||||
"GroupMember"."userId",
|
||||
"GroupMember"."role"
|
||||
from "GroupMember"
|
||||
where "GroupMember"."groupId" = @groupId
|
||||
and "GroupMember"."role" != 'OWNER'
|
||||
order by "GroupMember"."createdAt" asc
|
||||
`);
|
||||
|
||||
export const groupSuccessorOwner = (groupId: number) => {
|
||||
const rows = stm.all({ groupId }) as Array<
|
||||
Pick<Tables["GroupMember"], "role" | "userId">
|
||||
>;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const manager = rows.find((r) => r.role === "MANAGER");
|
||||
if (manager) return manager.userId;
|
||||
|
||||
return rows[0].userId;
|
||||
};
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { sql } from "~/db/sql";
|
||||
|
||||
const makeMemberOwnerStm = sql.prepare(/* sql */ `
|
||||
update "GroupMember"
|
||||
set "role" = 'OWNER'
|
||||
where "GroupMember"."groupId" = @groupId
|
||||
and "GroupMember"."userId" = @userId
|
||||
`);
|
||||
|
||||
const deleteGroupMemberStm = sql.prepare(/* sql */ `
|
||||
delete from "GroupMember"
|
||||
where "GroupMember"."groupId" = @groupId
|
||||
and "GroupMember"."userId" = @userId
|
||||
`);
|
||||
|
||||
const deleteGroupStm = sql.prepare(/* sql */ `
|
||||
delete from "Group"
|
||||
where "Group"."id" = @groupId
|
||||
`);
|
||||
|
||||
export const leaveGroup = sql.transaction(
|
||||
({
|
||||
groupId,
|
||||
userId,
|
||||
newOwnerId,
|
||||
wasOwner,
|
||||
}: {
|
||||
groupId: number;
|
||||
userId: number;
|
||||
newOwnerId: number | null;
|
||||
wasOwner: boolean;
|
||||
}) => {
|
||||
if (!wasOwner) {
|
||||
deleteGroupMemberStm.run({ groupId, userId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (newOwnerId) {
|
||||
makeMemberOwnerStm.run({ groupId, userId: newOwnerId });
|
||||
deleteGroupMemberStm.run({ groupId, userId });
|
||||
} else {
|
||||
deleteGroupStm.run({ groupId });
|
||||
}
|
||||
},
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user