SQ serverside refactoring, add tests, add Knip (#2665)

This commit is contained in:
Kalle 2025-12-22 15:47:15 +02:00 committed by GitHub
parent 459d172989
commit 94a93b0006
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
190 changed files with 4880 additions and 5663 deletions

View File

@ -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

View File

@ -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

View File

@ -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";

View File

@ -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>;
}

View File

@ -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",
})}

View File

@ -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 {

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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,
})),

View File

@ -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";

View File

@ -5,4 +5,5 @@ export const SEED_VARIATIONS = [
"SMALL_SOS",
"NZAP_IN_TEAM",
"NO_SCRIMS",
"NO_SQ_GROUPS",
] as const;

View File

@ -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;
};

View File

@ -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]);

View File

@ -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,

View File

@ -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),
})),

View File

@ -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";

View File

@ -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";

View File

@ -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,
});

View File

@ -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";

View File

@ -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")

View File

@ -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 }

View File

@ -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";

View File

@ -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,
}: {

View File

@ -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";

View File

@ -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,

View File

@ -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,
{

View File

@ -57,7 +57,7 @@ export function TagsFormField<T extends FieldValues>({
);
}
export const SelectableTags = React.forwardRef<
const SelectableTags = React.forwardRef<
HTMLDivElement,
{
selectedTags: Array<CalendarEventTag>;

View File

@ -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);

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

@ -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));
}

View File

@ -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";

View File

@ -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";

View File

@ -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",

View File

@ -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];

View File

@ -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,

View File

@ -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(

View File

@ -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,

View File

@ -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";

View File

@ -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)),
});

View File

@ -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);

View File

@ -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,
});

View File

@ -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`);
}
}
}

View File

@ -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,

View File

@ -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;

View File

@ -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!,
},
]),
),

View File

@ -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;

View File

@ -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,

View File

@ -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,
})

View File

@ -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;
}

View File

@ -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) {

View 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;
}
}

View File

@ -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";

View 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);
}

View File

@ -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>

View 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();
}

View File

@ -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();
}

View 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();
}

View File

@ -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;
}

View File

@ -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: {

View File

@ -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");
}

View 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;
}

View File

@ -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())} />}
/>
}
>

View File

@ -0,0 +1,4 @@
.input {
--input-width: 11rem;
width: 11rem;
}

View File

@ -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) => {

View 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);
});
});
});
});

View 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);
}

View File

@ -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>();

View File

@ -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" },
});
});
});

View File

@ -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)],
};
}

View File

@ -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;
}

View File

@ -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),
};
};

View File

@ -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!,
};
};

View File

@ -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;

View File

@ -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"
>;

View 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;
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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;
}

View File

@ -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;
},
);

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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];
}

View File

@ -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),
};
}

View File

@ -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;
}

View File

@ -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,
};
}),
};
}

View File

@ -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>;
}

View File

@ -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 }));
}

View File

@ -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;
}

View File

@ -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;
};

View File

@ -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