* Tables

* Clocks

* Maplist preference selector

* Fix SSR

* Nav icon

* RankedOrScrim

* Map pool

* Create group

* Redirect logic

* Persist map pool

* Advance from preparing page

* Rename query

* Fix merge

* Fix migration order

* Seed groups

* Find looking groups SQL

* Renders something

* More UI work

* Back to 30min

* Likes/dislikes

* Always return own group

* Fix like order

* 3 tc/rm/cb -> 2

* Show only 3 weapons

* Pass group size

* Handle both liked and liked by same group

* Fix SQL

* Group preference frontend work

* Morphing

* Styling

* Don't show group controls if not manager

* Give/remove manager

* Leave group

* Leave with confirm

* Delete likes when morphing groups

* Clocks consistency

* Remove bad invariant

* Persist settings to local storage

* Fix initial value flashing

* Fix never resolving loading indicator

* REFRESH_GROUP

* Flip animations

* Tweaks

* Auto refresh logic

* Groups of 4 seed

* Reduce throwing

* Load full groups initial

* Create match

* Match UI initial

* Score reporter initial

* Push footer down on match page

* Score reporter knows when set ended

* Score reporting untested

* Show score after report

* Align better

* Look again with same group functionality

* More migrations

* Team on match page

* Show confirmer before reporting score

* Report weapons

* Report weapos again by admin + skill changing

* Handle no tiebreaker given to MapPool

* Remove unranked

* Remove support for "team id skill"

* no-wrap -> nowrap

* Preparing page work

* Use common GroupCard component

* Add some metas

* MemberAdder in looking page

* Fix GroupCard actions

* Fix SZ only map list including other modes

* Add season info

* Prompt login

* Joining team

* Manage group on preparing page

* Manage group on preparing page

* Seed past matches

* Add to seed

* No map list preference when full group + fix expiry

* Fix skill matchesCount calculation

* Tiers initial work

* Some progress on tiers

* Tiering logic

* MMR in group cards

* Name to challenge

* Team MMR

* Big team rank icons

* Adjust todos

* Match score report with confirm

* Allow regular members to report score

* Handle reporting weapons edge cases

* Add tier images

* Improve GroupCard spacing

* Refactor looking page

* Looking mobile UI

* Calculate skill only for current season

* Divide groups visually when reporting weapons

* Fix match page weapons sorting

* Add cache to user skills+tier calculation

* Admin report match score

* Initial leaderboard

* Cached leaderboard

* Weapon category lb's

* Populate SkillTeamUser in SendouQ

* Team leaderboard filtered down

* Add TODOs

* Seasons initlal

* Season weapons initial

* Weapons stylized

* Show rest weapons as +

* Hide peak if same as current

* Load matches SQL initial

* Season matches UI initial

* Take user id in account

* Add weapons

* Paginated matches

* Fix pages count logic

* Scroll top on data change

* Day headers for matches

* Link from user page to user seasons page

* Summarize maps + ui initial

* Map stats

* Player info tabs

* MMR chart

* Chart adjustments

* Handle basing team MMR on player MMR

* Set initial MMR

* Add info about discord to match page

* Season support to tournaments

* Get tournament skills as well for the graph

* WIP

* New team rating logic + misc other

* tiered -> tiered.server

* Update season starting time

* TODOs

* Add rules page

* Hide elements correctly when off-season

* Fix crash when only one player with skill

* How-to video

* Fix StartRank showing when not logged in

* Make user leaderboard the default

* Make Skill season non-nullable

* Add suggested pass to match

* Add rule

* identifierToUserIds helper

* Fix tiers not showing
This commit is contained in:
Kalle 2023-08-12 22:42:54 +03:00 committed by GitHub
parent f738b2aba7
commit e7bbb565be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
136 changed files with 8520 additions and 169 deletions

View File

@ -44,13 +44,6 @@ module.exports = {
"Please import useTranslation from '~/hooks/useTranslation' instead.",
},
],
patterns: [
{
group: ["~/features/*/*"],
message:
"Please import from '~/features' instead. Export from index.ts what should be exposed. See README for more information.",
},
],
},
],
},

View File

@ -137,8 +137,7 @@ Some common files:
- feature-constants.ts
- feature-schemas.server.ts
Note: Anything that should be accessed outside of that feature should be re-exported in the index.ts file.
Note 2: Currently the project is in progress to migrating this style so that's why not everything is using it yet.
Note: Currently the project is in progress to migrating this style so that's why not everything is using it yet.
## Commands

51
app/components/Chart.tsx Normal file
View File

@ -0,0 +1,51 @@
import React from "react";
import { type AxisOptions, Chart as ReactChart } from "react-charts";
import { useIsMounted } from "~/hooks/useIsMounted";
import { Theme, useTheme } from "~/modules/theme";
export default function Chart({
options,
}: {
options: [
{ label: string; data: Array<{ primary: Date; secondary: number }> }
];
}) {
const theme = useTheme();
const isMounted = useIsMounted();
const primaryAxis = React.useMemo<
AxisOptions<(typeof options)[number]["data"][number]>
>(
() => ({
getValue: (datum) => datum.primary,
}),
[]
);
const secondaryAxes = React.useMemo<
AxisOptions<(typeof options)[number]["data"][number]>[]
>(
() => [
{
getValue: (datum) => datum.secondary,
},
],
[]
);
if (!isMounted) return <div className="chart__container" />;
return (
<div className="chart__container">
<ReactChart
options={{
data: options,
primaryAxis,
secondaryAxes,
dark: theme.htmlThemeClass === Theme.DARK,
defaultColors: ["var(--theme)"],
}}
/>
</div>
);
}

View File

@ -46,6 +46,7 @@ interface ComboboxProps<T> {
id?: string;
isLoading?: boolean;
required?: boolean;
value?: ComboboxOption<T> | null;
initialValue: ComboboxOption<T> | null;
onChange?: (selectedOption: ComboboxOption<T> | null) => void;
fullWidth?: boolean;
@ -57,6 +58,7 @@ export function Combobox<T extends Record<string, string | null | number>>({
options,
inputName,
placeholder,
value,
initialValue,
onChange,
required,
@ -69,7 +71,7 @@ export function Combobox<T extends Record<string, string | null | number>>({
}: ComboboxProps<T>) {
const { t } = useTranslation();
const [selectedOption, setSelectedOption] = React.useState<Unpacked<
const [_selectedOption, setSelectedOption] = React.useState<Unpacked<
typeof options
> | null>(initialValue);
const [query, setQuery] = React.useState("");
@ -94,6 +96,8 @@ export function Combobox<T extends Record<string, string | null | number>>({
return option?.label ?? "";
};
const selectedOption = value ?? _selectedOption;
return (
<div className="combobox-wrapper">
<HeadlessCombobox
@ -237,6 +241,7 @@ export function WeaponCombobox({
weaponIdsToOmit,
fullWidth,
nullable,
value,
}: Pick<
ComboboxProps<ComboboxBaseOption>,
| "inputName"
@ -249,6 +254,7 @@ export function WeaponCombobox({
> & {
initialWeaponId?: (typeof mainWeaponIds)[number];
weaponIdsToOmit?: Set<MainWeaponId>;
value?: MainWeaponId | null;
}) {
const { t } = useTranslation("weapons");
@ -264,6 +270,7 @@ export function WeaponCombobox({
options={mainWeaponIds
.filter((id) => !weaponIdsToOmit?.has(id))
.map(idToWeapon)}
value={typeof value === "number" ? idToWeapon(value) : null}
initialValue={
typeof initialWeaponId === "number" ? idToWeapon(initialWeaponId) : null
}

View File

@ -1,11 +1,14 @@
import type { TierName } from "~/features/mmr/mmr-constants";
import { useTranslation } from "~/hooks/useTranslation";
import type { MainWeaponId, ModeShort, StageId } from "~/modules/in-game-lists";
import {
TIER_PLUS_URL,
mainWeaponImageUrl,
modeImageUrl,
outlinedFiveStarMainWeaponImageUrl,
outlinedMainWeaponImageUrl,
stageImageUrl,
tierImageUrl,
} from "~/utils/urls";
interface ImageProps {
@ -61,12 +64,13 @@ export function Image({
type WeaponImageProps = {
weaponSplId: MainWeaponId;
variant: "badge" | "badge-5-star" | "build";
} & Omit<ImageProps, "path" | "alt" | "title">;
} & Omit<ImageProps, "path" | "alt">;
export function WeaponImage({
weaponSplId,
variant,
testId,
title,
...rest
}: WeaponImageProps) {
const { t } = useTranslation(["weapons"]);
@ -74,8 +78,8 @@ export function WeaponImage({
return (
<Image
{...rest}
alt={t(`weapons:MAIN_${weaponSplId}`)}
title={t(`weapons:MAIN_${weaponSplId}`)}
alt={title ?? t(`weapons:MAIN_${weaponSplId}`)}
title={title ?? t(`weapons:MAIN_${weaponSplId}`)}
testId={testId}
path={
variant === "badge"
@ -90,16 +94,16 @@ export function WeaponImage({
type ModeImageProps = {
mode: ModeShort;
} & Omit<ImageProps, "path" | "alt" | "title">;
} & Omit<ImageProps, "path" | "alt">;
export function ModeImage({ mode, testId, ...rest }: ModeImageProps) {
export function ModeImage({ mode, testId, title, ...rest }: ModeImageProps) {
const { t } = useTranslation(["game-misc"]);
return (
<Image
{...rest}
alt={t(`game-misc:MODE_LONG_${mode}`)}
title={t(`game-misc:MODE_LONG_${mode}`)}
alt={title ?? t(`game-misc:MODE_LONG_${mode}`)}
title={title ?? t(`game-misc:MODE_LONG_${mode}`)}
testId={testId}
path={modeImageUrl(mode)}
/>
@ -124,3 +128,35 @@ export function StageImage({ stageId, testId, ...rest }: StageImageProps) {
/>
);
}
type TierImageProps = {
tier: { name: TierName; isPlus: boolean };
} & Omit<ImageProps, "path" | "alt" | "title" | "size" | "height">;
export function TierImage({ tier, width = 200 }: TierImageProps) {
const title = `${tier.name}${tier.isPlus ? "+" : ""}`;
const height = width * 0.8675;
return (
<div className="tier__container">
<Image
path={tierImageUrl(tier.name)}
width={width}
height={height}
alt={title}
title={title}
containerClassName="tier__img"
/>
{tier.isPlus ? (
<Image
path={TIER_PLUS_URL}
width={width}
height={height}
alt=""
containerClassName="tier__img"
/>
) : null}
</div>
);
}

View File

@ -3,10 +3,22 @@ import type { LinkProps } from "@remix-run/react";
import clsx from "clsx";
import type * as React from "react";
export function SubNav({ children }: { children: React.ReactNode }) {
export function SubNav({
children,
secondary,
}: {
children: React.ReactNode;
secondary?: boolean;
}) {
return (
<div>
<nav className="sub-nav__container">{children}</nav>
<nav
className={clsx("sub-nav__container", {
"sub-nav__container__secondary": secondary,
})}
>
{children}
</nav>
</div>
);
}
@ -15,15 +27,40 @@ export function SubNavLink({
children,
className,
end = true,
secondary = false,
controlled = false,
active = false,
...props
}: LinkProps & {
end?: boolean;
children: React.ReactNode;
secondary?: boolean;
controlled?: boolean;
active?: boolean;
}) {
return (
<NavLink className="sub-nav__link__container" end={end} {...props}>
<div className={clsx("sub-nav__link", className)}>{children}</div>
<div className="sub-nav__border-guy" />
<NavLink
className={(state) =>
clsx("sub-nav__link__container", {
active: controlled ? active : state.isActive,
pending: state.isPending,
})
}
end={end}
{...props}
>
<div
className={clsx("sub-nav__link", className, {
"sub-nav__link__secondary": secondary,
})}
>
{children}
</div>
<div
className={clsx("sub-nav__border-guy", {
"sub-nav__border-guy__secondary": secondary,
})}
/>
</NavLink>
);
}

View File

@ -18,13 +18,27 @@ export function SubmitButton({
const isSubmitting = state ? state !== "idle" : navigation.state !== "idle";
const name = () => {
if (rest.name) return rest.name;
if (_action) return "_action";
return undefined;
};
const value = () => {
if (rest.value) return rest.value;
if (_action) return _action;
return undefined;
};
return (
<Button
{...rest}
disabled={rest.disabled || isSubmitting}
type="submit"
name={_action ? "_action" : undefined}
value={_action}
name={name()}
value={value()}
data-testid={testId ?? "submit-button"}
>
{children}

View File

@ -0,0 +1,18 @@
export function ArchiveBoxIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
);
}

View File

@ -0,0 +1,18 @@
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

@ -0,0 +1,18 @@
export function RefreshArrowsIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
}

View File

@ -0,0 +1,16 @@
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

@ -80,6 +80,15 @@
"invert(71%) sepia(42%) saturate(4219%) hue-rotate(150deg) brightness(99%) contrast(86%)"
]
},
{
"name": "sendouq",
"url": "q",
"prefetch": false,
"filters": [
"invert(65%) sepia(88%) saturate(376%) hue-rotate(80deg) brightness(97%) contrast(84%)",
"invert(63%) sepia(46%) saturate(1700%) hue-rotate(5deg) brightness(102%) contrast(103%)"
]
},
{
"name": "xsearch",
"url": "xsearch",

View File

@ -73,6 +73,7 @@ export const CUSTOMIZED_CSS_VARS_NAME = "css";
export const MAX_AP = 57;
export const HALF_HOUR_IN_MS = 30 * 60 * 1000;
export const ONE_HOUR_IN_MS = 60 * 60 * 1000;
export const SPLATOON_3_XP_BADGE_VALUES = [

View File

@ -12,6 +12,7 @@ import {
mainWeaponIds,
modesShort,
shoesGearIds,
stageIds,
} from "~/modules/in-game-lists";
import type {
MainWeaponId,
@ -29,7 +30,6 @@ import { dateToDatabaseTimestamp } from "~/utils/dates";
import type { UpsertManyPlusVotesArgs } from "../models/plusVotes/queries.server";
import { nanoid } from "nanoid";
import { mySlugify } from "~/utils/urls";
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { createVod } from "~/features/vods/queries/createVod.server";
import placements from "./placements.json";
@ -40,11 +40,29 @@ import {
NZAP_TEST_ID,
AMOUNT_OF_CALENDAR_EVENTS,
} from "./constants";
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import type { SeedVariation } from "~/routes/seed";
import { nullFilledArray, pickRandomItem } from "~/utils/arrays";
import type { Art, UserSubmittedImage } from "../types";
import { createGroup } from "~/features/sendouq/queries/createGroup.server";
import { MAP_LIST_PREFERENCE_OPTIONS } from "~/features/sendouq/q-constants";
import { addMember } from "~/features/sendouq/queries/addMember.server";
import { createMatch } from "~/features/sendouq/queries/createMatch.server";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
import { addSkills } from "~/features/sendouq/queries/addSkills.server";
import { reportScore } from "~/features/sendouq/queries/reportScore.server";
import { calculateMatchSkills } from "~/features/sendouq/core/skills.server";
import { winnersArrayToWinner } from "~/features/sendouq/q-utils";
import { addReportedWeapons } from "~/features/sendouq/queries/addReportedWeapons.server";
import { findMatchById } from "~/features/sendouq/queries/findMatchById.server";
import { setGroupAsInactive } from "~/features/sendouq/queries/setGroupAsInactive.server";
import { addMapResults } from "~/features/sendouq/queries/addMapResults.server";
import {
summarizeMaps,
summarizePlayerResults,
} from "~/features/sendouq/core/summarizer.server";
import { groupForMatch } from "~/features/sendouq/queries/groupForMatch.server";
import { addPlayerResults } from "~/features/sendouq/queries/addPlayerResults.server";
const calendarEventWithToToolsSz = () => calendarEventWithToTools(true);
const calendarEventWithToToolsTeamsSz = () =>
@ -89,6 +107,8 @@ const basicSeeds = (variation?: SeedVariation | null) => [
userFavBadges,
arts,
commissionsOpen,
playedMatches,
groups,
];
export function seed(variation?: SeedVariation | null) {
@ -102,6 +122,11 @@ export function seed(variation?: SeedVariation | null) {
function wipeDB() {
const tablesToDelete = [
"Skill",
"ReportedWeapon",
"GroupMatchMap",
"GroupMatch",
"Group",
"ArtUserMetadata",
"Art",
"UnvalidatedUserSubmittedImage",
@ -113,7 +138,6 @@ function wipeDB() {
"TournamentMatchGameResult",
"TournamentTeam",
"TournamentStage",
"Skill",
"TournamentResult",
"Tournament",
"CalendarEventDate",
@ -243,6 +267,35 @@ function userProfiles() {
country: Math.random() > 0.5 ? faker.location.countryCode() : null,
});
}
for (let id = 3; id < 500; id++) {
if (Math.random() < 0.15) continue; // 85% have weapons
const weapons = shuffle([...mainWeaponIds]);
for (let j = 0; j < faker.helpers.arrayElement([1, 2, 3, 4, 5]); j++) {
sql
.prepare(
/* sql */ `insert into "UserWeapon" (
"userId",
"weaponSplId",
"order",
"isFavorite"
) values (
@userId,
@weaponSplId,
@order,
@isFavorite
)`
)
.run({
userId: id,
weaponSplId: weapons.pop()!,
order: j + 1,
isFavorite: Math.random() > 0.8 ? 1 : 0,
});
}
}
}
function fakeUser(usedNames: Set<string>) {
@ -1170,16 +1223,23 @@ function otherTeams() {
/* sql */ `
insert into "AllTeam" ("id", "name", "customUrl", "inviteCode", "twitter", "bio")
values (
${i},
'${teamName}',
'${teamCustomUrl}',
'${nanoid(INVITE_CODE_LENGTH)}',
'${faker.internet.userName()}',
'${faker.lorem.paragraph()}'
@id,
@name,
@customUrl,
@inviteCode,
@twitter,
@bio
)
`
)
.run();
.run({
id: i,
name: teamName,
customUrl: teamCustomUrl,
inviteCode: nanoid(INVITE_CODE_LENGTH),
twitter: faker.internet.userName(),
bio: faker.lorem.paragraph(),
});
const numMembers = faker.helpers.arrayElement([
1, 2, 3, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 7, 7, 8,
@ -1498,3 +1558,256 @@ function commissionsOpen() {
}
}
}
const SENDOU_IN_FULL_GROUP = true;
function groups() {
const users = userIdsInAscendingOrderById()
.slice(0, 100)
.filter((id) => id !== ADMIN_ID && id !== NZAP_TEST_ID);
users.push(NZAP_TEST_ID);
for (let i = 0; i < 25; i++) {
const group = createGroup({
mapListPreference: faker.helpers.arrayElement(
MAP_LIST_PREFERENCE_OPTIONS
),
status: "ACTIVE",
userId: users.pop()!,
mapPool: new MapPool([
{ mode: "SZ", stageId: 1 },
{ mode: "SZ", stageId: 2 },
{ mode: "SZ", stageId: 3 },
{ mode: "SZ", stageId: 4 },
{ mode: "SZ", stageId: 5 },
{ mode: "SZ", stageId: 6 },
{ mode: "TC", stageId: 7 },
{ mode: "TC", stageId: 8 },
{ mode: "RM", stageId: 10 },
{ mode: "RM", stageId: 11 },
{ mode: "CB", stageId: 13 },
{ mode: "CB", stageId: 14 },
]),
});
const amountOfAdditionalMembers = () => {
if (SENDOU_IN_FULL_GROUP) {
if (i === 0) return 3;
if (i === 1) return 3;
}
return i === 0 ? 2 : i % 4;
};
for (let j = 0; j < amountOfAdditionalMembers(); j++) {
sql
.prepare(
/* sql */ `
insert into "GroupMember" ("groupId", "userId", "role")
values (@groupId, @userId, @role)
`
)
.run({
groupId: group.id,
userId: users.pop()!,
role: "REGULAR",
});
}
if (i === 0 && SENDOU_IN_FULL_GROUP) {
users.push(ADMIN_ID);
}
}
}
const randomMapList = (
groupAlpha: number,
groupBravo: number
): TournamentMapListMap[] => {
const szOnly = faker.helpers.arrayElement([true, false]);
const modePattern = shuffle([...rankedModesShort]);
const mapList: TournamentMapListMap[] = [];
const stageIdsShuffled = shuffle([...stageIds]);
for (let i = 0; i < 7; i++) {
const rankedMode = modePattern.pop()!;
mapList.push({
mode: szOnly ? "SZ" : rankedMode,
stageId: stageIdsShuffled.pop()!,
source: i === 6 ? "BOTH" : i % 2 === 0 ? groupAlpha : groupBravo,
});
modePattern.unshift(rankedMode);
}
return mapList;
};
const MATCHES_COUNT = 500;
function playedMatches() {
const _groupMembers = (() => {
return new Array(50).fill(null).map(() => {
const users = shuffle(userIdsInAscendingOrderById().slice(0, 50));
return new Array(4).fill(null).map(() => users.pop()!);
});
})();
const defaultWeapons = Object.fromEntries(
userIdsInAscendingOrderById()
.slice(0, 50)
.map((id) => {
const weapons = shuffle([...mainWeaponIds]);
return [id, weapons[0]];
})
);
// mid august 2021
let matchDate = new Date(Date.UTC(2021, 7, 15, 0, 0, 0, 0));
for (let i = 0; i < MATCHES_COUNT; i++) {
const groupMembers = shuffle([..._groupMembers]);
const groupAlphaMembers = groupMembers.pop()!;
invariant(groupAlphaMembers, "groupAlphaMembers not found");
const getGroupBravo = (): number[] => {
const result = groupMembers.pop()!;
invariant(result, "groupBravoMembers not found");
if (groupAlphaMembers.some((m) => result.includes(m))) {
return getGroupBravo();
}
return result;
};
const groupBravoMembers = getGroupBravo();
let groupAlpha = 0;
let groupBravo = 0;
// -> create groups
for (let i = 0; i < 2; i++) {
const users = i === 0 ? [...groupAlphaMembers] : [...groupBravoMembers];
const group = createGroup({
// these should not matter here
mapListPreference: "NO_PREFERENCE",
mapPool: new MapPool([]),
status: "ACTIVE",
userId: users.pop()!,
});
// -> add regular members of groups
for (let i = 0; i < 3; i++) {
addMember({
groupId: group.id,
userId: users.pop()!,
});
}
if (i === 0) {
groupAlpha = group.id;
} else {
groupBravo = group.id;
}
}
invariant(groupAlpha !== 0 && groupBravo !== 0, "groups not created");
const match = createMatch({
alphaGroupId: groupAlpha,
bravoGroupId: groupBravo,
mapList: randomMapList(groupAlpha, groupBravo),
});
// update match createdAt to the past
sql
.prepare(
/* sql */ `
update "GroupMatch"
set "createdAt" = @createdAt
where "id" = @id
`
)
.run({
createdAt: dateToDatabaseTimestamp(matchDate),
id: match.id,
});
if (Math.random() > 0.95) {
// increment date by 1 day
matchDate = new Date(matchDate.getTime() + 1000 * 60 * 60 * 24);
}
// -> report score
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const winners = faker.helpers.arrayElement([
["ALPHA", "ALPHA", "ALPHA", "ALPHA"],
["ALPHA", "ALPHA", "ALPHA", "BRAVO", "ALPHA"],
["BRAVO", "BRAVO", "BRAVO", "BRAVO"],
["ALPHA", "BRAVO", "BRAVO", "BRAVO", "BRAVO"],
["ALPHA", "ALPHA", "ALPHA", "BRAVO", "BRAVO", "BRAVO", "BRAVO"],
["BRAVO", "ALPHA", "BRAVO", "ALPHA", "BRAVO", "ALPHA", "BRAVO"],
["ALPHA", "BRAVO", "BRAVO", "ALPHA", "ALPHA", "ALPHA"],
["ALPHA", "BRAVO", "ALPHA", "BRAVO", "BRAVO", "BRAVO"],
]) as ("ALPHA" | "BRAVO")[];
const winner = winnersArrayToWinner(winners);
const finishedMatch = findMatchById(match.id)!;
const newSkills = calculateMatchSkills({
groupMatchId: match.id,
winner: winner === "ALPHA" ? groupAlphaMembers : groupBravoMembers,
loser: winner === "ALPHA" ? groupBravoMembers : groupAlphaMembers,
});
const members = [
...groupForMatch(match.alphaGroupId)!.members.map((m) => ({
...m,
groupId: match.alphaGroupId,
})),
...groupForMatch(match.bravoGroupId)!.members.map((m) => ({
...m,
groupId: match.bravoGroupId,
})),
];
sql.transaction(() => {
reportScore({
matchId: match.id,
reportedByUserId:
Math.random() > 0.5 ? groupAlphaMembers[0] : groupBravoMembers[0],
winners,
});
addSkills(newSkills);
setGroupAsInactive(groupAlpha);
setGroupAsInactive(groupBravo);
addMapResults(summarizeMaps({ match: finishedMatch, members, winners }));
addPlayerResults(
summarizePlayerResults({ match: finishedMatch, members, winners })
);
})();
// -> add weapons for 90% of matches
if (Math.random() > 0.9) continue;
const users = [...groupAlphaMembers, ...groupBravoMembers];
const mapsWithUsers = users.flatMap((u) =>
finishedMatch.mapList.map((m) => ({ map: m, user: u }))
);
addReportedWeapons(
mapsWithUsers.map((mu) => {
const weapon = () => {
if (Math.random() < 0.9) return defaultWeapons[mu.user];
if (Math.random() > 0.5)
return (
mainWeaponIds.find((id) => id > defaultWeapons[mu.user]) ?? 0
);
const shuffled = shuffle([...mainWeaponIds]);
return shuffled[0];
};
return {
groupMatchMapId: mu.map.id,
userId: mu.user,
weaponSplId: weapon(),
};
})
);
}
}

View File

@ -189,6 +189,7 @@ export interface MapPoolMap {
calendarEventId: number | null; // Part of tournament's map pool
tournamentTeamId: number | null; // Part of team's map pool
tieBreakerCalendarEventId: number | null; // Part of the tournament's tiebreaker pool
groupId: number | null; // Part of SendouQ group's map pool
stageId: StageId;
mode: ModeShort;
}
@ -352,6 +353,9 @@ export interface Skill {
matchesCount: number;
/** Tournament that caused the skill to change */
tournamentId: number | null;
/** Group match that caused the skill to change */
groupMatchId: number | null;
season: number;
}
export interface SkillTeamUser {
@ -365,6 +369,7 @@ export interface MapResult {
userId: number;
wins: number;
losses: number;
season: number;
}
export interface PlayerResult {
@ -375,6 +380,7 @@ export interface PlayerResult {
setWins: number;
setLosses: number;
type: "MATE" | "ENEMY";
season: number;
}
export interface TournamentResult {
@ -498,3 +504,57 @@ export interface ArtUserMetadata {
artId: number;
userId: number;
}
export interface Group {
id: number;
teamId: number | null;
createdAt: number;
latestActionAt: number;
mapListPreference:
| "SZ_ONLY"
| "ALL_MODES_ONLY"
| "PREFER_SZ"
| "PREFER_ALL_MODES"
| "NO_PREFERENCE";
inviteCode: string;
status: "PREPARING" | "ACTIVE" | "INACTIVE";
}
export interface GroupMember {
groupId: number;
userId: number;
role: "OWNER" | "MANAGER" | "REGULAR";
createdAt: number;
}
export interface GroupLike {
likerGroupId: number;
targetGroupId: number;
createdAt: number;
}
export interface GroupMatch {
id: number;
alphaGroupId: number;
bravoGroupId: number;
createdAt: number;
reportedAt: number | null;
reportedByUserId: number | null;
}
export interface GroupMatchMap {
id: number;
matchId: number;
index: number;
mode: ModeShort;
stageId: StageId;
/** serialized TournamentMaplistSource */
source: string;
winnerGroupId: number | null;
}
export interface ReportedWeapon {
groupMatchMapId: number | null;
weaponSplId: MainWeaponId;
userId: User["id"];
}

View File

@ -0,0 +1,72 @@
import { freshUserSkills } from "~/features/mmr/tiered.server";
import type { UserSPLeaderboardItem } from "../queries/userSPLeaderboard.server";
import type { SeasonPopularUsersWeapon } from "../queries/seasonPopularUsersWeapon.server";
import type { MainWeaponId } from "~/modules/in-game-lists";
import { weaponCategories } from "~/modules/in-game-lists";
import type { TeamSPLeaderboardItem } from "../queries/teamSPLeaderboard.server";
export function addTiers(entries: UserSPLeaderboardItem[]) {
const tiers = freshUserSkills();
const encounteredTiers = new Set<string>();
return entries.map((entry) => {
const tier = tiers.userSkills[entry.id].tier;
const tierKey = `${tier.name}${tier.isPlus ? "+" : ""}`;
const tierAlreadyEncountered = encounteredTiers.has(tierKey);
if (!tierAlreadyEncountered) {
encounteredTiers.add(tierKey);
}
return {
...entry,
tier: !tierAlreadyEncountered ? tier : undefined,
};
});
}
export function addWeapons(
entries: ReturnType<typeof addTiers>,
weapons: SeasonPopularUsersWeapon
) {
return entries.map((entry) => {
const weaponSplId = weapons[entry.id] as MainWeaponId | undefined;
return {
...entry,
weaponSplId,
};
});
}
export function filterByWeaponCategory<
T extends { weaponSplId?: MainWeaponId }
>(entries: Array<T>, category: (typeof weaponCategories)[number]["name"]) {
const weaponIdsOfCategory = new Set(
weaponCategories.find((c) => c.name === category)!.weaponIds
);
return entries.filter(
(entry) => entry.weaponSplId && weaponIdsOfCategory.has(entry.weaponSplId)
);
}
export function oneEntryPerUser(entries: TeamSPLeaderboardItem[]) {
const encounteredUserIds = new Set<number>();
return entries.filter((entry) => {
if (entry.members.some((m) => encounteredUserIds.has(m.id))) {
return false;
}
for (const member of entry.members) {
encounteredUserIds.add(member.id);
}
return true;
});
}
export function addPlacementRank<T>(entries: T[]) {
return entries.map((entry, index) => ({
...entry,
placementRank: index + 1,
}));
}

View File

@ -1,4 +1,4 @@
import { mainWeaponIds } from "~/modules/in-game-lists";
import { mainWeaponIds, weaponCategories } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
export const MATCHES_COUNT_NEEDED_FOR_LEADERBOARD = 7;
@ -7,6 +7,9 @@ export const LEADERBOARD_MAX_SIZE = 250;
export const LEADERBOARD_TYPES = [
"USER",
"TEAM",
...(weaponCategories.map(
(category) => `USER-${category.name}`
) as `USER-${(typeof weaponCategories)[number]["name"]}`[]),
"XP-ALL",
...(rankedModesShort.map(
(mode) => `XP-MODE-${mode}`

View File

@ -0,0 +1,45 @@
import { sql } from "~/db/sql";
import type { User } from "~/db/types";
import type { RankingSeason } from "~/features/mmr/season";
import { seasonObject } from "~/features/mmr/season";
import type { MainWeaponId } from "~/modules/in-game-lists";
import { dateToDatabaseTimestamp } from "~/utils/dates";
const stm = sql.prepare(/* sql */ `
with "q1" as (
select
"ReportedWeapon"."userId",
"ReportedWeapon"."weaponSplId",
count(*) as "count"
from "ReportedWeapon"
left join "GroupMatchMap" on "ReportedWeapon"."groupMatchMapId" = "GroupMatchMap"."id"
left join "GroupMatch" on "GroupMatchMap"."matchId" = "GroupMatch"."id"
where "GroupMatch"."createdAt" between @starts and @ends
group by "ReportedWeapon"."userId", "ReportedWeapon"."weaponSplId"
order by "count" desc
)
select
"q1"."userId",
"q1"."weaponSplId"
from "q1"
group by "q1"."userId"
`);
export type SeasonPopularUsersWeapon = Record<User["id"], MainWeaponId>;
export function seasonPopularUsersWeapon(
season: RankingSeason["nth"]
): SeasonPopularUsersWeapon {
const { starts, ends } = seasonObject(season);
const rows = stm.all({
season,
starts: dateToDatabaseTimestamp(starts),
ends: dateToDatabaseTimestamp(ends),
}) as Array<{
userId: User["id"];
weaponSplId: MainWeaponId;
}>;
return Object.fromEntries(rows.map((r) => [r.userId, r.weaponSplId]));
}

View File

@ -11,9 +11,6 @@ const stm = sql.prepare(/* sql */ `
select
"Skill"."id" as "entryId",
"Skill"."ordinal",
rank () over (
order by "Skill"."Ordinal" desc
) "placementRank",
json_group_array(
json_object(
'id',
@ -54,6 +51,7 @@ const stm = sql.prepare(/* sql */ `
) "Latest" on "Skill"."identifier" = "Latest"."identifier" and "Skill"."id" = "Latest"."maxId"
where
"Skill"."matchesCount" >= ${MATCHES_COUNT_NEEDED_FOR_LEADERBOARD}
and "Skill"."season" = @season
group by
"Skill"."identifier"
order by
@ -85,18 +83,20 @@ export interface TeamSPLeaderboardItem {
placementRank: number;
}
export function teamSPLeaderboard(): TeamSPLeaderboardItem[] {
return (stm.all() as any[]).map(({ ordinal, members, teams, ...rest }) => {
const parsedTeams = parseDBJsonArray(teams);
const sharesSameTeam =
parsedTeams.length === 4 &&
parsedTeams.every((team: any) => team.id === parsedTeams[0].id);
export function teamSPLeaderboard(season: number): TeamSPLeaderboardItem[] {
return (stm.all({ season }) as any[]).map(
({ ordinal, members, teams, ...rest }) => {
const parsedTeams = parseDBJsonArray(teams);
const sharesSameTeam =
parsedTeams.length === 4 &&
parsedTeams.every((team: any) => team.id === parsedTeams[0].id);
return {
...rest,
power: ordinalToSp(ordinal),
members: parseDBJsonArray(members),
team: sharesSameTeam ? parsedTeams[0] : undefined,
};
});
return {
...rest,
power: ordinalToSp(ordinal),
members: parseDBJsonArray(members),
team: sharesSameTeam ? parsedTeams[0] : undefined,
};
}
);
}

View File

@ -13,7 +13,6 @@ const stm = sql.prepare(/* sql */ `
"User"."id",
"User"."discordName",
"User"."discordAvatar",
"User"."discordDiscriminator",
"User"."discordId",
"User"."customUrl",
rank () over (
@ -30,6 +29,7 @@ const stm = sql.prepare(/* sql */ `
where
"Skill"."userId" is not null
and "Skill"."matchesCount" >= ${MATCHES_COUNT_NEEDED_FOR_LEADERBOARD}
and "Skill"."season" = @season
order by
"Skill"."ordinal" desc
limit
@ -42,14 +42,13 @@ export interface UserSPLeaderboardItem {
id: User["id"];
discordName: User["discordName"];
discordAvatar: User["discordAvatar"];
discordDiscriminator: User["discordDiscriminator"];
discordId: User["discordId"];
customUrl: User["customUrl"];
placementRank: number;
}
export function userSPLeaderboard(): UserSPLeaderboardItem[] {
return (stm.all() as any[]).map(({ ordinal, ...rest }) => ({
export function userSPLeaderboard(season: number): UserSPLeaderboardItem[] {
return (stm.all({ season }) as any[]).map(({ ordinal, ...rest }) => ({
...rest,
power: ordinalToSp(ordinal),
}));

View File

@ -7,20 +7,18 @@ import type {
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
import { Avatar } from "~/components/Avatar";
import { Main } from "~/components/Main";
import { discordFullName, makeTitle } from "~/utils/strings";
import { makeTitle } from "~/utils/strings";
import {
LEADERBOARDS_PAGE,
navIconUrl,
teamPage,
topSearchPlayerPage,
userPage,
userSeasonsPage,
userSubmittedImage,
} from "~/utils/urls";
import styles from "../../top-search/top-search.css";
import {
userSPLeaderboard,
type UserSPLeaderboardItem,
} from "../queries/userSPLeaderboard.server";
import { userSPLeaderboard } from "../queries/userSPLeaderboard.server";
import type { SendouRouteHandle } from "~/utils/remix";
import {
type TeamSPLeaderboardItem,
@ -36,13 +34,25 @@ import {
modeXPLeaderboard,
weaponXPLeaderboard,
} from "../queries/XPLeaderboard.server";
import { WeaponImage } from "~/components/Image";
import { TierImage, WeaponImage } from "~/components/Image";
import {
weaponCategories,
type MainWeaponId,
type RankedModeShort,
} from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { allSeasons } from "~/features/mmr/season";
import {
addPlacementRank,
addTiers,
addWeapons,
filterByWeaponCategory,
oneEntryPerUser,
} from "../core/leaderboards.server";
import { seasonPopularUsersWeapon } from "../queries/seasonPopularUsersWeapon.server";
import { cachified } from "cachified";
import { cache, ttl } from "~/utils/cache.server";
import { HALF_HOUR_IN_MS } from "~/constants";
export const handle: SendouRouteHandle = {
i18n: ["vods"],
@ -84,9 +94,51 @@ export const loader = async ({ request }: LoaderArgs) => {
LEADERBOARD_TYPES.find((type) => type === unvalidatedType) ??
LEADERBOARD_TYPES[0];
const userLeaderboard = type.includes("USER")
? await cachified({
// TODO: add season here
key: `user-leaderboard-season-${0}`,
cache,
ttl: ttl(HALF_HOUR_IN_MS),
// eslint-disable-next-line @typescript-eslint/require-await
async getFreshValue() {
const leaderboard = userSPLeaderboard(0);
const withTiers = addTiers(leaderboard);
return addWeapons(withTiers, seasonPopularUsersWeapon(0));
},
})
: null;
const teamLeaderboard =
type === "TEAM"
? await cachified({
// TODO: add season here
key: `team-leaderboard-season-${0}`,
cache,
ttl: ttl(HALF_HOUR_IN_MS),
// eslint-disable-next-line @typescript-eslint/require-await
async getFreshValue() {
const leaderboard = teamSPLeaderboard(0);
const filteredByUser = oneEntryPerUser(leaderboard);
return addPlacementRank(filteredByUser);
},
})
: null;
const filteredLeaderboard =
userLeaderboard && type !== "USER"
? filterByWeaponCategory(
userLeaderboard,
type.split("-")[1] as (typeof weaponCategories)[number]["name"]
)
: userLeaderboard;
// TODO: season selection logic
return {
userLeaderboard: type === "USER" ? userSPLeaderboard() : null,
teamLeaderboard: type === "TEAM" ? teamSPLeaderboard() : null,
userLeaderboard: filteredLeaderboard ?? userLeaderboard,
teamLeaderboard,
xpLeaderboard:
type === "XP-ALL"
? allXPLeaderboard()
@ -113,17 +165,29 @@ export default function LeaderboardsPage() {
setSearchParams({ [TYPE_SEARCH_PARAM_KEY]: e.target.value })
}
>
<optgroup label="SP">
{LEADERBOARD_TYPES.filter((type) => !type.includes("XP")).map(
(type) => {
return (
<option key={type} value={type}>
{t(`common:leaderboard.type.${type as "USER" | "TEAM"}`)}
</option>
);
}
)}
</optgroup>
{allSeasons(new Date()).map((season) => {
return (
<optgroup label={`SP - Season ${season}`} key={season}>
{LEADERBOARD_TYPES.filter((type) => !type.includes("XP")).map(
(type) => {
const userOrTeam = type.includes("USER") ? "USER" : "TEAM";
const category = weaponCategories.find((c) =>
type.includes(c.name)
)?.name;
return (
<option key={type} value={type}>
{t(`common:leaderboard.type.${userOrTeam}`)}
{category
? ` (${t(`common:weapon.category.${category}`)})`
: ""}
</option>
);
}
)}
</optgroup>
);
})}
<optgroup label="XP">
<option value="XP-ALL">{t(`common:leaderboard.type.XP-ALL`)}</option>
{rankedModesShort.map((mode) => {
@ -152,37 +216,75 @@ export default function LeaderboardsPage() {
})}
</select>
{data.userLeaderboard ? (
<PlayersTable entries={data.userLeaderboard} />
<PlayersTable
entries={data.userLeaderboard}
showTiers={
!searchParams.get(TYPE_SEARCH_PARAM_KEY) ||
searchParams.get(TYPE_SEARCH_PARAM_KEY) === "USER"
}
/>
) : null}
{data.teamLeaderboard ? (
<TeamTable entries={data.teamLeaderboard} />
) : null}
{data.xpLeaderboard ? <XPTable entries={data.xpLeaderboard} /> : null}
{/* TODO: only when viewing current season */}
{!data.xpLeaderboard ? (
<div className="text-xs text-lighter">
Leaderboard is updated once every 30 minutes.
</div>
) : null}
</Main>
);
}
function PlayersTable({ entries }: { entries: UserSPLeaderboardItem[] }) {
function PlayersTable({
entries,
showTiers,
}: {
entries: NonNullable<SerializeFrom<typeof loader>["userLeaderboard"]>;
showTiers?: boolean;
}) {
return (
<div className="placements__table">
{entries.map((entry) => {
return (
<Link
to={userPage(entry)}
key={entry.entryId}
className="placements__table__row"
>
<div className="placements__table__inner-row">
<div className="placements__table__rank">
{entry.placementRank}
<React.Fragment key={entry.entryId}>
{entry.tier && showTiers ? (
<div className="placements__tier-header">
<TierImage tier={entry.tier} width={32} />
{entry.tier.name}
{entry.tier.isPlus ? "+" : ""}
</div>
<div>
<Avatar size="xxs" user={entry} />
) : null}
{/* TODO: dynamic season */}
<Link
to={userSeasonsPage({ user: entry, season: 0 })}
className="placements__table__row"
>
<div className="placements__table__inner-row">
<div className="placements__table__rank">
{entry.placementRank}
</div>
<div>
<Avatar size="xxs" user={entry} />
</div>
{entry.weaponSplId ? (
<WeaponImage
className="placements__table__weapon"
variant="build"
weaponSplId={entry.weaponSplId}
width={32}
height={32}
/>
) : null}
<div className="placements__table__name">
{entry.discordName}
</div>
<div className="placements__table__power">{entry.power}</div>
</div>
<div>{discordFullName(entry)}</div>
<div className="placements__table__power">{entry.power}</div>
</div>
</Link>
</Link>
</React.Fragment>
);
})}
</div>

View File

@ -4,3 +4,5 @@ export {
} from "./mmr-utils.server";
export { rate, userIdsToIdentifier, ordinalToSp } from "./mmr-utils";
export { currentSeason } from "./season";

View File

@ -0,0 +1,80 @@
export const TIERS = [
{
name: "LEVIATHAN",
percentile: 5,
},
{
name: "DIAMOND",
percentile: 10,
},
{
name: "PLATINUM",
percentile: 15,
},
{
name: "GOLD",
percentile: 17.5,
},
{
name: "SILVER",
percentile: 20,
},
{
name: "BRONZE",
percentile: 17.5,
},
{
name: "IRON",
percentile: 15,
},
] as const;
export const TIERS_BEFORE_LEVIATHAN = [
{
name: "DIAMOND",
percentile: 15,
},
{
name: "PLATINUM",
percentile: 15,
},
{
name: "GOLD",
percentile: 17.5,
},
{
name: "SILVER",
percentile: 20,
},
{
name: "BRONZE",
percentile: 17.5,
},
{
name: "IRON",
percentile: 15,
},
] as const;
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

@ -2,8 +2,14 @@ import { rating } from "openskill";
import { findCurrentSkillByUserId } from "./queries/findCurrentSkillByUserId.server";
import { findCurrentTeamSkillByIdentifier } from "./queries/findCurrentTeamSkillByIdentifier.server";
export function queryCurrentUserRating(userId: number) {
const skill = findCurrentSkillByUserId(userId);
export function queryCurrentUserRating({
userId,
season,
}: {
userId: number;
season: number;
}) {
const skill = findCurrentSkillByUserId({ userId, season: season ?? null });
if (!skill) {
return rating();
@ -12,12 +18,46 @@ export function queryCurrentUserRating(userId: number) {
return rating(skill);
}
export function queryCurrentTeamRating(identifier: string) {
const skill = findCurrentTeamSkillByIdentifier(identifier);
export function queryCurrentTeamRating({
identifier,
season,
}: {
identifier: string;
season: number;
}) {
const skill = findCurrentTeamSkillByIdentifier({
identifier,
season,
});
if (!skill) {
return rating();
}
if (!skill) return rating();
return rating(skill);
}
export function queryTeamPlayerRatingAverage({
identifier,
season,
}: {
identifier: string;
season: number;
}) {
const playerRatings = identifierToUserIds(identifier).map((userId) =>
queryCurrentUserRating({ userId, season })
);
if (playerRatings.length === 0) return rating();
return {
mu:
playerRatings.reduce((acc, cur) => acc + cur.mu, 0) /
playerRatings.length,
sigma:
playerRatings.reduce((acc, cur) => acc + cur.sigma, 0) /
playerRatings.length,
};
}
export function identifierToUserIds(identifier: string) {
return identifier.split("-").map(Number);
}

View File

@ -1,21 +1,73 @@
import { rate as openskillRate } from "openskill";
import type { Team } from "openskill/dist/types";
import { rate as openskillRate, ordinal } from "openskill";
import type { Rating, Team } from "openskill/dist/types";
import invariant from "tiny-invariant";
const TAU = 0.3;
export function ordinalToSp(ordinal: number) {
return toTwoDecimals(ordinal * 10 + 1000);
return toTwoDecimals(ordinal * 15 + 1000);
}
export function ordinalToRoundedSp(ordinal: number) {
return Math.floor(ordinalToSp(ordinal));
}
function toTwoDecimals(value: number) {
return Number(value.toFixed(2));
}
export function rate(teams: Team[]) {
export function rate(teams: Team[], secondaryTeams?: [[Rating], [Rating]]) {
if (secondaryTeams) return rateConservative(teams, secondaryTeams);
return openskillRate(teams, { tau: TAU, preventSigmaIncrease: true });
}
// when ranking teams we rate the team against the actual team rating that it played against
// as well as against the average ratings of the players on the team
// then they get the bigger boost of the two (if won) or the smaller penalty of the two (if lost)
// this is to avoid situations where teams might unexpectedly lose a huge amount of points
// due to other team score not being accurate (not enough games played) to their perceived skill level
function rateConservative(
teams: Team[],
secondaryTeams: [[Rating], [Rating]]
): [[Rating], [Rating]] {
const [[ordinaryRatingForWinner], [ordinaryRatingForLoser]] = openskillRate(
teams,
{
tau: TAU,
preventSigmaIncrease: true,
}
);
const [, [conservativeRatingForLoser]] = openskillRate(
[secondaryTeams[0], teams[1]],
{
tau: TAU,
preventSigmaIncrease: true,
}
);
const [[conservativeRatingForWinner]] = openskillRate(
[teams[0], secondaryTeams[1]],
{
tau: TAU,
preventSigmaIncrease: true,
}
);
const winnerRating =
ordinal(ordinaryRatingForWinner) > ordinal(conservativeRatingForWinner)
? ordinaryRatingForWinner
: conservativeRatingForWinner;
const loserRating =
ordinal(ordinaryRatingForLoser) > ordinal(conservativeRatingForLoser)
? ordinaryRatingForLoser
: conservativeRatingForLoser;
return [[winnerRating], [loserRating]];
}
export function userIdsToIdentifier(userIds: number[]) {
invariant(userIds.length === 4, "userIds for identifier must be length 4");
return [...userIds].sort((a, b) => a - b).join("-");

View File

@ -8,15 +8,21 @@ const stm = sql.prepare(/* sql */ `
from
"Skill"
where
"userId" = @userId
and "id" = (
"id" = (
select max("id")
from "Skill"
where "userId" = @userId
and "season" = @season
group by "userId"
)
`);
export function findCurrentSkillByUserId(userId: number) {
return stm.get({ userId }) as Pick<Skill, "mu" | "sigma"> | null;
export function findCurrentSkillByUserId({
userId,
season,
}: {
userId: number;
season: number;
}) {
return stm.get({ userId, season }) as Pick<Skill, "mu" | "sigma"> | null;
}

View File

@ -8,15 +8,21 @@ const stm = sql.prepare(/* sql */ `
from
"Skill"
where
"identifier" = @identifier
and "id" = (
"id" = (
select max("id")
from "Skill"
where "identifier" = @identifier
and "season" = @season
group by "identifier"
)
`);
export function findCurrentTeamSkillByIdentifier(identifier: string) {
return stm.get({ identifier }) as Pick<Skill, "mu" | "sigma"> | null;
export function findCurrentTeamSkillByIdentifier({
identifier,
season,
}: {
identifier: string;
season: number;
}) {
return stm.get({ identifier, season }) as Pick<Skill, "mu" | "sigma"> | null;
}

View File

@ -0,0 +1,56 @@
import { sql } from "~/db/sql";
import type { Skill } from "~/db/types";
const userStm = sql.prepare(/* sql */ `
select
"Skill"."ordinal",
"Skill"."matchesCount",
"Skill"."userId"
from
"Skill"
inner join (
select "userId", max("id") as "maxId"
from "Skill"
where "Skill"."season" = @season
group by "userId"
) "Latest" on "Skill"."userId" = "Latest"."userId" and "Skill"."id" = "Latest"."maxId"
where
"Skill"."season" = @season
and "Skill"."userId" is not null
order by
"Skill"."ordinal" desc
`);
const teamStm = sql.prepare(/* sql */ `
select
"Skill"."ordinal",
"Skill"."matchesCount",
"Skill"."identifier"
from
"Skill"
inner join (
select "identifier", max("id") as "maxId"
from "Skill"
where "Skill"."season" = @season
group by "identifier"
) "Latest" on "Skill"."identifier" = "Latest"."identifier" and "Skill"."id" = "Latest"."maxId"
where
"Skill"."season" = @season
and "Skill"."identifier" is not null
order by
"Skill"."ordinal" desc
`);
export function orderedMMRBySeason({
season,
type,
}: {
season: number;
type: "team" | "user";
}) {
const stm = type === "team" ? teamStm : userStm;
return stm.all({ season }) as Array<
Pick<Skill, "ordinal" | "matchesCount" | "userId" | "identifier">
>;
}

View File

@ -0,0 +1,38 @@
import { sql } from "~/db/sql";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
const stm = sql.prepare(/* sql */ `
select
max("Skill"."ordinal") as "ordinal",
date(
coalesce("GroupMatch"."createdAt", "CalendarEventDate"."startTime"), 'unixepoch'
) as "date"
from
"Skill"
left join "GroupMatch" on "GroupMatch"."id" = "Skill"."groupMatchId"
left join "Tournament" on "Tournament"."id" = "Skill"."tournamentId"
-- TODO: support tournament having many start dates
left join "CalendarEvent" on "Tournament"."id" = "CalendarEvent"."tournamentId"
left join "CalendarEventDate" on "CalendarEvent"."id" = "CalendarEventDate"."eventId"
where
"Skill"."userId" = @userId
and "Skill"."season" = @season
and "Skill"."matchesCount" >= ${MATCHES_COUNT_NEEDED_FOR_LEADERBOARD}
and ("GroupMatch"."id" is not null or "Tournament"."id" is not null)
group by "date"
order by "date" asc
`);
export function seasonAllMMRByUserId({
userId,
season,
}: {
userId: number;
season: number;
}) {
return stm.all({ userId, season }) as Array<{
ordinal: number;
date: string;
isMostRecent: number;
}>;
}

View File

@ -0,0 +1,59 @@
const SEASONS =
process.env.NODE_ENV === "development"
? ([
{
nth: 0,
starts: new Date("2020-08-14T15:00:00.000Z"),
ends: new Date("2029-08-27T20:59:59.999Z"),
},
] as const)
: ([
{
nth: 0,
starts: new Date("2023-08-14T17:00:00.000Z"),
ends: new Date("2023-08-27T20:59:59.999Z"),
},
] as const);
export type RankingSeason = (typeof SEASONS)[number];
export function previousOrCurrentSeason(date: Date) {
const _currentSeason = currentSeason(date);
if (_currentSeason) return _currentSeason;
let latestPreviousSeason;
for (const season of SEASONS) {
if (date >= season.ends) latestPreviousSeason = season;
}
return latestPreviousSeason;
}
export function currentSeason(date: Date) {
for (const season of SEASONS) {
if (date >= season.starts && date <= season.ends) return season;
}
return null;
}
export function nextSeason(date: Date) {
for (const season of SEASONS) {
if (date < season.starts) return season;
}
return null;
}
export function seasonObject(nth: number) {
return SEASONS[nth];
}
export function allSeasons(date: Date) {
const startedSeasons = SEASONS.filter((s) => date >= s.starts);
if (startedSeasons.length > 0) {
return startedSeasons.map((s) => s.nth).reverse();
}
return [0];
}

View File

@ -0,0 +1,147 @@
import {
TIERS,
USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN,
type TierName,
TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN,
TIERS_BEFORE_LEVIATHAN,
} from "./mmr-constants";
import type { Skill } from "~/db/types";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "../leaderboards/leaderboards-constants";
import { orderedMMRBySeason } from "./queries/orderedMMRBySeason.server";
import { currentSeason } from "./season";
import { cachified } from "cachified";
import { cache, ttl } from "~/utils/cache.server";
import { HALF_HOUR_IN_MS, ONE_HOUR_IN_MS } from "~/constants";
import { USER_SKILLS_CACHE_KEY } from "../sendouq/q-constants";
export interface TieredSkill {
ordinal: number;
tier: {
name: TierName;
isPlus: boolean;
};
approximate: boolean;
}
export function freshUserSkills(): {
userSkills: Record<string, TieredSkill>;
intervals: SkillTierInterval[];
} {
const points = orderedMMRBySeason({
season: currentSeason(new Date())!.nth,
type: "user",
});
const tierIntervals = skillTierIntervals(points, "user");
return {
intervals: tierIntervals,
userSkills: Object.fromEntries(
points.map((p) => {
const { name, isPlus } = tierIntervals.find(
(t) => t.neededOrdinal! <= p.ordinal
) ?? { name: "IRON", isPlus: false };
return [
p.userId as number,
{
ordinal: p.ordinal,
tier: { name, isPlus },
approximate: p.matchesCount < MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
},
];
})
),
};
}
export async function userSkills() {
const cachedSkills = await cachified({
key: USER_SKILLS_CACHE_KEY,
cache,
ttl: ttl(HALF_HOUR_IN_MS),
staleWhileRevalidate: ttl(ONE_HOUR_IN_MS),
getFreshValue() {
return freshUserSkills();
},
});
// TODO: this can be removed after Season 0 has been kicked off
if (Object.keys(cachedSkills.userSkills).length < 10) {
return freshUserSkills();
}
return cachedSkills;
}
export type SkillTierInterval = ReturnType<typeof skillTierIntervals>[number];
function skillTierIntervals(
orderedPoints: Array<Pick<Skill, "ordinal" | "matchesCount">>,
type: "user" | "team"
) {
const LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN =
type === "user"
? USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN
: TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN;
let points = orderedPoints.filter(
(p) => p.matchesCount >= MATCHES_COUNT_NEEDED_FOR_LEADERBOARD
);
const hasLeviathan = points.length >= LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN;
if (!hasLeviathan) {
// using all entries, no matter if they have enough to be on the leaderboard
// to create the tiers
points = orderedPoints;
}
const totalPlayers = points.length;
const tiersToUse = hasLeviathan ? TIERS : TIERS_BEFORE_LEVIATHAN;
const allTiers = tiersToUse.flatMap((tier) =>
[true, false].map((isPlus) => ({
...tier,
isPlus,
percentile: tier.percentile / 2,
}))
);
const result: Array<{
name: TierName;
isPlus: boolean;
/** inclusive */
neededOrdinal?: number;
}> = [
{
name: tiersToUse[0].name,
isPlus: true,
},
];
if (points.length === 1) {
result[0].neededOrdinal = points[0].ordinal;
return result;
}
let previousPercentiles = 0;
for (let i = 0; i < points.length; i++) {
const currentTier = allTiers[result.length - 1];
const currentPercentile = ((i + 1) / totalPlayers) * 100;
// "isPlus" is top 50% of that tier
const accPercentile = previousPercentiles + currentTier.percentile;
if (currentPercentile > accPercentile) {
const previousPoints = points[i - 1];
const thisTier = result[result.length - 1];
thisTier.neededOrdinal = previousPoints.ordinal;
const newTier = allTiers[result.length];
result.push({
name: newTier.name,
isPlus: newTier.isPlus,
});
previousPercentiles = accPercentile;
}
}
return result;
}

View File

@ -0,0 +1,195 @@
import { Link, useFetcher } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { Flipped } from "react-flip-toolkit";
import { Avatar } from "~/components/Avatar";
import { Button } from "~/components/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { TierImage, WeaponImage } from "~/components/Image";
import { SubmitButton } from "~/components/SubmitButton";
import { ArrowsPointingInIcon } from "~/components/icons/ArrowsPointingIn";
import { StarFilledIcon } from "~/components/icons/StarFilled";
import UndoIcon from "~/components/icons/Undo";
import { UsersIcon } from "~/components/icons/Users";
import type { Group, GroupMember as GroupMemberType } from "~/db/types";
import { SENDOUQ_LOOKING_PAGE, userPage } from "~/utils/urls";
import { FULL_GROUP_SIZE } from "../q-constants";
import type { LookingGroup } from "../q-types";
import { ModePreferenceIcons } from "./ModePrefenceIcons";
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
export function GroupCard({
group,
action,
mapListPreference,
ownRole,
ownGroup = false,
}: {
group: LookingGroup;
action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP";
mapListPreference?: Group["mapListPreference"];
ownRole?: GroupMemberType["role"];
ownGroup?: boolean;
}) {
const fetcher = useFetcher();
return (
<Flipped flipId={group.id}>
<section className="q__group">
{mapListPreference ? (
<div className="stack lg horizontal justify-center">
<div className="stack xs horizontal items-center">
<ModePreferenceIcons preference={mapListPreference} />
</div>
</div>
) : null}
<div
className={clsx("stack sm", {
"horizontal justify-center": !group.members,
})}
>
{group.members?.map((member) => {
return (
<React.Fragment key={member.discordId}>
<GroupMember
member={member}
showActions={ownGroup && ownRole === "OWNER"}
/>
<div className="stack md horizontal items-center justify-between">
<div className="q__group-member-weapons">
{member.weapons?.map((weapon) => {
return (
<WeaponImage
key={weapon}
weaponSplId={weapon}
variant="badge"
size={36}
className="q__group-member-weapon"
/>
);
})}
</div>
{member.skill ? (
<div className="text-xs font-bold text-lighter stack horizontal xxs items-center">
<TierImage tier={member.skill.tier} width={36} />
{!member.skill.approximate ? (
<>{ordinalToRoundedSp(member.skill.ordinal)}SP</>
) : null}
</div>
) : null}
</div>
</React.Fragment>
);
})}
{!group.members
? new Array(FULL_GROUP_SIZE).fill(null).map((_, i) => {
return (
<div key={i} className="q__member-placeholder">
?
</div>
);
})
: null}
</div>
{group.tier ? (
<div className="stack xs text-lighter font-bold items-center justify-center text-xs">
<TierImage tier={group.tier} width={100} />
{group.tier.name}
{group.tier.isPlus ? "+" : ""}
</div>
) : null}
{action && (ownRole === "OWNER" || ownRole === "MANAGER") ? (
<fetcher.Form className="stack items-center" method="post">
<input type="hidden" name="targetGroupId" value={group.id} />
<SubmitButton
size="tiny"
variant={action === "UNLIKE" ? "destructive" : "outlined"}
_action={action}
state={fetcher.state}
icon={
action === "MATCH_UP" ? (
<ArrowsPointingInIcon />
) : action === "LIKE" ? (
<StarFilledIcon />
) : action === "GROUP_UP" ? (
<UsersIcon />
) : (
<UndoIcon />
)
}
>
{action === "MATCH_UP"
? "Start match"
: action === "LIKE" && !group.members
? "Challenge"
: action === "LIKE"
? "Ask to play"
: action === "GROUP_UP"
? "Group up"
: "Undo"}
</SubmitButton>
</fetcher.Form>
) : null}
{ownGroup ? (
<FormWithConfirm
dialogHeading="Leave this group?"
fields={[["_action", "LEAVE_GROUP"]]}
deleteButtonText="Leave"
action={SENDOUQ_LOOKING_PAGE}
>
<Button variant="minimal-destructive" size="tiny">
Leave group
</Button>
</FormWithConfirm>
) : null}
</section>
</Flipped>
);
}
function GroupMember({
member,
showActions,
}: {
member: NonNullable<LookingGroup["members"]>[number];
showActions: boolean;
}) {
const fetcher = useFetcher();
return (
<fetcher.Form
className="stack sm horizontal items-center font-bold"
method="post"
action={SENDOUQ_LOOKING_PAGE}
>
<input type="hidden" name="userId" value={member.id} />
<Link to={userPage(member)} className="q__group-member" target="_blank">
<Avatar user={member} size="xxs" />
{member.discordName}
</Link>
{member.plusTier ? (
<div className="text-xs text-lighter">+{member.plusTier}</div>
) : null}
{member.role === "REGULAR" && showActions ? (
<SubmitButton
variant="minimal"
size="tiny"
_action="GIVE_MANAGER"
state={fetcher.state}
>
Give manager
</SubmitButton>
) : null}
{member.role === "MANAGER" && showActions ? (
<SubmitButton
variant="minimal-destructive"
size="tiny"
_action="REMOVE_MANAGER"
state={fetcher.state}
>
Remove manager
</SubmitButton>
) : null}
</fetcher.Form>
);
}

View File

@ -0,0 +1,79 @@
import { useFetcher } from "@remix-run/react";
import { useCopyToClipboard } from "react-use";
import { Button } from "~/components/Button";
import { SubmitButton } from "~/components/SubmitButton";
import {
SENDOUQ_PREPARING_PAGE,
SENDOU_INK_BASE_URL,
sendouQInviteLink,
} from "~/utils/urls";
import * as React from "react";
export function MemberAdder({
inviteCode,
trustedPlayers,
}: {
inviteCode: string;
trustedPlayers: Array<{
id: number;
discordName: string;
}>;
}) {
const [trustedUser, setTrustedUser] = React.useState<number>();
const fetcher = useFetcher();
const inviteLink = `${SENDOU_INK_BASE_URL}${sendouQInviteLink(inviteCode)}`;
const [, copyToClipboard] = useCopyToClipboard();
React.useEffect(() => {
setTrustedUser(undefined);
}, [trustedPlayers]);
return (
<div className="stack horizontal lg flex-wrap justify-center">
{trustedPlayers.length > 0 ? (
<fetcher.Form method="post" action={SENDOUQ_PREPARING_PAGE}>
<label htmlFor="players">Add people you have played with</label>
<div className="stack horizontal sm items-center">
<select
name="id"
id="players"
onChange={(e) =>
setTrustedUser(
e.target.value ? Number(e.target.value) : undefined
)
}
>
<option value="">Select member</option>
{trustedPlayers.map((player) => {
return (
<option key={player.id} value={player.id}>
{player.discordName}
</option>
);
})}
</select>
<SubmitButton
variant="outlined"
_action="ADD_TRUSTED"
disabled={!trustedUser}
>
Add
</SubmitButton>
</div>
</fetcher.Form>
) : null}
<div>
<label htmlFor="invite">Share your invite link</label>
<div className="stack horizontal sm items-center">
<input type="text" value={inviteLink} readOnly id="invite" />
<Button
variant="outlined"
onClick={() => copyToClipboard(inviteLink)}
>
Copy
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
import { ModeImage } from "~/components/Image";
import type { Group } from "~/db/types";
import { assertUnreachable } from "~/utils/types";
export function ModePreferenceIcons({
preference,
}: {
preference: Group["mapListPreference"];
}) {
const comparisonSign = (() => {
switch (preference) {
case "SZ_ONLY":
case "ALL_MODES_ONLY":
return null;
case "NO_PREFERENCE":
return "=";
case "PREFER_ALL_MODES":
return "<";
case "PREFER_SZ":
return ">";
default:
assertUnreachable(preference);
}
})();
return (
<>
{preference !== "ALL_MODES_ONLY" ? (
<ModeImage mode="SZ" size={16} />
) : null}
{comparisonSign ? (
<span className="text-main-forced">{comparisonSign}</span>
) : null}
{preference !== "SZ_ONLY" ? (
<>
<ModeImage mode="SZ" size={16} />
<ModeImage mode="TC" size={16} />
<ModeImage mode="RM" size={16} />
<ModeImage mode="CB" size={16} />
</>
) : null}
</>
);
}

View File

@ -0,0 +1,202 @@
import invariant from "tiny-invariant";
import type { Group, GroupLike } from "~/db/types";
import { databaseTimestampToDate } from "~/utils/dates";
import { FULL_GROUP_SIZE } from "../q-constants";
import type {
DividedGroups,
DividedGroupsUncensored,
LookingGroup,
LookingGroupWithInviteCode,
} from "../q-types";
import type {
SkillTierInterval,
TieredSkill,
} from "~/features/mmr/tiered.server";
export function divideGroups({
groups,
ownGroupId,
likes,
}: {
groups: LookingGroupWithInviteCode[];
ownGroupId: number;
likes: Pick<GroupLike, "likerGroupId" | "targetGroupId">[];
}): DividedGroupsUncensored {
let own: LookingGroupWithInviteCode | null = null;
let neutral: LookingGroupWithInviteCode[] = [];
const likesReceived: LookingGroupWithInviteCode[] = [];
const likesGiven: LookingGroupWithInviteCode[] = [];
const unneutralGroupIds = new Set<number>();
for (const like of likes) {
for (const group of groups) {
if (group.id === ownGroupId) continue;
// handles edge case where they liked each other
// right after each other so the group didn't morph
// so instead it will look so that the group liked us
// and there is the option to morph
if (unneutralGroupIds.has(group.id)) continue;
if (like.likerGroupId === group.id) {
likesReceived.push(group);
unneutralGroupIds.add(group.id);
break;
}
if (like.targetGroupId === group.id) {
likesGiven.push(group);
unneutralGroupIds.add(group.id);
break;
}
}
}
for (const group of groups) {
if (group.id === ownGroupId) {
own = group;
continue;
}
if (unneutralGroupIds.has(group.id)) continue;
neutral.push(group);
}
invariant(own && own.members, "own group not found");
return {
own,
neutral,
likesGiven,
likesReceived,
};
}
export function filterOutGroupsWithIncompatibleMapListPreference(
groups: DividedGroupsUncensored
): DividedGroupsUncensored {
if (
groups.own.mapListPreference !== "SZ_ONLY" &&
groups.own.mapListPreference !== "ALL_MODES_ONLY"
) {
return groups;
}
return {
...groups,
neutral: groups.neutral.filter((group) => {
if (
group.mapListPreference !== "SZ_ONLY" &&
group.mapListPreference !== "ALL_MODES_ONLY"
) {
return true;
}
return group.mapListPreference === groups.own.mapListPreference;
}),
};
}
const censorGroupFully = ({
inviteCode: _inviteCode,
...group
}: LookingGroupWithInviteCode): LookingGroup => ({
...group,
members: undefined,
mapListPreference: undefined,
});
const censorGroupPartly = ({
inviteCode: _inviteCode,
...group
}: LookingGroupWithInviteCode): LookingGroup => group;
export function censorGroups({
groups,
showMembers,
showInviteCode,
}: {
groups: DividedGroupsUncensored;
showMembers: boolean;
showInviteCode: boolean;
}): DividedGroups {
return {
own: showInviteCode ? groups.own : censorGroupPartly(groups.own),
neutral: groups.neutral.map(
showMembers ? censorGroupPartly : censorGroupFully
),
likesGiven: groups.likesGiven.map(
showMembers ? censorGroupPartly : censorGroupFully
),
likesReceived: groups.likesReceived.map(
showMembers ? censorGroupPartly : censorGroupFully
),
};
}
export function addSkillsToGroups({
groups,
userSkills,
intervals,
}: {
groups: DividedGroupsUncensored;
userSkills: Record<string, TieredSkill>;
intervals: SkillTierInterval[];
}): DividedGroupsUncensored {
const resolveGroupSkill = (
group: LookingGroupWithInviteCode
): TieredSkill["tier"] | undefined => {
if (group.members.length < FULL_GROUP_SIZE) return;
const skills = group.members
.map((m) => userSkills[String(m.id)])
.filter(Boolean);
const averageOrdinal =
skills.reduce((acc, s) => acc + s.ordinal, 0) / skills.length;
return (
intervals.find(
(i) => i.neededOrdinal && averageOrdinal > i.neededOrdinal
) ?? { isPlus: false, name: "IRON" }
);
};
const addSkill = (group: LookingGroupWithInviteCode) => ({
...group,
members: group.members?.map((m) => ({
...m,
skill: userSkills[String(m.id)],
})),
tier: resolveGroupSkill(group),
});
return {
own: addSkill(groups.own),
neutral: groups.neutral.map(addSkill),
likesGiven: groups.likesGiven.map(addSkill),
likesReceived: groups.likesReceived.map(addSkill),
};
}
export function membersNeededForFull(currentSize: number) {
return FULL_GROUP_SIZE - currentSize;
}
export function groupExpiryStatus(
group: Pick<Group, "latestActionAt">
): null | "EXPIRING_SOON" | "EXPIRED" {
// group expires in 30min without actions performed
const groupExpiresAt =
databaseTimestampToDate(group.latestActionAt).getTime() + 30 * 60 * 1000;
const now = new Date().getTime();
if (now > groupExpiresAt) {
return "EXPIRED";
}
const tenMinutesFromNow = now + 10 * 60 * 1000;
if (tenMinutesFromNow > groupExpiresAt) {
return "EXPIRING_SOON";
}
return null;
}

View File

@ -0,0 +1,39 @@
import type { GroupMember } from "~/db/types";
import type { LookingGroup } from "../q-types";
// logic is that team who is bigger decides the settings
// but if groups are the same size then the one who liked
// is basically consenting that other team's setting are used
export function groupAfterMorph({
ourGroup,
theirGroup,
liker,
}: {
ourGroup: LookingGroup;
theirGroup: LookingGroup;
liker: "US" | "THEM";
}) {
const ourMembers = ourGroup.members ?? [];
const theirMembers = theirGroup.members ?? [];
// if one group is full no mapListPreference is returned and we are not gonna morph anything anymore
if (!theirGroup.mapListPreference) return theirGroup;
if (ourMembers.length > theirMembers.length) {
return ourGroup;
}
if (theirMembers.length > ourMembers.length) {
return theirGroup;
}
if (liker === "US") {
return theirGroup;
}
return ourGroup;
}
export function hasGroupManagerPerms(role: GroupMember["role"]) {
return role === "OWNER" || role === "MANAGER";
}

View File

@ -0,0 +1,104 @@
import type { Group } from "~/db/types";
import { MapPool } from "~/modules/map-pool-serializer";
import { createTournamentMapList } from "~/modules/tournament-map-list-generator";
import { SENDOUQ_BEST_OF } from "../q-constants";
import type { LookingGroup } from "../q-types";
import invariant from "tiny-invariant";
import type { MatchById } from "../queries/findMatchById.server";
const filterMapPoolToSZ = (mapPool: MapPool) =>
new MapPool(mapPool.stageModePairs.filter(({ mode }) => mode === "SZ"));
export function matchMapList({
ourGroup,
theirGroup,
ourMapPool,
theirMapPool,
}: {
ourGroup: LookingGroup;
theirGroup: LookingGroup;
ourMapPool: MapPool;
theirMapPool: MapPool;
}) {
invariant(ourGroup.mapListPreference, "ourGroup.mapListPreference");
invariant(theirGroup.mapListPreference, "theirGroup.mapListPreference");
const type = mapListType([
ourGroup.mapListPreference,
theirGroup.mapListPreference,
]);
return createTournamentMapList({
bestOf: SENDOUQ_BEST_OF,
seed: String(ourGroup.id),
modesIncluded: type === "SZ" ? ["SZ"] : ["SZ", "TC", "RM", "CB"],
tiebreakerMaps: new MapPool([]),
teams: [
{
id: ourGroup.id,
maps: type === "SZ" ? filterMapPoolToSZ(ourMapPool) : ourMapPool,
},
{
id: theirGroup.id,
maps: type === "SZ" ? filterMapPoolToSZ(theirMapPool) : theirMapPool,
},
],
});
}
// type score as const object
const typeScore = {
ALL_MODES_ONLY: -2,
PREFER_ALL_MODES: -1,
NO_PREFERENCE: 0,
PREFER_SZ: 1,
SZ_ONLY: 2,
} as const;
function mapListType(
preferences: [Group["mapListPreference"], Group["mapListPreference"]]
) {
const score = typeScore[preferences[0]] + typeScore[preferences[1]];
if (score < 0) return "ALL_MODES";
if (score > 0) return "SZ";
return Math.random() < 0.5 ? "ALL_MODES" : "SZ";
}
export function compareMatchToReportedScores({
match,
winners,
newReporterGroupId,
previousReporterGroupId,
}: {
match: MatchById;
winners: ("ALPHA" | "BRAVO")[];
newReporterGroupId: number;
previousReporterGroupId?: number;
}) {
// match has not been reported before
if (!match.reportedByUserId) return "FIRST_REPORT";
const sameGroupReporting = newReporterGroupId === previousReporterGroupId;
const differentConstant = sameGroupReporting ? "FIX_PREVIOUS" : "DIFFERENT";
for (const [
i,
{ winnerGroupId: previousWinnerGroupId },
] of match.mapList.entries()) {
const newWinner = winners[i] ?? null;
if (!newWinner && !previousWinnerGroupId) continue;
if (!newWinner && previousWinnerGroupId) return differentConstant;
if (newWinner && !previousWinnerGroupId) return differentConstant;
const previousWinner =
previousWinnerGroupId === match.alphaGroupId ? "ALPHA" : "BRAVO";
if (previousWinner !== newWinner) return differentConstant;
}
// same group reporting the same exact score
if (sameGroupReporting) return "DUPLICATE";
return "SAME";
}

View File

@ -0,0 +1,22 @@
import { SENDOUQ_BEST_OF } from "../q-constants";
export function matchEndedAtIndex(scores: ("ALPHA" | "BRAVO")[]) {
let alphaCount = 0;
let bravoCount = 0;
let matchEndedAt = -1;
const mapsToWin = Math.ceil(SENDOUQ_BEST_OF / 2);
for (const [i, winner] of scores.entries()) {
if (winner === "ALPHA") alphaCount++;
if (winner === "BRAVO") bravoCount++;
if (alphaCount === mapsToWin || bravoCount === mapsToWin) {
matchEndedAt = i;
break;
}
}
if (matchEndedAt === -1) return null;
return matchEndedAt;
}

View File

@ -0,0 +1,105 @@
import invariant from "tiny-invariant";
import type { GroupMatch, Skill, User } from "~/db/types";
import {
queryCurrentTeamRating,
queryCurrentUserRating,
rate,
userIdsToIdentifier,
} from "~/features/mmr";
import { queryTeamPlayerRatingAverage } from "~/features/mmr/mmr-utils.server";
import { previousOrCurrentSeason } from "~/features/mmr/season";
export function calculateMatchSkills({
groupMatchId,
winner,
loser,
}: {
groupMatchId: GroupMatch["id"];
winner: User["id"][];
loser: User["id"][];
}) {
const result: Array<
Pick<
Skill,
"groupMatchId" | "identifier" | "mu" | "season" | "sigma" | "userId"
>
> = [];
const season = previousOrCurrentSeason(new Date())?.nth;
invariant(typeof season === "number", "No ranked season for skills");
{
// individual skills
const [winnerTeamNew, loserTeamNew] = rate([
winner.map((userId) => queryCurrentUserRating({ userId, season })),
loser.map((userId) => queryCurrentUserRating({ userId, season })),
]);
for (const [index, userId] of winner.entries()) {
result.push({
groupMatchId: groupMatchId,
identifier: null,
mu: winnerTeamNew[index].mu,
season,
sigma: winnerTeamNew[index].sigma,
userId,
});
}
for (const [index, userId] of loser.entries()) {
result.push({
groupMatchId: groupMatchId,
identifier: null,
mu: loserTeamNew[index].mu,
season,
sigma: loserTeamNew[index].sigma,
userId,
});
}
}
{
// team skills
const winnerTeamIdentifier = userIdsToIdentifier(winner);
const loserTeamIdentifier = userIdsToIdentifier(loser);
const [[winnerTeamNew], [loserTeamNew]] = rate(
[
[queryCurrentTeamRating({ identifier: winnerTeamIdentifier, season })],
[queryCurrentTeamRating({ identifier: loserTeamIdentifier, season })],
],
[
[
queryTeamPlayerRatingAverage({
identifier: winnerTeamIdentifier,
season,
}),
],
[
queryTeamPlayerRatingAverage({
identifier: loserTeamIdentifier,
season,
}),
],
]
);
result.push({
groupMatchId: groupMatchId,
identifier: winnerTeamIdentifier,
mu: winnerTeamNew.mu,
season,
sigma: winnerTeamNew.sigma,
userId: null,
});
result.push({
groupMatchId: groupMatchId,
identifier: loserTeamIdentifier,
mu: loserTeamNew.mu,
season,
sigma: loserTeamNew.sigma,
userId: null,
});
}
return result;
}

View File

@ -0,0 +1,150 @@
import type { MapResult, PlayerResult } from "~/db/types";
import type { MatchById } from "../queries/findMatchById.server";
import { previousOrCurrentSeason } from "~/features/mmr/season";
import invariant from "tiny-invariant";
import { winnersArrayToWinner } from "../q-utils";
export function summarizeMaps({
match,
winners,
members,
}: {
match: MatchById;
winners: ("ALPHA" | "BRAVO")[];
members: { id: number; groupId: number }[];
}) {
const season = previousOrCurrentSeason(new Date())?.nth;
invariant(typeof season === "number", "No ranked season for skills");
const result: Array<MapResult> = [];
const playedMaps = match.mapList.slice(0, winners.length);
for (const [i, map] of playedMaps.entries()) {
const winnerSide = winners[i];
const winnerGroupId =
winnerSide === "ALPHA" ? match.alphaGroupId : match.bravoGroupId;
const winnerPlayers = members.filter((p) => p.groupId === winnerGroupId);
const loserPlayers = members.filter((p) => p.groupId !== winnerGroupId);
for (const winner of winnerPlayers) {
result.push({
userId: winner.id,
wins: 1,
losses: 0,
mode: map.mode,
stageId: map.stageId,
season,
});
}
for (const loser of loserPlayers) {
result.push({
userId: loser.id,
wins: 0,
losses: 1,
mode: map.mode,
stageId: map.stageId,
season,
});
}
}
return result;
}
export function summarizePlayerResults({
match,
winners,
members,
}: {
match: MatchById;
winners: ("ALPHA" | "BRAVO")[];
members: { id: number; groupId: number }[];
}) {
const season = previousOrCurrentSeason(new Date())?.nth;
invariant(typeof season === "number", "No ranked season for skills");
const result: Array<PlayerResult> = [];
const addMapResult = ({
outcome,
type,
ownerUserId,
otherUserId,
}: {
outcome: "win" | "loss";
type: "MATE" | "ENEMY";
ownerUserId: number;
otherUserId: number;
}) => {
const existing = result.find(
(r) => r.ownerUserId === ownerUserId && r.otherUserId === otherUserId
);
if (existing) {
if (outcome === "win") {
existing.mapWins++;
} else existing.mapLosses++;
} else {
result.push({
ownerUserId,
otherUserId,
type,
mapWins: outcome === "win" ? 1 : 0,
mapLosses: outcome === "win" ? 0 : 1,
season,
setLosses: 0,
setWins: 0,
});
}
};
for (const winner of winners) {
for (const member of members) {
for (const member2 of members) {
if (member.id === member2.id) continue;
const type = member.groupId === member2.groupId ? "MATE" : "ENEMY";
const won =
winner === "ALPHA"
? member.groupId === match.alphaGroupId
: member.groupId === match.bravoGroupId;
addMapResult({
ownerUserId: member.id,
otherUserId: member2.id,
type,
outcome: won ? "win" : "loss",
});
}
}
}
const winner = winnersArrayToWinner(winners);
for (const member of members) {
for (const member2 of members) {
if (member.id === member2.id) continue;
const type = member.groupId === member2.groupId ? "MATE" : "ENEMY";
const won =
winner === "ALPHA"
? member.groupId === match.alphaGroupId
: member.groupId === match.bravoGroupId;
result.push({
ownerUserId: member.id,
otherUserId: member2.id,
type,
mapWins: 0,
mapLosses: 0,
season,
setWins: won ? 1 : 0,
setLosses: won ? 0 : 1,
});
}
}
return result;
}

View File

@ -0,0 +1,32 @@
import type { Group } from "~/db/types";
import { assertType } from "~/utils/types";
export const MAP_LIST_PREFERENCE_OPTIONS = [
"NO_PREFERENCE",
"PREFER_ALL_MODES",
"PREFER_SZ",
"ALL_MODES_ONLY",
"SZ_ONLY",
] as const;
assertType<
Group["mapListPreference"],
(typeof MAP_LIST_PREFERENCE_OPTIONS)[number]
>();
assertType<
(typeof MAP_LIST_PREFERENCE_OPTIONS)[number],
Group["mapListPreference"]
>();
export const SENDOUQ = {
SZ_MAP_COUNT: 6,
OTHER_MODE_MAP_COUNT: 2,
MAX_STAGE_REPEAT_COUNT: 2,
} as const;
export const FULL_GROUP_SIZE = 4;
export const SENDOUQ_BEST_OF = 7;
export const JOIN_CODE_SEARCH_PARAM_KEY = "join";
export const USER_SKILLS_CACHE_KEY = "user-skills";

View File

@ -0,0 +1,110 @@
import { z } from "zod";
import {
FULL_GROUP_SIZE,
MAP_LIST_PREFERENCE_OPTIONS,
SENDOUQ_BEST_OF,
} from "./q-constants";
import {
checkboxValueToBoolean,
id,
safeJSONParse,
weaponSplId,
} from "~/utils/zod";
import { matchEndedAtIndex } from "./core/match";
export const frontPageSchema = z.union([
z.object({
_action: z.literal("JOIN_QUEUE"),
mapListPreference: z.enum(MAP_LIST_PREFERENCE_OPTIONS),
mapPool: z.string(),
direct: z.literal("true").nullish(),
}),
z.object({
_action: z.literal("JOIN_TEAM"),
}),
z.object({
_action: z.literal("SET_INITIAL_SP"),
tier: z.enum(["higher", "default", "lower"]),
}),
]);
export const preparingSchema = z.union([
z.object({
_action: z.literal("JOIN_QUEUE"),
}),
z.object({
_action: z.literal("ADD_TRUSTED"),
id,
}),
]);
export const lookingSchema = z.union([
z.object({
_action: z.literal("LIKE"),
targetGroupId: id,
}),
z.object({
_action: z.literal("UNLIKE"),
targetGroupId: id,
}),
z.object({
_action: z.literal("GROUP_UP"),
targetGroupId: id,
}),
z.object({
_action: z.literal("MATCH_UP"),
targetGroupId: id,
}),
z.object({
_action: z.literal("GIVE_MANAGER"),
userId: id,
}),
z.object({
_action: z.literal("REMOVE_MANAGER"),
userId: id,
}),
z.object({
_action: z.literal("LEAVE_GROUP"),
}),
z.object({
_action: z.literal("REFRESH_GROUP"),
}),
]);
const winners = z.preprocess(
safeJSONParse,
z
.array(z.enum(["ALPHA", "BRAVO"]))
.min(Math.ceil(SENDOUQ_BEST_OF / 2))
.max(SENDOUQ_BEST_OF)
.refine((val) => {
const matchEndedAt = matchEndedAtIndex(val);
// match did end
if (matchEndedAt === null) return true;
// no extra scores after match ended
return val.length === matchEndedAt + 1;
})
);
export const matchSchema = z.union([
z.object({
_action: z.literal("REPORT_SCORE"),
winners,
adminReport: z.preprocess(
checkboxValueToBoolean,
z.boolean().nullish().default(false)
),
}),
z.object({
_action: z.literal("LOOK_AGAIN"),
previousGroupId: id,
}),
z.object({
_action: z.literal("REPORT_WEAPONS"),
weapons: z.preprocess(
safeJSONParse,
z.array(z.array(weaponSplId).length(FULL_GROUP_SIZE * 2))
),
}),
]);

View File

@ -0,0 +1,38 @@
import type { Group, GroupMember, PlusTier } from "~/db/types";
import type { MainWeaponId } from "~/modules/in-game-lists";
import type { TieredSkill } from "../mmr/tiered.server";
export type LookingGroup = {
id: number;
mapListPreference?: Group["mapListPreference"];
tier?: TieredSkill["tier"];
members?: {
id: number;
discordId: string;
discordName: string;
discordAvatar: string;
plusTier?: PlusTier["tier"];
role: GroupMember["role"];
weapons?: MainWeaponId[];
skill?: TieredSkill;
}[];
};
export type LookingGroupWithInviteCode = LookingGroup & {
inviteCode: Group["inviteCode"];
members: NonNullable<LookingGroup["members"]>;
};
export interface DividedGroups {
own: LookingGroup | LookingGroupWithInviteCode;
neutral: LookingGroup[];
likesReceived: LookingGroup[];
likesGiven: LookingGroup[];
}
export interface DividedGroupsUncensored {
own: LookingGroupWithInviteCode;
neutral: LookingGroupWithInviteCode[];
likesReceived: LookingGroupWithInviteCode[];
likesGiven: LookingGroupWithInviteCode[];
}

View File

@ -0,0 +1,89 @@
import type { Group } from "~/db/types";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type { MapPool } from "~/modules/map-pool-serializer";
import {
SENDOUQ_LOOKING_PAGE,
SENDOUQ_PAGE,
SENDOUQ_PREPARING_PAGE,
sendouQMatchPage,
} from "~/utils/urls";
import { SENDOUQ } from "./q-constants";
import { stageIds } from "~/modules/in-game-lists";
import type { Params } from "@remix-run/react";
import invariant from "tiny-invariant";
function groupRedirectLocation(
group?: Pick<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<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;
}
export function matchIdFromParams(params: Params<string>) {
const result = Number(params["id"]);
invariant(!Number.isNaN(result), "match id is not a number");
return result;
}
export function winnersArrayToWinner(winners: ("ALPHA" | "BRAVO")[]) {
const alphaCount = winners.filter((winner) => winner === "ALPHA").length;
const bravoCount = winners.filter((winner) => winner === "BRAVO").length;
if (alphaCount > bravoCount) return "ALPHA";
if (bravoCount > alphaCount) return "BRAVO";
throw new Error("no winner");
}

154
app/features/sendouq/q.css Normal file
View File

@ -0,0 +1,154 @@
.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);
align-items: center;
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__header {
font-size: var(--fonts-lg);
}
.q__map-preference-label {
margin-block-end: 0;
font-weight: var(--semi-bold);
display: flex;
align-items: center;
gap: var(--s-1);
color: var(--text-lighter);
}
.q__map-pool-grid {
display: grid;
grid-template-columns: repeat(7, max-content);
gap: var(--s-2-5);
font-size: var(--fonts-xs);
align-items: center;
}
.q__map-pool-grid__stage-image {
border-radius: 7px;
}
.q__own-group-container {
margin: 0 auto;
min-width: 300px;
}
.q__groups-container {
display: grid;
justify-content: center;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--s-6);
}
.q__mobile-groups-container {
max-width: 300px;
margin: 0 auto;
}
.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);
}
.q__group-member {
display: flex;
gap: var(--s-2);
align-items: center;
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
color: var(--text);
}
.q__member-placeholder {
border-radius: 100%;
background-color: var(--bg-lightest);
height: 24px;
width: 24px;
display: grid;
place-items: center;
font-weight: var(--semi-bold);
}
.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-preparing__card-container {
min-width: 250px;
margin: 0 auto;
}
.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);
}
.q-match__members-container {
min-width: 180px;
}
.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-4);
}
.q-match__weapon-grid-item {
width: 32px;
height: 32px;
}
@media screen and (min-width: 480px) {
.q-match__teams-container {
grid-template-columns: 1fr 1fr;
}
}

View File

@ -0,0 +1,34 @@
import { ordinal } from "openskill";
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
insert into "Skill" ("mu", "season", "sigma", "ordinal", "userId", "matchesCount")
values (
@mu,
@season,
@sigma,
@ordinal,
@userId,
0
)
`);
export function addInitialSkill({
mu,
sigma,
season,
userId,
}: {
mu: number;
sigma: number;
season: number;
userId: number;
}) {
stm.run({
mu,
sigma,
season,
ordinal: ordinal({ mu, sigma }),
userId,
});
}

View File

@ -0,0 +1,17 @@
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

@ -0,0 +1,18 @@
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

@ -0,0 +1,34 @@
import { sql } from "~/db/sql";
import type { MapResult } from "~/db/types";
const addMapResultDeltaStm = sql.prepare(/* sql */ `
insert into "MapResult" (
"mode",
"stageId",
"userId",
"wins",
"losses",
"season"
) values (
@mode,
@stageId,
@userId,
@wins,
@losses,
@season
) on conflict ("userId", "stageId", "mode", "season") do
update
set
"wins" = "wins" + @wins,
"losses" = "losses" + @losses
`);
export function addMapResults(
results: Array<
Pick<MapResult, "losses" | "wins" | "userId" | "mode" | "stageId">
>
) {
for (const result of results) {
addMapResultDeltaStm.run(result);
}
}

View File

@ -0,0 +1,23 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
insert into "GroupMember" (
"groupId",
"userId",
"role"
) values (
@groupId,
@userId,
'REGULAR'
)
`);
export function addMember({
groupId,
userId,
}: {
groupId: number;
userId: number;
}) {
stm.run({ groupId, userId });
}

View File

@ -0,0 +1,36 @@
import { sql } from "~/db/sql";
import type { PlayerResult } from "~/db/types";
const addPlayerResultDeltaStm = sql.prepare(/* sql */ `
insert into "PlayerResult" (
"ownerUserId",
"otherUserId",
"mapWins",
"mapLosses",
"setWins",
"setLosses",
"type",
"season"
) values (
@ownerUserId,
@otherUserId,
@mapWins,
@mapLosses,
@setWins,
@setLosses,
@type,
@season
) on conflict ("ownerUserId", "otherUserId", "type", "season") do
update
set
"mapWins" = "mapWins" + @mapWins,
"mapLosses" = "mapLosses" + @mapLosses,
"setWins" = "setWins" + @setWins,
"setLosses" = "setLosses" + @setLosses
`);
export function addPlayerResults(results: Array<PlayerResult>) {
for (const result of results) {
addPlayerResultDeltaStm.run(result);
}
}

View File

@ -0,0 +1,20 @@
import { sql } from "~/db/sql";
import type { MainWeaponId } from "~/modules/in-game-lists";
const insertStm = sql.prepare(/* sql */ `
insert into "ReportedWeapon"
("groupMatchMapId", "weaponSplId", "userId")
values (@groupMatchMapId, @weaponSplId, @userId)
`);
export const addReportedWeapons = (
args: {
groupMatchMapId: number;
weaponSplId: MainWeaponId;
userId: number;
}[]
) => {
for (const { groupMatchMapId, userId, weaponSplId } of args) {
insertStm.run({ groupMatchMapId, userId, weaponSplId });
}
};

View File

@ -0,0 +1,65 @@
import { ordinal } from "openskill";
import { sql } from "~/db/sql";
import type { Skill } from "~/db/types";
import { identifierToUserIds } from "~/features/mmr/mmr-utils.server";
const getStm = (type: "user" | "team") =>
sql.prepare(/* sql */ `
insert into "Skill" ("groupMatchId", "identifier", "mu", "season", "sigma", "ordinal", "userId", "matchesCount")
values (
@groupMatchId,
@identifier,
@mu,
@season,
@sigma,
@ordinal,
@userId,
1 + coalesce((
select max("matchesCount") from "Skill"
where
${type === "user" ? /* sql */ `"userId" = @userId` : ""}
${type === "team" ? /* sql */ `"identifier" = @identifier` : ""}
and "season" = @season
group by ${
type === "user" ? /* sql */ `"userId"` : /* sql */ `"identifier"`
}
), 0)
) returning *
`);
const addSkillTeamUserStm = sql.prepare(/* sql */ `
insert into "SkillTeamUser" (
"skillId",
"userId"
) values (
@skillId,
@userId
) on conflict("skillId", "userId") do nothing
`);
const userStm = getStm("user");
const teamStm = getStm("team");
export function addSkills(
skills: Pick<
Skill,
"groupMatchId" | "identifier" | "mu" | "season" | "sigma" | "userId"
>[]
) {
for (const skill of skills) {
const stm = skill.userId ? userStm : teamStm;
const insertedSkill = stm.get({
...skill,
ordinal: ordinal(skill),
}) as Skill;
if (insertedSkill.identifier) {
for (const userId of identifierToUserIds(insertedSkill.identifier)) {
addSkillTeamUserStm.run({
skillId: insertedSkill.id,
userId,
});
}
}
}
}

View File

@ -0,0 +1,111 @@
import { nanoid } from "nanoid";
import { INVITE_CODE_LENGTH } from "~/constants";
import { sql } from "~/db/sql";
import type { Group, GroupMember } from "~/db/types";
import type { MapPool } from "~/modules/map-pool-serializer";
const createGroupStm = sql.prepare(/* sql */ `
insert into "Group"
("mapListPreference", "inviteCode", "status")
values
(@mapListPreference, @inviteCode, @status)
returning *
`);
const createGroupMemberStm = sql.prepare(/* sql */ `
insert into "GroupMember"
("groupId", "userId", "role")
values
(@groupId, @userId, @role)
`);
const createMapPoolMapStm = sql.prepare(/* sql */ `
insert into "MapPoolMap"
("stageId", "mode", "groupId")
values
(@stageId, @mode, @groupId)
`);
type CreateGroupArgs = Pick<Group, "mapListPreference"> & {
status: Exclude<Group["status"], "INACTIVE">;
userId: number;
mapPool: MapPool;
};
const DEFAULT_ROLE: GroupMember["role"] = "OWNER";
export const createGroup = sql.transaction((args: CreateGroupArgs) => {
const group = createGroupStm.get({
mapListPreference: args.mapListPreference,
inviteCode: nanoid(INVITE_CODE_LENGTH),
status: args.status,
}) as Group;
createGroupMemberStm.run({
groupId: group.id,
userId: args.userId,
role: DEFAULT_ROLE,
});
for (const { stageId, mode } of args.mapPool.stageModePairs) {
createMapPoolMapStm.run({
stageId,
mode,
groupId: group.id,
});
}
return group;
});
type CreateGroupFromPreviousGroupArgs = {
previousGroupId: number;
members: {
id: number;
role: GroupMember["role"];
}[];
};
const createGroupFromPreviousGroupStm = sql.prepare(/* sql */ `
insert into "Group"
("mapListPreference", "teamId", "inviteCode", "status")
values
(
(select "mapListPreference" from "Group" where "id" = @previousGroupId),
(select "teamId" from "Group" where "id" = @previousGroupId),
@inviteCode,
@status
)
returning *
`);
const stealMapPoolStm = sql.prepare(/* sql */ `
update "MapPoolMap"
set "groupId" = @groupId
where "groupId" = @previousGroupId
`);
export const createGroupFromPreviousGroup = sql.transaction(
(args: CreateGroupFromPreviousGroupArgs) => {
const group = createGroupFromPreviousGroupStm.get({
previousGroupId: args.previousGroupId,
inviteCode: nanoid(INVITE_CODE_LENGTH),
status: "PREPARING",
}) as Group;
for (const member of args.members) {
createGroupMemberStm.run({
groupId: group.id,
userId: member.id,
role: member.role,
});
}
stealMapPoolStm.run({
previousGroupId: args.previousGroupId,
groupId: group.id,
});
return group;
}
);

View File

@ -0,0 +1,59 @@
import { sql } from "~/db/sql";
import type { GroupMatch } from "~/db/types";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
const createMatchStm = sql.prepare(/* sql */ `
insert into "GroupMatch" (
"alphaGroupId",
"bravoGroupId"
) values (
@alphaGroupId,
@bravoGroupId
)
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,
}: {
alphaGroupId: number;
bravoGroupId: number;
mapList: TournamentMapListMap[];
}) => {
const match = createMatchStm.get({
alphaGroupId,
bravoGroupId,
}) as GroupMatch;
for (const [i, { mode, source, stageId }] of mapList.entries()) {
createMatchMapStm.run({
matchId: match.id,
index: i,
mode,
stageId,
source: String(source),
});
}
return match;
}
);

View File

@ -0,0 +1,17 @@
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

@ -0,0 +1,21 @@
import { sql } from "~/db/sql";
const deleteStm = sql.prepare(/* sql */ `
delete from "ReportedWeapon"
where "groupMatchMapId" = @groupMatchMapId
`);
const getGroupMatchMapsStm = sql.prepare(/* sql */ `
select "id" from "GroupMatchMap"
where "matchId" = @matchId
`);
export const deleteReporterWeaponsByMatchId = (matchId: number) => {
const groupMatchMaps = getGroupMatchMapsStm.all({ matchId }) as Array<{
id: number;
}>;
for (const { id } of groupMatchMaps) {
deleteStm.run({ groupMatchMapId: id });
}
};

View File

@ -0,0 +1,35 @@
import invariant from "tiny-invariant";
import { sql } from "~/db/sql";
import type { Group, GroupMember } from "~/db/types";
const stm = sql.prepare(/* sql */ `
select
"Group"."id",
"Group"."status",
"Group"."latestActionAt",
"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<Group, "id" | "status" | "latestActionAt"> & {
matchId?: number;
role: 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

@ -0,0 +1,21 @@
import { sql } from "~/db/sql";
import type { GroupLike } from "~/db/types";
const stm = sql.prepare(/* sql */ `
select
"GroupLike"."likerGroupId",
"GroupLike"."targetGroupId"
from
"GroupLike"
where
"GroupLike"."likerGroupId" = @groupId
or "GroupLike"."targetGroupId" = @groupId
order by
"GroupLike"."createdAt" desc
`);
export function findLikes(
groupId: number
): Pick<GroupLike, "likerGroupId" | "targetGroupId">[] {
return stm.all({ groupId }) as any;
}

View File

@ -0,0 +1,51 @@
import { sql } from "~/db/sql";
import type { GroupMatch, GroupMatchMap } from "~/db/types";
import { parseDBJsonArray } from "~/utils/sql";
const stm = sql.prepare(/* sql */ `
select
"GroupMatch"."id",
"GroupMatch"."alphaGroupId",
"GroupMatch"."bravoGroupId",
"GroupMatch"."createdAt",
"GroupMatch"."reportedAt",
"GroupMatch"."reportedByUserId",
(select exists (select 1 from "Skill" where "Skill"."groupMatchId" = @id)) as "isLocked",
json_group_array(
json_object(
'id', "GroupMatchMap"."id",
'mode', "GroupMatchMap"."mode",
'stageId', "GroupMatchMap"."stageId",
'source', "GroupMatchMap"."source",
'winnerGroupId', "GroupMatchMap"."winnerGroupId"
)
) as "mapList"
from "GroupMatch"
left join "GroupMatchMap" on "GroupMatchMap"."matchId" = "GroupMatch"."id"
where "GroupMatch"."id" = @id
group by "GroupMatch"."id"
order by "GroupMatchMap"."index" asc
`);
export interface MatchById {
id: GroupMatch["id"];
alphaGroupId: GroupMatch["alphaGroupId"];
bravoGroupId: GroupMatch["bravoGroupId"];
createdAt: GroupMatch["createdAt"];
reportedAt: GroupMatch["reportedAt"];
reportedByUserId: GroupMatch["reportedByUserId"];
isLocked: number;
mapList: Array<
Pick<GroupMatchMap, "id" | "mode" | "stageId" | "source" | "winnerGroupId">
>;
}
export function findMatchById(id: number) {
const row = stm.get({ id }) as any;
if (!row) return null;
return {
...row,
mapList: parseDBJsonArray(row.mapList),
} as MatchById;
}

View File

@ -0,0 +1,69 @@
import { sql } from "~/db/sql";
import { parseDBArray, parseDBJsonArray } from "~/utils/sql";
import type { LookingGroupWithInviteCode } from "../q-types";
const stm = sql.prepare(/* sql */ `
with "q1" as (
select
"Group"."id",
"Group"."createdAt",
"Group"."mapListPreference",
"Group"."inviteCode",
"User"."id" as "userId",
"User"."discordId",
"User"."discordName",
"User"."discordAvatar",
"GroupMember"."role",
json_group_array("UserWeapon"."weaponSplId") as "weapons"
from
"Group"
left join "GroupMember" on "GroupMember"."groupId" = "Group"."id"
left join "User" on "User"."id" = "GroupMember"."userId"
left join "UserWeapon" on "UserWeapon"."userId" = "User"."id"
left join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id"
or "GroupMatch"."bravoGroupId" = "Group"."id"
where
"Group"."id" = @ownGroupId
and "Group"."status" = 'PREPARING'
and ("UserWeapon"."order" is null or "UserWeapon"."order" <= 3)
group by "User"."id"
order by "UserWeapon"."order" asc
)
select
"q1"."id",
"q1"."mapListPreference",
"q1"."inviteCode",
json_group_array(
json_object(
'id', "q1"."userId",
'discordId', "q1"."discordId",
'discordName', "q1"."discordName",
'discordAvatar', "q1"."discordAvatar",
'role', "q1"."role",
'weapons', "q1"."weapons"
)
) 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,
mapListPreference: row.mapListPreference,
inviteCode: row.inviteCode,
members: parseDBJsonArray(row.members).map((member: any) => {
const weapons = parseDBArray(member.weapons);
return {
...member,
weapons: weapons.length > 0 ? weapons : undefined,
};
}),
};
}

View File

@ -0,0 +1,33 @@
import { sql } from "~/db/sql";
import type { Group } from "~/db/types";
import { parseDBArray } from "~/utils/sql";
const stm = sql.prepare(/* sql */ `
select
"Group"."id",
"Group"."status",
json_group_array(
"User"."discordName"
) 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 findTeamByInviteCode(
inviteCode: string
): { id: number; status: Group["status"]; members: string[] } | null {
const row = stm.get({ inviteCode }) as any;
if (!row) return null;
return {
id: row.id,
status: row.status,
members: parseDBArray(row.members),
};
}

View File

@ -0,0 +1,64 @@
import { sql } from "~/db/sql";
import type { Group, GroupMember, User } from "~/db/types";
const stm = sql.prepare(/* sql */ `
select
"Group"."id",
"AllTeam"."name" as "teamName",
"AllTeam"."customUrl" as "teamCustomUrl",
"UserSubmittedImage"."url" as "teamAvatarUrl",
json_group_array(
json_object(
'id', "GroupMember"."userId",
'discordId', "User"."discordId",
'discordName', "User"."discordName",
'discordAvatar', "User"."discordAvatar",
'role', "GroupMember"."role",
'customUrl', "User"."customUrl"
)
) as "members"
from
"Group"
left join "GroupMember" on "GroupMember"."groupId" = "Group"."id"
left join "User" on "User"."id" = "GroupMember"."userId"
left join "AllTeam" on "AllTeam"."id" = "Group"."teamId"
left join "UserSubmittedImage" on "AllTeam"."avatarImgId" = "UserSubmittedImage"."id"
where
"Group"."id" = @id
group by "Group"."id"
order by "GroupMember"."userId" asc
`);
export interface GroupForMatch {
id: Group["id"];
team?: {
name: string;
avatarUrl: string | null;
customUrl: string;
};
members: Array<{
id: GroupMember["userId"];
discordId: User["discordId"];
discordName: User["discordName"];
discordAvatar: User["discordAvatar"];
role: GroupMember["role"];
customUrl: User["customUrl"];
}>;
}
export function groupForMatch(id: number) {
const row = stm.get({ id }) as any;
if (!row) return null;
return {
id: row.id,
team: row.teamName
? {
name: row.teamName,
avatarUrl: row.teamAvatarUrl,
customUrl: row.teamCustomUrl,
}
: undefined,
members: JSON.parse(row.members),
} as GroupForMatch;
}

View File

@ -0,0 +1,14 @@
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.pluck().get({ groupId }) as number;
}

View File

@ -0,0 +1,27 @@
import { sql } from "~/db/sql";
import type { GroupMember } from "~/db/types";
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<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

@ -0,0 +1,45 @@
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 });
}
}
);

View File

@ -0,0 +1,18 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
select 1 from "GroupLike"
where
"GroupLike"."likerGroupId" = @likerGroupId
and "GroupLike"."targetGroupId" = @targetGroupId
`);
export function likeExists({
likerGroupId,
targetGroupId,
}: {
likerGroupId: number;
targetGroupId: number;
}) {
return Boolean(stm.get({ likerGroupId, targetGroupId }));
}

View File

@ -0,0 +1,93 @@
import { sql } from "~/db/sql";
import { parseDBArray, parseDBJsonArray } from "~/utils/sql";
import type { LookingGroupWithInviteCode } from "../q-types";
// groups visible for longer to make development easier
const SECONDS_TILL_STALE =
process.env.NODE_ENV === "development" ? 1_000_000 : 1_800;
const stm = sql.prepare(/* sql */ `
with "q1" as (
select
"Group"."id",
"Group"."createdAt",
"Group"."mapListPreference",
"Group"."inviteCode",
"User"."id" as "userId",
"User"."discordId",
"User"."discordName",
"User"."discordAvatar",
"PlusTier"."tier" as "plusTier",
"GroupMember"."role",
json_group_array("UserWeapon"."weaponSplId") as "weapons"
from
"Group"
left join "GroupMember" on "GroupMember"."groupId" = "Group"."id"
left join "User" on "User"."id" = "GroupMember"."userId"
left join "PlusTier" on "PlusTier"."userId" = "User"."id"
left join "UserWeapon" on "UserWeapon"."userId" = "User"."id"
left join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id"
or "GroupMatch"."bravoGroupId" = "Group"."id"
where
"Group"."status" = 'ACTIVE'
-- only groups that were active in the last half an hour as well as own group
and ("Group"."latestActionAt" > (unixepoch() - ${SECONDS_TILL_STALE}) or "Group"."id" = @ownGroupId)
and "GroupMatch"."id" is null
and ("UserWeapon"."order" is null or "UserWeapon"."order" <= 3)
group by "User"."id"
order by "UserWeapon"."order" asc
)
select
"q1"."id",
"q1"."mapListPreference",
"q1"."inviteCode",
json_group_array(
json_object(
'id', "q1"."userId",
'discordId', "q1"."discordId",
'discordName', "q1"."discordName",
'discordAvatar', "q1"."discordAvatar",
'plusTier', "q1"."plusTier",
'role', "q1"."role",
'weapons', "q1"."weapons"
)
) as "members"
from "q1"
group by "q1"."id"
order by "q1"."createdAt" desc
`);
export function findLookingGroups({
minGroupSize,
maxGroupSize,
ownGroupId,
}: {
minGroupSize?: number;
maxGroupSize?: number;
ownGroupId: number;
}): LookingGroupWithInviteCode[] {
return stm
.all({ ownGroupId })
.map((row: any) => {
return {
id: row.id,
mapListPreference: row.mapListPreference,
inviteCode: row.inviteCode,
members: parseDBJsonArray(row.members).map((member: any) => {
const weapons = parseDBArray(member.weapons);
return {
...member,
weapons: weapons.length > 0 ? weapons : undefined,
};
}),
};
})
.filter((group: any) => {
if (group.id === ownGroupId) return true;
if (maxGroupSize && group.members.length > maxGroupSize) return false;
if (minGroupSize && group.members.length < minGroupSize) return false;
return true;
});
}

View File

@ -0,0 +1,14 @@
import { sql } from "~/db/sql";
import type { MapPoolMap } from "~/db/types";
const stm = sql.prepare(/* sql */ `
select
"MapPoolMap"."stageId",
"MapPoolMap"."mode"
from "MapPoolMap"
where "MapPoolMap"."groupId" = @groupId
`);
export function mapPoolByGroupId(groupId: number) {
return stm.all({ groupId }) as Array<Pick<MapPoolMap, "stageId" | "mode">>;
}

View File

@ -0,0 +1,41 @@
import { sql } from "~/db/sql";
const deleteGroupStm = sql.prepare(/* sql */ `
delete from "Group"
where "Group"."id" = @groupId
`);
const addGroupMemberStm = sql.prepare(/* sql */ `
insert into "GroupMember" ("groupId", "userId", "role")
values (@groupId, @userId, @role)
`);
const deleteLikesStm = sql.prepare(/* sql */ `
delete from "GroupLike"
where "likerGroupId" = @groupId
or "targetGroupId" = @groupId
`);
export const morphGroups = sql.transaction(
({
survivingGroupId,
otherGroupId,
newMembers,
}: {
survivingGroupId: number;
otherGroupId: number;
newMembers: number[];
}) => {
deleteGroupStm.run({ groupId: otherGroupId });
deleteLikesStm.run({ groupId: survivingGroupId });
for (const userId of newMembers) {
addGroupMemberStm.run({
groupId: survivingGroupId,
userId,
role: "REGULAR",
});
}
}
);

View File

@ -0,0 +1,12 @@
import { sql } from "~/db/sql";
import { dateToDatabaseTimestamp } from "~/utils/dates";
const stm = sql.prepare(/* sql */ `
update "Group"
set 'latestActionAt' = @latestActionAt
where "Group"."id" = @groupId
`);
export function refreshGroup(groupId: number) {
stm.run({ latestActionAt: dateToDatabaseTimestamp(new Date()), groupId });
}

View File

@ -0,0 +1,18 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
update "GroupMember"
set "role" = 'REGULAR'
where "userId" = @userId
and "groupId" = @groupId
`);
export function removeManagerRole({
userId,
groupId,
}: {
userId: number;
groupId: number;
}) {
stm.run({ userId, groupId });
}

View File

@ -0,0 +1,52 @@
import { sql } from "~/db/sql";
import type { GroupMatch } from "~/db/types";
import { dateToDatabaseTimestamp } from "~/utils/dates";
const updateMatchStm = sql.prepare(/* sql */ `
update "GroupMatch"
set "reportedAt" = @reportedAt,
"reportedByUserId" = @reportedByUserId
where "id" = @matchId
returning *
`);
const clearMatchMapWinnersStm = sql.prepare(/* sql */ `
update "GroupMatchMap"
set "winnerGroupId" = null
where "matchId" = @matchId
`);
const updateMatchMapStm = sql.prepare(/* sql */ `
update "GroupMatchMap"
set "winnerGroupId" = @winnerGroupId
where "matchId" = @matchId and "index" = @index
`);
export const reportScore = ({
reportedByUserId,
winners,
matchId,
}: {
reportedByUserId: number;
winners: ("ALPHA" | "BRAVO")[];
matchId: number;
}) => {
const updatedMatch = updateMatchStm.get({
reportedAt: dateToDatabaseTimestamp(new Date()),
reportedByUserId,
matchId,
}) as GroupMatch;
clearMatchMapWinnersStm.run({ matchId });
for (const [index, winner] of winners.entries()) {
updateMatchMapStm.run({
winnerGroupId:
winner === "ALPHA"
? updatedMatch.alphaGroupId
: updatedMatch.bravoGroupId,
matchId,
index,
});
}
};

View File

@ -0,0 +1,21 @@
import { sql } from "~/db/sql";
import type { ReportedWeapon } from "~/db/types";
const stm = sql.prepare(/* sql */ `
select
"ReportedWeapon"."groupMatchMapId",
"ReportedWeapon"."weaponSplId",
"ReportedWeapon"."userId"
from
"ReportedWeapon"
left join "GroupMatchMap" on "GroupMatchMap"."id" = "ReportedWeapon"."groupMatchMapId"
where "GroupMatchMap"."matchId" = @matchId
`);
export function reportedWeaponsByMatchId(matchId: number) {
const rows = stm.all({ matchId }) as Array<ReportedWeapon>;
if (rows.length === 0) return null;
return rows;
}

View File

@ -0,0 +1,195 @@
import { sql } from "~/db/sql";
import type { GroupMatch, GroupMatchMap, User } from "~/db/types";
import { type RankingSeason, seasonObject } from "~/features/mmr/season";
import type { MainWeaponId } from "~/modules/in-game-lists";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { parseDBArray, parseDBJsonArray } from "~/utils/sql";
const MATCHES_PER_PAGE = 8;
const stm = sql.prepare(/* sql */ `
with "q1" as (
select
"GroupMatch"."id",
"GroupMatch"."alphaGroupId",
"GroupMatch"."bravoGroupId",
"GroupMatch"."createdAt"
from "GroupMatch"
left join "Group" on
"GroupMatch"."alphaGroupId" = "Group"."id" or
"GroupMatch"."bravoGroupId" = "Group"."id"
left join "GroupMember" on "Group"."id" = "GroupMember"."groupId"
where "GroupMember"."userId" = @userId
and "GroupMatch"."createdAt" between @starts and @ends
order by "GroupMatch"."id" desc
limit ${MATCHES_PER_PAGE}
offset ${MATCHES_PER_PAGE} * (@page - 1)
),
"q2" as (
select
"q1".*,
json_group_array(
"GroupMatchMap"."winnerGroupId"
) as "winnerGroupIds"
from
"q1"
left join "GroupMatchMap" on "q1"."id" = "GroupMatchMap"."matchId"
where "GroupMatchMap"."winnerGroupId" is not null
group by "q1"."id"
), "q3" as (
select
"q2".*,
json_group_array(
json_object(
'id', "User"."id",
'discordName', "User"."discordName",
'discordId', "User"."discordId",
'discordAvatar', "User"."discordAvatar"
)
) as "groupAlphaMembers"
from "q2"
left join "Group" on "q2"."alphaGroupId" = "Group"."id"
left join "GroupMember" on "Group"."id" = "GroupMember"."groupId"
left join "User" on "GroupMember"."userId" = "User"."id"
group by "q2"."id"
)
select
"q3".*,
json_group_array(
json_object(
'id', "User"."id",
'discordName', "User"."discordName",
'discordId', "User"."discordId",
'discordAvatar', "User"."discordAvatar"
)
) as "groupBravoMembers"
from "q3"
left join "Group" on "q3"."bravoGroupId" = "Group"."id"
left join "GroupMember" on "Group"."id" = "GroupMember"."groupId"
left join "User" on "GroupMember"."userId" = "User"."id"
group by "q3"."id"
order by "q3"."id" desc
`);
const weaponsStm = sql.prepare(/* sql */ `
with "q1" as (
select
"ReportedWeapon"."userId",
"ReportedWeapon"."weaponSplId",
count(*) as "count"
from
"GroupMatch"
left join "GroupMatchMap" on "GroupMatch"."id" = "GroupMatchMap"."matchId"
left join "ReportedWeapon" on "GroupMatchMap"."id" = "ReportedWeapon"."groupMatchMapId"
where "GroupMatch"."id" = @id
group by "ReportedWeapon"."userId", "ReportedWeapon"."weaponSplId"
order by "count" desc
)
select
"q1"."userId",
"q1"."weaponSplId"
from "q1"
group by "q1"."userId"
`);
interface SeasonMatchByUserId {
id: GroupMatch["id"];
alphaGroupId: GroupMatch["alphaGroupId"];
bravoGroupId: GroupMatch["bravoGroupId"];
winnerGroupIds: Array<GroupMatchMap["winnerGroupId"]>;
createdAt: GroupMatch["createdAt"];
groupAlphaMembers: Array<{
id: User["id"];
discordName: User["discordName"];
discordId: User["discordId"];
discordAvatar: User["discordAvatar"];
weaponSplId?: MainWeaponId;
}>;
groupBravoMembers: Array<{
id: User["id"];
discordName: User["discordName"];
discordId: User["discordId"];
discordAvatar: User["discordAvatar"];
weaponSplId?: MainWeaponId;
}>;
}
export function seasonMatchesByUserId({
userId,
season,
page,
}: {
userId: number;
season: RankingSeason["nth"];
page: number;
}): SeasonMatchByUserId[] {
const { starts, ends } = seasonObject(season);
const rows = stm.all({
userId,
starts: dateToDatabaseTimestamp(starts),
ends: dateToDatabaseTimestamp(ends),
page,
}) as any;
return rows.map((row: any) => {
const weapons = weaponsStm.all({ id: row.id }) as any;
return {
...row,
winnerGroupIds: parseDBArray(row.winnerGroupIds),
groupAlphaMembers: parseDBJsonArray(row.groupAlphaMembers).map(
(member: any) => ({
...member,
weaponSplId: weapons.find((w: any) => w.userId === member.id)
?.weaponSplId,
})
),
groupBravoMembers: parseDBJsonArray(row.groupBravoMembers).map(
(member: any) => ({
...member,
weaponSplId: weapons.find((w: any) => w.userId === member.id)
?.weaponSplId,
})
),
};
});
}
const pagesStm = sql.prepare(/* sql */ `
with "q1" as (
select
"GroupMatch"."id"
from "GroupMatch"
left join "Group" on
"GroupMatch"."alphaGroupId" = "Group"."id" or
"GroupMatch"."bravoGroupId" = "Group"."id"
left join "GroupMember" on "Group"."id" = "GroupMember"."groupId"
left join "GroupMatchMap" on "GroupMatch"."id" = "GroupMatchMap"."matchId"
where "GroupMember"."userId" = @userId
and "GroupMatch"."createdAt" between @starts and @ends
and "GroupMatchMap"."winnerGroupId" is not null
group by "GroupMatch"."id"
)
select
count(*) as "count"
from "q1"
`);
export function seasonMatchesByUserIdPagesCount({
userId,
season,
}: {
userId: number;
season: RankingSeason["nth"];
}): number {
const { starts, ends } = seasonObject(season);
const row = pagesStm.get({
userId,
starts: dateToDatabaseTimestamp(starts),
ends: dateToDatabaseTimestamp(ends),
}) as any;
return Math.ceil((row.count as number) / MATCHES_PER_PAGE);
}

View File

@ -0,0 +1,35 @@
import { sql } from "~/db/sql";
import { type RankingSeason, seasonObject } from "~/features/mmr/season";
import type { MainWeaponId } from "~/modules/in-game-lists";
import { dateToDatabaseTimestamp } from "~/utils/dates";
const stm = sql.prepare(/* sql */ `
select
"ReportedWeapon"."weaponSplId",
count(*) as "count"
from
"ReportedWeapon"
left join "GroupMatchMap" on "GroupMatchMap"."id" = "ReportedWeapon"."groupMatchMapId"
left join "GroupMatch" on "GroupMatch"."id" = "GroupMatchMap"."matchId"
where
"ReportedWeapon"."userId" = @userId
and "GroupMatch"."createdAt" between @starts and @ends
group by "ReportedWeapon"."weaponSplId"
order by "count" desc
`);
export function seasonReportedWeaponsByUserId({
userId,
season,
}: {
userId: number;
season: RankingSeason["nth"];
}) {
const { starts, ends } = seasonObject(season);
return stm.all({
userId,
starts: dateToDatabaseTimestamp(starts),
ends: dateToDatabaseTimestamp(ends),
}) as Array<{ weaponSplId: MainWeaponId; count: number }>;
}

View File

@ -0,0 +1,50 @@
import { sql } from "~/db/sql";
import type { MapResult } from "~/db/types";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
const stm = sql.prepare(/* sql */ `
select
*
from
"MapResult"
where
"MapResult"."userId" = @userId
and "MapResult"."season" = @season
`);
export function seasonStagesByUserId({
userId,
season,
}: {
userId: number;
season: number;
}) {
const rows = stm.all({ userId, season }) as Array<MapResult>;
return rows.reduce(
(acc, cur) => {
if (!acc[cur.stageId]) acc[cur.stageId] = {};
acc[cur.stageId]![cur.mode] = {
wins: cur.wins,
losses: cur.losses,
};
return acc;
},
{} as Partial<
Record<
StageId,
Partial<
Record<
ModeShort,
{
wins: number;
losses: number;
}
>
>
>
>
);
}

View File

@ -0,0 +1,51 @@
import { sql } from "~/db/sql";
import type { PlayerResult, User } from "~/db/types";
import { type RankingSeason } from "~/features/mmr/season";
const stm = sql.prepare(/* sql */ `
select
"mapWins",
"mapLosses",
"setWins",
"setLosses",
json_object(
'id', "User"."id",
'discordName', "User"."discordName",
'discordAvatar', "User"."discordAvatar",
'discordId', "User"."discordId",
'customUrl', "User"."customUrl"
) as "user"
from "PlayerResult"
left join "User" on "User"."id" = "PlayerResult"."otherUserId"
where
"ownerUserId" = @userId
and "season" = @season
and "type" = @type
order by "mapWins" + "mapLosses" desc
`);
export function seasonsMatesEnemiesByUserId({
userId,
season,
type,
}: {
userId: number;
season: RankingSeason["nth"];
type: PlayerResult["type"];
}) {
const rows = stm.all({ userId, season, type }) as any[];
return rows.map((row) => ({
...row,
user: JSON.parse(row.user),
})) as Array<{
mapWins: number;
mapLosses: number;
setWins: number;
setLosses: number;
user: Pick<
User,
"id" | "discordName" | "discordAvatar" | "discordId" | "customUrl"
>;
}>;
}

View File

@ -0,0 +1,12 @@
import { sql } from "~/db/sql";
import type { Group } from "~/db/types";
const stm = sql.prepare(/* sql */ `
update "Group"
set "status" = 'ACTIVE'
where "id" = @groupId
`);
export function setGroupAsActive(groupId: Group["id"]) {
stm.run({ groupId });
}

View File

@ -0,0 +1,11 @@
import { sql } from "~/db/sql";
const groupToInactiveStm = sql.prepare(/* sql */ `
update "Group"
set "status" = 'INACTIVE'
where "id" = @groupId
`);
export function setGroupAsInactive(groupId: number) {
groupToInactiveStm.run({ groupId });
}

View File

@ -0,0 +1,32 @@
import invariant from "tiny-invariant";
import { sql } from "~/db/sql";
import { FULL_GROUP_SIZE } from "../q-constants";
const memberTeamIdsStm = sql.prepare(/* sql */ `
select "TeamMember"."teamId"
from "GroupMember"
left join "TeamMember" on "TeamMember"."userId" = "GroupMember"."userId"
where "groupId" = @groupId
`);
const updateStm = sql.prepare(/* sql */ `
update "Group"
set "teamId" = @teamId
where "id" = @groupId
`);
export function syncGroupTeamId(groupId: number) {
const teamIds = memberTeamIdsStm
.all({ groupId })
.map((row: any) => row.teamId);
invariant(teamIds.length === FULL_GROUP_SIZE, "Group to sync is not full");
const set = new Set(teamIds);
if (set.size === 1) {
const teamId = teamIds[0];
updateStm.run({ groupId, teamId });
} else {
updateStm.run({ groupId, teamId: null });
}
}

View File

@ -0,0 +1,24 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
select
1
from
"Skill"
where
"Skill"."userId" = @userId
and "Skill"."season" = @season
limit 1
`);
export function userHasSkill({
userId,
season,
}: {
userId: number;
season: number;
}) {
const rows = stm.all({ userId, season });
return rows.length > 0;
}

View File

@ -0,0 +1,29 @@
import { sql } from "~/db/sql";
import { findTrustedPlayers } from "~/features/tournament/queries/findTrustedPlayers.server";
const stm = sql.prepare(/* sql */ `
select
"GroupMember"."userId"
from
"GroupMember"
left join "Group" on "Group"."id" = "GroupMember"."groupId"
where
"Group"."status" != 'INACTIVE'
`);
export function trustedPlayersAvailableToPlay(user: {
id: number;
team?: { id: number };
}) {
const trusted = findTrustedPlayers({
userId: user.id,
teamId: user.team?.id,
});
if (trusted.length === 0) return trusted;
const activePlayers = (stm.all() as Array<{ userId: number }>).map(
(u) => u.userId
);
return trusted.filter((u) => !activePlayers.includes(u.id));
}

View File

@ -0,0 +1,597 @@
import type {
ActionFunction,
LinksFunction,
LoaderArgs,
V2_MetaFunction,
} from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { useFetcher, useLoaderData, useRevalidator } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { Flipper } from "react-flip-toolkit";
import invariant from "tiny-invariant";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTranslation } from "~/hooks/useTranslation";
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
import { getUser, requireUserId } from "~/modules/auth/user.server";
import { MapPool } from "~/modules/map-pool-serializer";
import {
parseRequestFormData,
validate,
type SendouRouteHandle,
} from "~/utils/remix";
import { assertUnreachable } from "~/utils/types";
import {
SENDOUQ_LOOKING_PAGE,
SENDOUQ_PAGE,
navIconUrl,
sendouQMatchPage,
} from "~/utils/urls";
import { GroupCard } from "../components/GroupCard";
import { groupAfterMorph, hasGroupManagerPerms } from "../core/groups";
import {
addSkillsToGroups,
censorGroups,
divideGroups,
filterOutGroupsWithIncompatibleMapListPreference,
groupExpiryStatus,
membersNeededForFull,
} from "../core/groups.server";
import { matchMapList } from "../core/match.server";
import { FULL_GROUP_SIZE } from "../q-constants";
import { lookingSchema } from "../q-schemas.server";
import { groupRedirectLocationByCurrentLocation } from "../q-utils";
import styles from "../q.css";
import { addLike } from "../queries/addLike.server";
import { addManagerRole } from "../queries/addManagerRole.server";
import { createMatch } from "../queries/createMatch.server";
import { deleteLike } from "../queries/deleteLike.server";
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
import { findLikes } from "../queries/findLikes";
import { groupSize } from "../queries/groupSize.server";
import { groupSuccessorOwner } from "../queries/groupSuccessorOwner";
import { leaveGroup } from "../queries/leaveGroup.server";
import { likeExists } from "../queries/likeExists.server";
import { findLookingGroups } from "../queries/lookingGroups.server";
import { mapPoolByGroupId } from "../queries/mapPoolByGroupId.server";
import { morphGroups } from "../queries/morphGroups.server";
import { refreshGroup } from "../queries/refreshGroup.server";
import { removeManagerRole } from "../queries/removeManagerRole.server";
import { syncGroupTeamId } from "../queries/syncGroupTeamId.server";
import { makeTitle } from "~/utils/strings";
import { MemberAdder } from "../components/MemberAdder";
import type { LookingGroupWithInviteCode } from "../q-types";
import { trustedPlayersAvailableToPlay } from "../queries/usersInActiveGroup.server";
import { userSkills } from "~/features/mmr/tiered.server";
import { useWindowSize } from "~/hooks/useWindowSize";
import { Tab, Tabs } from "~/components/Tabs";
export const handle: SendouRouteHandle = {
i18n: ["q"],
breadcrumb: () => ({
imgPath: navIconUrl("sendouq"),
href: SENDOUQ_LOOKING_PAGE,
type: "IMAGE",
}),
};
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const meta: V2_MetaFunction = () => {
return [{ title: makeTitle("SendouQ") }];
};
// this function doesn't throw normally because we are assuming
// if there is a validation error the user saw stale data
// and when we return null we just force a refresh
export const action: ActionFunction = async ({ request }) => {
const user = await requireUserId(request);
const data = await parseRequestFormData({
request,
schema: lookingSchema,
});
const currentGroup = findCurrentGroupByUserId(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 = () =>
validate(currentGroup.role === "OWNER", "Not owner");
const isGroupManager = () =>
currentGroup.role === "MANAGER" || currentGroup.role === "OWNER";
switch (data._action) {
case "LIKE": {
if (!isGroupManager()) return null;
addLike({
likerGroupId: currentGroup.id,
targetGroupId: data.targetGroupId,
});
refreshGroup(currentGroup.id);
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 = findLookingGroups({
maxGroupSize: membersNeededForFull(groupSize(currentGroup.id)),
ownGroupId: currentGroup.id,
});
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.members.length + otherGroup.members.length ===
FULL_GROUP_SIZE
) {
syncGroupTeamId(survivingGroupId);
}
break;
}
case "MATCH_UP": {
if (!isGroupManager()) return null;
if (
!likeExists({
targetGroupId: currentGroup.id,
likerGroupId: data.targetGroupId,
})
) {
return null;
}
const lookingGroups = findLookingGroups({
minGroupSize: FULL_GROUP_SIZE,
ownGroupId: currentGroup.id,
});
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 createdMatch = createMatch({
alphaGroupId: ourGroup.id,
bravoGroupId: theirGroup.id,
mapList: matchMapList({
ourGroup,
theirGroup,
ourMapPool: new MapPool(mapPoolByGroupId(ourGroup.id)),
theirMapPool: new MapPool(mapPoolByGroupId(theirGroup.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": {
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",
});
throw redirect(SENDOUQ_PAGE);
}
case "REFRESH_GROUP": {
refreshGroup(currentGroup.id);
break;
}
default: {
assertUnreachable(data);
}
}
return null;
};
export const loader = async ({ request }: LoaderArgs) => {
const user = await getUser(request);
const currentGroup = user ? findCurrentGroupByUserId(user.id) : undefined;
const redirectLocation = groupRedirectLocationByCurrentLocation({
group: currentGroup,
currentLocation: "looking",
});
if (redirectLocation) {
throw redirect(redirectLocation);
}
invariant(currentGroup, "currentGroup is undefined");
const currentGroupSize = groupSize(currentGroup.id);
const groupIsFull = currentGroupSize === FULL_GROUP_SIZE;
const dividedGroups = divideGroups({
groups: findLookingGroups({
maxGroupSize: groupIsFull
? undefined
: membersNeededForFull(currentGroupSize),
minGroupSize: groupIsFull ? FULL_GROUP_SIZE : undefined,
ownGroupId: currentGroup.id,
}),
ownGroupId: currentGroup.id,
likes: findLikes(currentGroup.id),
});
const groupsWithSkills = addSkillsToGroups({
groups: dividedGroups,
...(await userSkills()),
});
const compatibleGroups = groupIsFull
? filterOutGroupsWithIncompatibleMapListPreference(groupsWithSkills)
: groupsWithSkills;
const censoredGroups = censorGroups({
groups: compatibleGroups,
showMembers: !groupIsFull,
showInviteCode: hasGroupManagerPerms(currentGroup.role) && !groupIsFull,
});
return {
groups: censoredGroups,
role: currentGroup.role,
lastUpdated: new Date().getTime(),
expiryStatus: groupExpiryStatus(currentGroup),
trustedPlayers: hasGroupManagerPerms(currentGroup.role)
? trustedPlayersAvailableToPlay(user!)
: [],
};
};
export default function QLookingPage() {
const data = useLoaderData<typeof loader>();
useAutoRefresh();
const ownGroup = data.groups.own as LookingGroupWithInviteCode;
return (
<Main className="stack lg">
<div className="stack sm">
<InfoText />
<div className="q__own-group-container">
<GroupCard
group={data.groups.own}
mapListPreference={data.groups.own.mapListPreference}
ownRole={data.role}
ownGroup
/>
</div>
</div>
{ownGroup.inviteCode ? (
<MemberAdder
inviteCode={ownGroup.inviteCode}
trustedPlayers={data.trustedPlayers}
/>
) : null}
<Groups />
</Main>
);
}
// TODO: could be improved e.g. don't refresh when group has expired
// or we got new data in the last 20 seconds
function useAutoRefresh() {
const { revalidate } = useRevalidator();
const visibility = useVisibilityChange();
React.useEffect(() => {
// when user comes back to this tab
if (visibility === "visible") {
revalidate();
}
// ...as well as every 20 seconds
const interval = setInterval(() => {
if (visibility === "hidden") return;
revalidate();
}, 20 * 1000);
return () => {
clearInterval(interval);
};
}, [visibility, revalidate]);
}
function InfoText() {
const { i18n } = useTranslation();
const isMounted = useIsMounted();
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
if (data.expiryStatus === "EXPIRED") {
return (
<fetcher.Form
method="post"
className="text-xs text-lighter ml-auto text-error stack horizontal sm"
>
Group hidden due to inactivity. Still looking?{" "}
<SubmitButton
size="tiny"
variant="minimal"
_action="REFRESH_GROUP"
state={fetcher.state}
>
Click here
</SubmitButton>
</fetcher.Form>
);
}
if (data.expiryStatus === "EXPIRING_SOON") {
return (
<fetcher.Form
method="post"
className="text-xs text-lighter ml-auto text-warning stack horizontal sm"
>
Group will be hidden soon due to inactivity. Still looking?{" "}
<SubmitButton
size="tiny"
variant="minimal"
_action="REFRESH_GROUP"
state={fetcher.state}
>
Click here
</SubmitButton>
</fetcher.Form>
);
}
return (
<div
className={clsx("text-xs text-lighter ml-auto", {
invisible: !isMounted,
})}
>
{isMounted
? `Last updated at ${new Date(data.lastUpdated).toLocaleTimeString(
i18n.language
)}`
: "Placeholder"}
</div>
);
}
function Groups() {
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
const { width } = useWindowSize();
if (data.expiryStatus === "EXPIRED" || !isMounted) return null;
if (width < 750) return <MobileGroupCards />;
return <GroupCardColumns />;
}
function MobileGroupCards() {
const data = useLoaderData<typeof loader>();
const [tab, setTab] = React.useState<"received" | "neutral" | "given">(
"neutral"
);
const isFullGroup = data.groups.own.members!.length === FULL_GROUP_SIZE;
const groups =
tab === "received"
? data.groups.likesReceived
: tab === "given"
? data.groups.likesGiven
: data.groups.neutral;
return (
<div className="mt-6">
<Tabs compact>
<Tab active={tab === "received"} onClick={() => setTab("received")}>
Received ({data.groups.likesReceived.length})
</Tab>
<Tab active={tab === "neutral"} onClick={() => setTab("neutral")}>
Neutral ({data.groups.neutral.length})
</Tab>
<Tab active={tab === "given"} onClick={() => setTab("given")}>
Given ({data.groups.likesGiven.length})
</Tab>
</Tabs>
<div className="stack sm q__mobile-groups-container">
{groups.map((group) => {
const { mapListPreference } = groupAfterMorph({
liker: tab === "received" ? "THEM" : "US",
ourGroup: data.groups.own,
theirGroup: group,
});
const action =
tab === "neutral"
? "LIKE"
: tab === "given"
? "UNLIKE"
: isFullGroup
? "MATCH_UP"
: "GROUP_UP";
return (
<GroupCard
key={group.id}
group={group}
action={action}
mapListPreference={mapListPreference}
ownRole={data.role}
/>
);
})}
</div>
</div>
);
}
function GroupCardColumns() {
const data = useLoaderData<typeof loader>();
const isFullGroup = data.groups.own.members!.length === FULL_GROUP_SIZE;
return (
<Flipper
flipKey={`${data.groups.likesReceived
.map((g) => g.id)
.join("")}-${data.groups.neutral
.map((g) => g.id)
.join("")}-${data.groups.likesGiven.map((g) => g.id).join("")}`}
>
<div className="q__groups-container">
<div>
<h2 className="text-sm text-center mb-2">
{isFullGroup ? "Challenges received" : "Groups that asked you"}
</h2>
<div className="stack sm">
{data.groups.likesReceived.map((group) => {
const { mapListPreference } = groupAfterMorph({
liker: "THEM",
ourGroup: data.groups.own,
theirGroup: group,
});
return (
<GroupCard
key={group.id}
group={group}
action={isFullGroup ? "MATCH_UP" : "GROUP_UP"}
mapListPreference={mapListPreference}
ownRole={data.role}
/>
);
})}
</div>
</div>
<div className="w-full">
<h2 className="text-sm text-center mb-2 invisible">Neutral</h2>
<div className="stack sm">
{data.groups.neutral.map((group) => {
const { mapListPreference } = groupAfterMorph({
liker: "US",
ourGroup: data.groups.own,
theirGroup: group,
});
return (
<GroupCard
key={group.id}
group={group}
action="LIKE"
mapListPreference={mapListPreference}
ownRole={data.role}
/>
);
})}
</div>
</div>
<div>
<h2 className="text-sm text-center mb-2">
{isFullGroup ? "Challenges issued" : "Groups you asked"}
</h2>
<div className="stack sm">
{data.groups.likesGiven.map((group) => {
const { mapListPreference } = groupAfterMorph({
liker: "US",
ourGroup: data.groups.own,
theirGroup: group,
});
return (
<GroupCard
key={group.id}
group={group}
action="UNLIKE"
mapListPreference={mapListPreference}
ownRole={data.role}
/>
);
})}
</div>
</div>
</div>
</Flipper>
);
}

View File

@ -0,0 +1,982 @@
import type {
ActionArgs,
LinksFunction,
LoaderArgs,
SerializeFrom,
} from "@remix-run/node";
import { redirect } from "@remix-run/node";
import type { FetcherWithComponents } from "@remix-run/react";
import { Link, useFetcher, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { Flipped, Flipper } from "react-flip-toolkit";
import invariant from "tiny-invariant";
import { Avatar } from "~/components/Avatar";
import { Button } from "~/components/Button";
import { WeaponCombobox } from "~/components/Combobox";
import { ModeImage, StageImage, WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { ArchiveBoxIcon } from "~/components/icons/ArchiveBox";
import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows";
import { sql } from "~/db/sql";
import type { GroupMember, ReportedWeapon } from "~/db/types";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTranslation } from "~/hooks/useTranslation";
import { useUser } from "~/modules/auth";
import { requireUserId } from "~/modules/auth/user.server";
import type { MainWeaponId } from "~/modules/in-game-lists";
import { isAdmin } from "~/permissions";
import { databaseTimestampToDate } from "~/utils/dates";
import { animate } from "~/utils/flip";
import type { SendouRouteHandle } from "~/utils/remix";
import {
badRequestIfFalsy,
notFoundIfFalsy,
parseRequestFormData,
validate,
} from "~/utils/remix";
import type { Unpacked } from "~/utils/types";
import { assertUnreachable } from "~/utils/types";
import {
SENDOUQ_PAGE,
SENDOUQ_PREPARING_PAGE,
SENDOUQ_RULES_PAGE,
SENDOU_INK_DISCORD_URL,
navIconUrl,
teamPage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import { matchEndedAtIndex } from "../core/match";
import { compareMatchToReportedScores } from "../core/match.server";
import { calculateMatchSkills } from "../core/skills.server";
import { FULL_GROUP_SIZE, USER_SKILLS_CACHE_KEY } from "../q-constants";
import { matchSchema } from "../q-schemas.server";
import { matchIdFromParams, winnersArrayToWinner } from "../q-utils";
import styles from "../q.css";
import { addReportedWeapons } from "../queries/addReportedWeapons.server";
import { addSkills } from "../queries/addSkills.server";
import { createGroupFromPreviousGroup } from "../queries/createGroup.server";
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
import { findMatchById } from "../queries/findMatchById.server";
import type { GroupForMatch } from "../queries/groupForMatch.server";
import { groupForMatch } from "../queries/groupForMatch.server";
import { reportScore } from "../queries/reportScore.server";
import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server";
import { setGroupAsInactive } from "../queries/setGroupAsInactive.server";
import { deleteReporterWeaponsByMatchId } from "../queries/deleteReportedWeaponsByMatchId.server";
import { Divider } from "~/components/Divider";
import { cache } from "~/utils/cache.server";
import { Toggle } from "~/components/Toggle";
import { addMapResults } from "../queries/addMapResults.server";
import {
summarizeMaps,
summarizePlayerResults,
} from "../core/summarizer.server";
import { addPlayerResults } from "../queries/addPlayerResults.server";
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const handle: SendouRouteHandle = {
i18n: ["q", "tournament"],
breadcrumb: () => ({
imgPath: navIconUrl("sendouq"),
href: SENDOUQ_PAGE,
type: "IMAGE",
}),
};
export const action = async ({ request, params }: ActionArgs) => {
const matchId = matchIdFromParams(params);
const user = await requireUserId(request);
const data = await parseRequestFormData({
request,
schema: matchSchema,
});
switch (data._action) {
case "REPORT_SCORE": {
const match = notFoundIfFalsy(findMatchById(matchId));
validate(
!match.isLocked,
"Match has already been reported by both teams"
);
validate(
!data.adminReport || isAdmin(user),
"Only admins can report scores as admin"
);
const members = [
...groupForMatch(match.alphaGroupId)!.members.map((m) => ({
...m,
groupId: match.alphaGroupId,
})),
...groupForMatch(match.bravoGroupId)!.members.map((m) => ({
...m,
groupId: match.bravoGroupId,
})),
];
const groupMemberOfId = members.find((m) => m.id === user.id)?.groupId;
invariant(
groupMemberOfId || data.adminReport,
"User is not a manager of any group"
);
const winner = winnersArrayToWinner(data.winners);
const winnerTeamId =
winner === "ALPHA" ? match.alphaGroupId : match.bravoGroupId;
const loserTeamId =
winner === "ALPHA" ? match.bravoGroupId : match.alphaGroupId;
// when admin reports match gets locked right away
const compared = data.adminReport
? "SAME"
: compareMatchToReportedScores({
match,
winners: data.winners,
newReporterGroupId: groupMemberOfId!,
previousReporterGroupId: match.reportedByUserId
? members.find((m) => m.id === match.reportedByUserId)!.groupId
: undefined,
});
// same group reporting same score, probably by mistake
if (compared === "DUPLICATE") {
return null;
}
if (compared === "DIFFERENT") {
return { error: "different" as const };
}
const newSkills =
compared === "SAME"
? calculateMatchSkills({
groupMatchId: match.id,
winner: groupForMatch(winnerTeamId)!.members.map((m) => m.id),
loser: groupForMatch(loserTeamId)!.members.map((m) => m.id),
})
: null;
sql.transaction(() => {
reportScore({
matchId,
reportedByUserId: user.id,
winners: data.winners,
});
// own group gets set inactive
if (groupMemberOfId) setGroupAsInactive(groupMemberOfId);
// skills & map/player results only update after both teams have reported
if (newSkills) {
addMapResults(
summarizeMaps({ match, members, winners: data.winners })
);
addPlayerResults(
summarizePlayerResults({ match, members, winners: data.winners })
);
addSkills(newSkills);
cache.delete(USER_SKILLS_CACHE_KEY);
}
// fix edge case where they 1) report score 2) report weapons 3) report score again, but with different amount of maps played
if (compared === "FIX_PREVIOUS") {
deleteReporterWeaponsByMatchId(matchId);
}
// admin reporting, just set both groups inactive
if (data.adminReport) {
setGroupAsInactive(match.alphaGroupId);
setGroupAsInactive(match.bravoGroupId);
}
})();
break;
}
case "LOOK_AGAIN": {
const previousGroup = groupForMatch(data.previousGroupId);
validate(previousGroup, "Previous group not found");
for (const member of previousGroup.members) {
const currentGroup = findCurrentGroupByUserId(member.id);
validate(!currentGroup, "Member is already in a group");
if (member.id === user.id) {
validate(
member.role === "OWNER",
"You are not the owner of the group"
);
}
}
createGroupFromPreviousGroup({
previousGroupId: data.previousGroupId,
members: previousGroup.members.map((m) => ({ id: m.id, role: m.role })),
});
throw redirect(SENDOUQ_PREPARING_PAGE);
}
case "REPORT_WEAPONS": {
const match = notFoundIfFalsy(findMatchById(matchId));
validate(match.reportedAt, "Match has not been reported yet");
if (reportedWeaponsByMatchId(matchId) && !isAdmin(user)) {
return null;
}
const reportedMaps = match.mapList.reduce(
(acc, cur) => acc + (cur.winnerGroupId ? 1 : 0),
0
);
validate(
reportedMaps === data.weapons.length,
"Not reporting weapons for all maps"
);
const groupAlpha = badRequestIfFalsy(groupForMatch(match.alphaGroupId));
const groupBravo = badRequestIfFalsy(groupForMatch(match.bravoGroupId));
const users = [
...groupAlpha.members.map((m) => m.id),
...groupBravo.members.map((m) => m.id),
];
sql.transaction(() => {
deleteReporterWeaponsByMatchId(matchId);
addReportedWeapons(
match.mapList
.filter((m) => m.winnerGroupId)
.flatMap((matchMap, i) =>
data.weapons[i].map((weaponSplId, j) => ({
groupMatchMapId: matchMap.id,
weaponSplId: weaponSplId as MainWeaponId,
userId: users[j],
}))
)
);
})();
break;
}
default: {
assertUnreachable(data);
}
}
return null;
};
export const loader = ({ params }: LoaderArgs) => {
const matchId = matchIdFromParams(params);
const match = notFoundIfFalsy(findMatchById(matchId));
const groupAlpha = groupForMatch(match.alphaGroupId);
invariant(groupAlpha, "Group alpha not found");
const groupBravo = groupForMatch(match.bravoGroupId);
invariant(groupBravo, "Group bravo not found");
return {
match,
groupAlpha,
groupBravo,
reportedWeapons: match.reportedAt
? reportedWeaponsByMatchId(matchId)
: undefined,
};
};
export default function QMatchPage() {
const user = useUser();
const isMounted = useIsMounted();
const { i18n } = useTranslation();
const data = useLoaderData<typeof loader>();
const [showWeaponsForm, setShowWeaponsForm] = React.useState(false);
const submitScoreFetcher = useFetcher<typeof action>();
React.useEffect(() => {
setShowWeaponsForm(false);
}, [data.reportedWeapons]);
const ownMember =
data.groupAlpha.members.find((m) => m.id === user?.id) ??
data.groupBravo.members.find((m) => m.id === user?.id);
const canReportScore = Boolean(
!data.match.isLocked && (ownMember || isAdmin(user))
);
const ownGroup = data.groupAlpha.members.some((m) => m.id === user?.id)
? data.groupAlpha
: data.groupBravo.members.some((m) => m.id === user?.id)
? data.groupBravo
: null;
const ownTeamReported = Boolean(
data.match.reportedByUserId &&
ownGroup?.members.some((m) => m.id === data.match.reportedByUserId)
);
const showScore = data.match.isLocked || ownTeamReported;
return (
<Main className="q-match__container stack lg">
<div className="q-match__header">
<h2>Match #{data.match.id}</h2>
<div
className={clsx("text-xs text-lighter", {
invisible: !isMounted,
})}
>
{isMounted
? databaseTimestampToDate(data.match.createdAt).toLocaleString(
i18n.language,
{
day: "numeric",
month: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
}
)
: // reserve place
"0/0/0 0:00"}
</div>
</div>
{showScore ? (
<>
<Score reportedAt={data.match.reportedAt!} />
{ownGroup && ownMember && data.match.reportedAt ? (
<AfterMatchActions
ownGroupId={ownGroup.id}
role={ownMember.role}
reportedAt={data.match.reportedAt}
showWeaponsForm={showWeaponsForm}
setShowWeaponsForm={setShowWeaponsForm}
key={data.reportedWeapons?.map((w) => w.weaponSplId).join("")}
/>
) : null}
</>
) : null}
{!showWeaponsForm ? (
<>
<div className="q-match__teams-container">
<MatchGroup group={data.groupAlpha} side="ALPHA" />
<MatchGroup group={data.groupBravo} side="BRAVO" />
</div>
{!data.match.isLocked ? (
<div>
<div className="stack items-end">
<Link to={SENDOUQ_RULES_PAGE} className="text-xs font-bold">
Read the rules
</Link>
</div>
<div className="q-match__join-discord-section">
If needed, contact your opponent on the <b>#match-meetup</b>{" "}
channel of the sendou.ink Discord:{" "}
<a
href={SENDOU_INK_DISCORD_URL}
target="_blank"
rel="noreferrer"
>
{SENDOU_INK_DISCORD_URL}
</a>
. Alpha team hosts. Password should be{" "}
<b>{resolveRoomPass(data.match.id)}</b>. Pool code is{" "}
<b>SENDOUQ</b>
</div>
</div>
) : null}
<MapList
canReportScore={canReportScore}
isResubmission={ownTeamReported}
fetcher={submitScoreFetcher}
/>
{submitScoreFetcher.data?.error === "different" ? (
<div className="text-xs text-warning font-semi-bold text-center">
You reported different results than your opponent. Double check
the above is correct and otherwise contact the opponent to fix it
on their side.
</div>
) : null}
</>
) : null}
</Main>
);
}
function Score({ reportedAt }: { reportedAt: number }) {
const isMounted = useIsMounted();
const { i18n } = useTranslation();
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);
const score = data.match.mapList.reduce(
(acc, cur) => {
if (!cur.winnerGroupId) return acc;
if (cur.winnerGroupId === data.match.alphaGroupId) {
return [acc[0] + 1, acc[1]];
}
return [acc[0], acc[1] + 1];
},
[0, 0]
);
return (
<div className="stack items-center line-height-tight">
<div className="text-lg font-bold">{score.join(" - ")}</div>
{data.match.isLocked ? (
<div
className={clsx("text-xs text-lighter", { invisible: !isMounted })}
>
Reported by {reporter?.discordName ?? <b>admin</b>} at{" "}
{isMounted
? databaseTimestampToDate(reportedAt).toLocaleString(
i18n.language,
{
day: "numeric",
month: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
}
)
: ""}
</div>
) : (
<div className="text-xs text-lighter">
SP will be adjusted after opponent confirms the score
</div>
)}
</div>
);
}
function AfterMatchActions({
ownGroupId,
role,
reportedAt,
showWeaponsForm,
setShowWeaponsForm,
}: {
ownGroupId: number;
role: GroupMember["role"];
reportedAt: number;
showWeaponsForm: boolean;
setShowWeaponsForm: (show: boolean) => void;
}) {
const user = useUser();
const { t } = useTranslation(["game-misc"]);
const data = useLoaderData<typeof loader>();
const lookAgainFetcher = useFetcher();
const weaponsFetcher = useFetcher();
const playedMaps = data.match.mapList.filter((m) => m.winnerGroupId);
const weaponsUsageInitialValue = () => {
if (!data.reportedWeapons)
return playedMaps.map(() => new Array(FULL_GROUP_SIZE * 2).fill(null));
const result: MainWeaponId[][] = [];
const players = [...data.groupAlpha.members, ...data.groupBravo.members];
for (const matchMap of data.match.mapList.filter((m) => m.winnerGroupId)) {
result.push(
players.map((u) => {
const weaponSplId = data.reportedWeapons?.find(
(rw) => rw.groupMatchMapId === matchMap.id && rw.userId === u.id
)?.weaponSplId;
invariant(typeof weaponSplId === "number", "weaponSplId is null");
return weaponSplId;
})
);
}
return result;
};
const [weaponsUsage, setWeaponsUsage] = React.useState<
(null | MainWeaponId)[][]
>(weaponsUsageInitialValue());
const wasReportedInTheLastHour =
databaseTimestampToDate(reportedAt).getTime() > Date.now() - 3600 * 1000;
const showLookAgain = role === "OWNER" && wasReportedInTheLastHour;
const showWeaponsFormButton = isAdmin(user) || !data.reportedWeapons;
const winners = playedMaps.map((m) =>
m.winnerGroupId === data.match.alphaGroupId ? "ALPHA" : "BRAVO"
);
return (
<div className="stack lg">
<lookAgainFetcher.Form
method="post"
className="stack horizontal justify-center md flex-wrap"
>
<input type="hidden" name="previousGroupId" value={ownGroupId} />
{showLookAgain ? (
<SubmitButton
icon={<RefreshArrowsIcon />}
state={lookAgainFetcher.state}
_action="LOOK_AGAIN"
>
Look again with same group
</SubmitButton>
) : null}
{showWeaponsFormButton ? (
<Button
icon={<ArchiveBoxIcon />}
onClick={() => setShowWeaponsForm(!showWeaponsForm)}
>
Report used weapons
</Button>
) : null}
</lookAgainFetcher.Form>
{showWeaponsForm ? (
<weaponsFetcher.Form method="post" className="stack lg">
<input
type="hidden"
name="weapons"
value={JSON.stringify(weaponsUsage)}
/>
<div className="stack md w-max mx-auto">
{playedMaps.map((map, i) => {
return (
<div key={map.stageId} className="stack md">
<MapListMap
canReportScore={false}
i={i}
map={map}
winners={winners}
/>
{i !== 0 ? (
<Button
size="tiny"
variant="outlined"
className="self-center"
onClick={() => {
setWeaponsUsage((val) => {
const newVal = [...val];
newVal[i] = [...newVal[i - 1]];
return newVal;
});
}}
>
Copy weapons from above map
</Button>
) : null}
<div className="stack sm">
{[
...data.groupAlpha.members,
...data.groupBravo.members,
].map((m, j) => {
return (
<React.Fragment key={m.id}>
{j === 0 ? (
<Divider className="text-sm">Alpha</Divider>
) : null}
{j === FULL_GROUP_SIZE ? (
<Divider className="text-sm">Bravo</Divider>
) : null}
<div
key={m.id}
className="stack horizontal sm justify-between items-center"
>
<div className="stack sm horizontal">
<Avatar user={m} size="xxs" /> {m.discordName}
</div>
<WeaponCombobox
inputName="weapon"
value={weaponsUsage[i][j]}
onChange={(weapon) => {
if (!weapon) return;
setWeaponsUsage((val) => {
const newVal = [...val];
newVal[i] = [...newVal[i]];
newVal[i][j] = Number(
weapon.value
) as MainWeaponId;
return newVal;
});
}}
/>
</div>
</React.Fragment>
);
})}
</div>
</div>
);
})}
</div>
<div className="stack sm">
{weaponsUsage.map((match, i) => {
return (
<div key={i} className="stack xs">
<div className="text-sm font-semi-bold text-center">
{t(`game-misc:MODE_SHORT_${data.match.mapList[i].mode}`)}{" "}
{t(`game-misc:STAGE_${data.match.mapList[i].stageId}`)}
</div>
<div className="stack sm horizontal justify-center items-center">
{match.map((weapon, j) => {
return (
<>
{typeof weapon === "number" ? (
<WeaponImage
key={j}
weaponSplId={weapon}
variant="badge"
size={32}
/>
) : (
<span
className="text-lg font-bold text-center q-match__weapon-grid-item"
key={j}
>
?
</span>
)}
{j === 3 ? <div className="w-4" /> : null}
</>
);
})}
</div>
</div>
);
})}
</div>
{weaponsUsage.flat().some((val) => val === null) ? (
<div className="text-sm text-center text-warning font-semi-bold">
Report all weapons to submit
</div>
) : (
<div className="stack items-center">
<SubmitButton _action="REPORT_WEAPONS">
Report weapons
</SubmitButton>
</div>
)}
</weaponsFetcher.Form>
) : null}
</div>
);
}
function MatchGroup({
group,
side,
}: {
group: GroupForMatch;
side: "ALPHA" | "BRAVO";
}) {
return (
<div className="stack sm items-center">
<h3 className="text-lighter">{side}</h3>
<div className="stack sm q-match__members-container">
{group.team ? (
<Link
to={teamPage(group.team.customUrl)}
className="stack horizontal xs font-bold"
target="_blank"
>
{group.team.avatarUrl ? (
<Avatar
url={userSubmittedImage(group.team.avatarUrl)}
size="xxs"
/>
) : null}
{group.team.name}
</Link>
) : null}
{group.members.map((member) => (
<Link
key={member.discordId}
to={userPage(member)}
className="stack horizontal xs items-center"
target="_blank"
>
<Avatar size="xxs" user={member} />
<div className="text-sm text-main-forced font-body">
{member.discordName}
</div>
</Link>
))}
</div>
</div>
);
}
function MapList({
canReportScore,
isResubmission,
fetcher,
}: {
canReportScore: boolean;
isResubmission: boolean;
fetcher: FetcherWithComponents<any>;
}) {
const user = useUser();
const data = useLoaderData<typeof loader>();
const [adminToggleChecked, setAdminToggleChecked] = React.useState(false);
const previouslyReportedWinners = isResubmission
? data.match.mapList
.filter((m) => m.winnerGroupId)
.map((m) =>
m.winnerGroupId === data.groupAlpha.id ? "ALPHA" : "BRAVO"
)
: [];
const [winners, setWinners] = React.useState<("ALPHA" | "BRAVO")[]>(
previouslyReportedWinners
);
const newScoresAreDifferent =
!previouslyReportedWinners ||
previouslyReportedWinners.length !== winners.length ||
previouslyReportedWinners.some((w, i) => w !== winners[i]);
const scoreCanBeReported =
Boolean(matchEndedAtIndex(winners)) &&
!data.match.isLocked &&
newScoresAreDifferent;
const allMembers = [
...data.groupAlpha.members,
...data.groupBravo.members,
].map((m) => m.id);
return (
<fetcher.Form method="post">
<input type="hidden" name="winners" value={JSON.stringify(winners)} />
<Flipper flipKey={winners.join("")}>
<div className="stack md w-max mx-auto">
{data.match.mapList.map((map, i) => {
return (
<MapListMap
key={map.stageId}
canReportScore={canReportScore}
i={i}
map={map}
winners={winners}
setWinners={setWinners}
weapons={data.reportedWeapons
?.filter((w) => w.groupMatchMapId === map.id)
.sort(
(a, b) =>
allMembers.indexOf(a.userId) -
allMembers.indexOf(b.userId)
)}
/>
);
})}
</div>
</Flipper>
{scoreCanBeReported && isAdmin(user) ? (
<div className="stack sm horizontal items-center text-sm font-semi-bold">
<Toggle
name="adminReport"
checked={adminToggleChecked}
setChecked={setAdminToggleChecked}
/>
Report as admin
</div>
) : null}
{scoreCanBeReported ? (
<div className="stack md items-center mt-4">
<ResultSummary winners={winners} />
<SubmitButton _action="REPORT_SCORE" state={fetcher.state}>
{isResubmission ? "Submit adjusted scores" : "Submit scores"}
</SubmitButton>
</div>
) : null}
</fetcher.Form>
);
}
function MapListMap({
i,
map,
winners,
setWinners,
canReportScore,
weapons,
}: {
i: number;
map: Unpacked<SerializeFrom<typeof loader>["match"]["mapList"]>;
winners: ("ALPHA" | "BRAVO")[];
setWinners?: (winners: ("ALPHA" | "BRAVO")[]) => void;
canReportScore: boolean;
weapons?: ReportedWeapon[];
}) {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["game-misc", "tournament"]);
const pickInfo = (source: string) => {
if (source === "TIEBREAKER") return t("tournament:pickInfo.tiebreaker");
if (source === "BOTH") return t("tournament:pickInfo.both");
if (source === "DEFAULT") return t("tournament:pickInfo.default");
if (source === String(data.match.alphaGroupId)) {
return t("tournament:pickInfo.team.specific", {
team: "Alpha",
});
}
return t("tournament:pickInfo.team.specific", {
team: "Bravo",
});
};
const handleReportScore = (i: number, side: "ALPHA" | "BRAVO") => () => {
const newWinners = [...winners];
newWinners[i] = side;
// delete any scores that would have been after set ended (can happen when they go back to edit previously reported scores)
const matchEndedAt = matchEndedAtIndex(newWinners);
if (matchEndedAt) {
newWinners.splice(matchEndedAt + 1);
}
setWinners?.(newWinners);
};
const scoreCanBeReported =
Boolean(matchEndedAtIndex(winners)) && !data.match.isLocked;
const showWinnerReportRow = (i: number) => {
if (!canReportScore) return false;
if (i === 0) return true;
if (scoreCanBeReported && !winners[i]) return false;
const previous = winners[i - 1];
return Boolean(previous);
};
const winningInfoText = (winnerId: number | null) => {
if (!data.match.isLocked) return null;
if (!winnerId)
return (
<>
<i>Unplayed</i>
</>
);
const winner = winnerId === data.match.alphaGroupId ? "Alpha" : "Bravo";
return <> {winner} won</>;
};
return (
<div key={map.stageId} className="stack xs">
<Flipped flipId={map.stageId}>
<div className="stack sm horizontal items-center">
<StageImage stageId={map.stageId} width={64} className="rounded-sm" />
<div>
<div className="text-sm stack horizontal xs items-center">
{i + 1}) <ModeImage mode={map.mode} size={18} />{" "}
{t(`game-misc:STAGE_${map.stageId}`)}
</div>
<div className="text-lighter text-xs">
{pickInfo(map.source)} {winningInfoText(map.winnerGroupId)}
</div>
</div>
</div>
</Flipped>
{weapons ? (
<div className="stack sm horizontal">
{weapons.map((w, i) => {
return (
<React.Fragment key={w.userId}>
<WeaponImage
weaponSplId={w.weaponSplId}
size={30}
variant="badge"
/>
{i === 3 ? <div className="w-4" /> : null}
</React.Fragment>
);
})}
</div>
) : null}
{showWinnerReportRow(i) ? (
<Flipped
flipId={`${map.stageId}-report`}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onAppear={async (el: HTMLElement) => {
await animate(el, [{ opacity: 0 }, { opacity: 1 }], {
duration: 300,
});
el.style.opacity = "1";
}}
>
<div className="stack horizontal sm text-xs">
<label className="mb-0 text-theme-secondary">Winner</label>
<div className="stack sm horizontal items-center font-semi-bold">
<input
type="radio"
name={`winner-${i}`}
value="alpha"
id={`alpha-${i}`}
checked={winners[i] === "ALPHA"}
onChange={handleReportScore(i, "ALPHA")}
/>
<label className="mb-0" htmlFor={`alpha-${i}`}>
Alpha
</label>
</div>
<div className="stack sm horizontal items-center font-semi-bold">
<input
type="radio"
name={`winner-${i}`}
value="bravo"
id={`bravo-${i}`}
checked={winners[i] === "BRAVO"}
onChange={handleReportScore(i, "BRAVO")}
/>
<label className="mb-0" htmlFor={`bravo-${i}`}>
Bravo
</label>
</div>
</div>
</Flipped>
) : null}
</div>
);
}
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)
? "ALPHA"
: "BRAVO";
const score = winners.reduce(
(acc, cur) => {
if (cur === "ALPHA") {
return [acc[0] + 1, acc[1]];
}
return [acc[0], acc[1] + 1];
},
[0, 0]
);
const userWon =
ownSide === "ALPHA" ? score[0] > score[1] : score[0] < score[1];
return (
<div
className={clsx("text-sm font-semi-bold", {
"text-success": userWon,
"text-warning": !userWon,
})}
>
Reporting {score.join("-")} {userWon ? "win" : "loss"}
</div>
);
}

View File

@ -0,0 +1,158 @@
import type {
ActionFunction,
LinksFunction,
LoaderArgs,
V2_MetaFunction,
} from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { useFetcher, useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { getUser, requireUser } from "~/modules/auth/user.server";
import type { SendouRouteHandle } from "~/utils/remix";
import { parseRequestFormData, validate } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import {
SENDOUQ_LOOKING_PAGE,
SENDOUQ_PREPARING_PAGE,
navIconUrl,
} from "~/utils/urls";
import { GroupCard } from "../components/GroupCard";
import { MemberAdder } from "../components/MemberAdder";
import { hasGroupManagerPerms } from "../core/groups";
import { FULL_GROUP_SIZE } from "../q-constants";
import { preparingSchema } from "../q-schemas.server";
import { groupRedirectLocationByCurrentLocation } from "../q-utils";
import styles from "../q.css";
import { addMember } from "../queries/addMember.server";
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
import { findPreparingGroup } from "../queries/findPreparingGroup.server";
import { groupForMatch } from "../queries/groupForMatch.server";
import { refreshGroup } from "../queries/refreshGroup.server";
import { setGroupAsActive } from "../queries/setGroupAsActive.server";
import { trustedPlayersAvailableToPlay } from "../queries/usersInActiveGroup.server";
export const handle: SendouRouteHandle = {
i18n: ["q"],
breadcrumb: () => ({
imgPath: navIconUrl("sendouq"),
href: SENDOUQ_PREPARING_PAGE,
type: "IMAGE",
}),
};
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const meta: V2_MetaFunction = () => {
return [{ title: makeTitle("SendouQ") }];
};
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
const data = await parseRequestFormData({
request,
schema: preparingSchema,
});
const currentGroup = findCurrentGroupByUserId(user.id);
validate(currentGroup, "No group found");
validate(hasGroupManagerPerms(currentGroup.role), "Can't manage group");
switch (data._action) {
case "JOIN_QUEUE": {
validate(currentGroup.status === "PREPARING", "No group preparing");
setGroupAsActive(currentGroup.id);
refreshGroup(currentGroup.id);
return redirect(SENDOUQ_LOOKING_PAGE);
}
case "ADD_TRUSTED": {
const available = trustedPlayersAvailableToPlay(user);
validate(
available.some((u) => u.id === data.id),
"Player not available to play"
);
const ownGroupWithMembers = groupForMatch(currentGroup.id);
invariant(ownGroupWithMembers, "No own group found");
validate(
ownGroupWithMembers.members.length < FULL_GROUP_SIZE,
"Group is full"
);
addMember({
groupId: currentGroup.id,
userId: data.id,
});
return null;
}
default: {
assertUnreachable(data);
}
}
};
export const loader = async ({ request }: LoaderArgs) => {
const user = await getUser(request);
const currentGroup = user ? findCurrentGroupByUserId(user.id) : undefined;
const redirectLocation = groupRedirectLocationByCurrentLocation({
group: currentGroup,
currentLocation: "preparing",
});
if (redirectLocation) {
throw redirect(redirectLocation);
}
const ownGroup = findPreparingGroup(currentGroup!.id);
invariant(ownGroup, "No own group found");
return {
group: ownGroup,
role: currentGroup!.role,
trustedPlayers: hasGroupManagerPerms(currentGroup!.role)
? trustedPlayersAvailableToPlay(user!)
: [],
};
};
export default function QPreparingPage() {
const data = useLoaderData<typeof loader>();
const joinQFetcher = useFetcher();
return (
<Main className="stack lg items-center">
<div className="q-preparing__card-container">
<GroupCard
group={data.group}
mapListPreference={data.group.mapListPreference}
ownRole={data.role}
ownGroup
/>
</div>
{data.group.members.length < FULL_GROUP_SIZE &&
hasGroupManagerPerms(data.role) ? (
<MemberAdder
inviteCode={data.group.inviteCode}
trustedPlayers={data.trustedPlayers}
/>
) : null}
<joinQFetcher.Form method="post">
<SubmitButton
size="big"
state={joinQFetcher.state}
_action="JOIN_QUEUE"
>
Join the queue
</SubmitButton>
</joinQFetcher.Form>
</Main>
);
}

View File

@ -0,0 +1,89 @@
import type { V2_MetaFunction } from "@remix-run/react";
import { Main } from "~/components/Main";
import { makeTitle } from "~/utils/strings";
export const meta: V2_MetaFunction = () => {
return [{ title: makeTitle("SendouQ Rules") }];
};
export default function SendouqRules() {
return (
<Main>
<h1>SendouQ Rules</h1>
<h2 className="text-lg">Disconnections</h2>
<div>
Each team is allowed one replay per set due to a team member
disconnecting. Replay is only possible if ALL of the following are true:
<ol>
<li>
More than half was left in the clock (the clock was 2:30 or higher
at the time of the DC)
</li>
<li>
The team without DC&apos;s objective counter was at 30 or higher at
the time of the disconnect
</li>
<li>Team with the disconnection stopped playing without delay</li>
<li>Disconnection was unintentional</li>
</ol>
For the replay same weapons and gear must be used by both teams. The
team who fails to do so loses the map. If players disconnect from both
teams a replay can be played without using either team&apos;s one replay
for the set (or even if there were no replays left to use from either
team). Host disconnection can be replayed with the same conditions as
above.
</div>
<h2 className="text-lg mt-4">Subs</h2>
<div>
There are no subs. If a player is unavailable to play from either team
then the set must be played with 3 players or forfeited.
</div>
<h2 className="text-lg mt-4">Alting</h2>
<div>You can only play with one account.</div>
<h2 className="text-lg mt-4">Player eligibility</h2>
<div>
Players banned by{" "}
<a href="https://twitter.com/splatsafety">
Splatoon Competitive Community Safety
</a>{" "}
are not allowed to participate. Playing with banned players is not
allowed.
</div>
<h2 className="text-lg mt-4">Time limits</h2>
<div>
After a team has all their members in the lobby and has shared the
password with the other team then that team has <b>15 minutes</b> to
join the lobby. Failing to do so, the match can be started with the
members currently in the room. If a player has problems connecting to
the room it is advised to try switching the host.
</div>
<h2 className="text-lg mt-4">Spectators</h2>
<div>There can be spectators if both teams agree to it.</div>
<h2 className="text-lg mt-4">Intentional losing</h2>
<div>
Players are not allowed to intentionally lose a match. This includes
(but is not limited to) tanking your own rank on purpose or boosting
another player&apos;s/team&apos;s ranking.
</div>
<h2 className="text-lg mt-4">Discriminatory language</h2>
<div>
Any kind of discriminatory language such as using slurs is strictly not
allowed. This rule applies everywhere in SendouQ including (but not
limited to) text chats, voice chats & in-game names.
</div>
<h2 className="text-lg mt-4">Repercussions</h2>
<div>
Players found breaking the rules can lose access to SendouQ and other
sendou.ink features such as tournaments and the Plus Server.
</div>
</Main>
);
}

View File

@ -0,0 +1,688 @@
import { Flag } from "~/components/Flag";
import { Main } from "~/components/Main";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import styles from "../q.css";
import { redirect } from "@remix-run/node";
import type {
LoaderArgs,
ActionFunction,
LinksFunction,
V2_MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import { Link, useFetcher, useLoaderData } from "@remix-run/react";
import { useTranslation } from "~/hooks/useTranslation";
import {
FULL_GROUP_SIZE,
JOIN_CODE_SEARCH_PARAM_KEY,
MAP_LIST_PREFERENCE_OPTIONS,
SENDOUQ,
} from "../q-constants";
import {
parseRequestFormData,
validate,
type SendouRouteHandle,
} from "~/utils/remix";
import { Image, ModeImage } from "~/components/Image";
import * as React from "react";
import { useIsMounted } from "~/hooks/useIsMounted";
import clsx from "clsx";
import {
LOG_IN_URL,
SENDOUQ_LOOKING_PAGE,
SENDOUQ_PAGE,
SENDOUQ_PREPARING_PAGE,
SENDOUQ_RULES_PAGE,
SENDOUQ_YOUTUBE_VIDEO,
navIconUrl,
stageImageUrl,
} from "~/utils/urls";
import { stageIds } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { MapPool } from "~/modules/map-pool-serializer";
import { SubmitButton } from "~/components/SubmitButton";
import { getUserId, requireUserId } from "~/modules/auth/user.server";
import { frontPageSchema } from "../q-schemas.server";
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
import { createGroup } from "../queries/createGroup.server";
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
import { groupRedirectLocationByCurrentLocation, mapPoolOk } from "../q-utils";
import { ModePreferenceIcons } from "../components/ModePrefenceIcons";
import { makeTitle } from "~/utils/strings";
import { currentSeason } from "~/features/mmr";
import type { RankingSeason } from "~/features/mmr/season";
import { nextSeason } from "~/features/mmr/season";
import { useUser } from "~/modules/auth";
import { Button } from "~/components/Button";
import { findTeamByInviteCode } from "../queries/findTeamByInviteCode.server";
import { Alert } from "~/components/Alert";
import { Dialog } from "~/components/Dialog";
import { joinListToNaturalString } from "~/utils/arrays";
import { assertUnreachable } from "~/utils/types";
import { addMember } from "../queries/addMember.server";
import { userHasSkill } from "../queries/userHasSkill.server";
import { FormMessage } from "~/components/FormMessage";
import { addInitialSkill } from "../queries/addInitialSkill.server";
import {
DEFAULT_SKILL_HIGH,
DEFAULT_SKILL_LOW,
DEFAULT_SKILL_MID,
} from "~/features/mmr/mmr-constants";
export const handle: SendouRouteHandle = {
i18n: ["q"],
breadcrumb: () => ({
imgPath: navIconUrl("sendouq"),
href: SENDOUQ_PAGE,
type: "IMAGE",
}),
};
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const meta: V2_MetaFunction = () => {
return [
{ title: makeTitle("SendouQ") },
{
name: "description",
content:
"Splatoon 3 competitive ladder. Join by yourself or with your team and play ranked matches.",
},
];
};
export const action: ActionFunction = async ({ request }) => {
const user = await requireUserId(request);
const data = await parseRequestFormData({
request,
schema: frontPageSchema,
});
const season = currentSeason(new Date());
validate(!findCurrentGroupByUserId(user.id), "Already in a group");
validate(season, "Season is not active");
switch (data._action) {
case "JOIN_QUEUE": {
const mapPool = new MapPool(data.mapPool);
validate(mapPoolOk(mapPool), "Invalid map pool");
createGroup({
mapListPreference: data.mapListPreference,
status: data.direct === "true" ? "ACTIVE" : "PREPARING",
userId: user.id,
mapPool,
});
return redirect(
data.direct === "true" ? SENDOUQ_LOOKING_PAGE : SENDOUQ_PREPARING_PAGE
);
}
case "JOIN_TEAM": {
const code = new URL(request.url).searchParams.get(
JOIN_CODE_SEARCH_PARAM_KEY
);
const teamInvitedTo =
code && user ? findTeamByInviteCode(code) : undefined;
validate(teamInvitedTo, "Invite code doesn't match any active team");
validate(teamInvitedTo.members.length < FULL_GROUP_SIZE, "Team is full");
addMember({
groupId: teamInvitedTo.id,
userId: user.id,
});
return redirect(
teamInvitedTo.status === "PREPARING"
? SENDOUQ_PREPARING_PAGE
: SENDOUQ_LOOKING_PAGE
);
}
case "SET_INITIAL_SP": {
validate(
!userHasSkill({ userId: user.id, season: season.nth }),
"Already set initial SP"
);
const defaultSkill =
data.tier === "higher"
? DEFAULT_SKILL_HIGH
: data.tier === "default"
? DEFAULT_SKILL_MID
: DEFAULT_SKILL_LOW;
addInitialSkill({
mu: defaultSkill.mu,
season: season.nth,
sigma: defaultSkill.sigma,
userId: user.id,
});
return null;
}
default: {
assertUnreachable(data);
}
}
};
export const loader = async ({ request }: LoaderArgs) => {
const user = await getUserId(request);
const redirectLocation = groupRedirectLocationByCurrentLocation({
group: user ? findCurrentGroupByUserId(user.id) : undefined,
currentLocation: "default",
});
if (redirectLocation) {
throw redirect(redirectLocation);
}
const code = new URL(request.url).searchParams.get(
JOIN_CODE_SEARCH_PARAM_KEY
);
const teamInvitedTo = code && user ? findTeamByInviteCode(code) : undefined;
const now = new Date();
const season = currentSeason(now);
const upcomingSeason = nextSeason(now);
return {
hasSkill: season
? userHasSkill({ userId: user!.id, season: season.nth })
: null,
season,
upcomingSeason,
teamInvitedTo,
};
};
export default function QPage() {
const [dialogOpen, setDialogOpen] = React.useState(true);
const user = useUser();
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
return (
<Main halfWidth className="stack lg">
<div className="stack sm">
<Clocks />
<a
href={SENDOUQ_YOUTUBE_VIDEO}
target="_blank"
rel="noreferrer"
className="text-xs font-bold text-center"
>
Watch how-to video (YouTube)
</a>
</div>
{data.upcomingSeason ? (
<UpcomingSeasonInfo season={data.upcomingSeason} />
) : null}
{data.season ? (
<>
{data.hasSkill && data.teamInvitedTo === null ? (
<Alert variation="WARNING">
Invite code doesn&apos;t match any active team
</Alert>
) : null}
{data.teamInvitedTo &&
data.teamInvitedTo.members.length < FULL_GROUP_SIZE &&
data.hasSkill ? (
<JoinTeamDialog
open={dialogOpen}
close={() => setDialogOpen(false)}
members={data.teamInvitedTo.members}
/>
) : null}
{!data.hasSkill && user ? <StartRank /> : null}
{user && data.hasSkill ? (
<>
<fetcher.Form className="stack md" method="post">
<input type="hidden" name="_action" value="JOIN_QUEUE" />
<div>
<div className="stack horizontal items-center justify-between">
<h2 className="q__header">Join the queue!</h2>
<Link to={SENDOUQ_RULES_PAGE} className="text-xs font-bold">
Rules
</Link>
</div>
<ActiveSeasonInfo season={data.season} />
</div>
<MapPreference />
<MapPoolSelector />
<div className="stack md items-center mt-4">
<SubmitButton>Add team members</SubmitButton>
<div className="text-lighter text-xs text-center">
No team members in mind yet? <br />
<SubmitButton
variant="minimal"
className="text-xs mx-auto"
name="direct"
value="true"
state={fetcher.state}
>
Join the queue directly.
</SubmitButton>
</div>
</div>
</fetcher.Form>
</>
) : null}
{!user ? (
<form
className="stack items-center"
action={LOG_IN_URL}
method="post"
>
<Button size="big" type="submit">
Log in to join SendouQ
</Button>
</form>
) : null}
</>
) : null}
</Main>
);
}
const countries = [
{
id: 1,
countryCode: "US",
timeZone: "America/Los_Angeles",
city: "Los Angeles",
},
{ id: 2, countryCode: "US", timeZone: "America/New_York", city: "New York" },
{ id: 3, countryCode: "FR", timeZone: "Europe/Paris", city: "Paris" },
{ id: 4, countryCode: "JP", timeZone: "Asia/Tokyo", city: "Tokyo" },
] as const;
const weekdayFormatter = ({
timeZone,
locale,
}: {
timeZone: string;
locale: string;
}) =>
new Intl.DateTimeFormat([locale], {
timeZone,
weekday: "long",
});
const clockFormatter = ({
timeZone,
locale,
}: {
timeZone: string;
locale: string;
}) =>
new Intl.DateTimeFormat([locale], {
timeZone,
hour: "numeric",
minute: "numeric",
});
function Clocks() {
const isMounted = useIsMounted();
const { i18n } = useTranslation();
useAutoRerender();
return (
<div className="q__clocks-container">
{countries.map((country) => {
return (
<div key={country.id} className="q__clock">
<div className="q__clock-country">{country.city}</div>
<Flag countryCode={country.countryCode} />
<div className={clsx({ invisible: !isMounted })}>
{isMounted
? weekdayFormatter({
timeZone: country.timeZone,
locale: i18n.language,
}).format(new Date())
: // take space
"Monday"}
</div>
<div className={clsx({ invisible: !isMounted })}>
{isMounted
? clockFormatter({
timeZone: country.timeZone,
locale: i18n.language,
}).format(new Date())
: // take space
"0:00 PM"}
</div>
</div>
);
})}
</div>
);
}
function JoinTeamDialog({
open,
close,
members,
}: {
open: boolean;
close: () => void;
members: string[];
}) {
const fetcher = useFetcher();
return (
<Dialog
isOpen={open}
close={close}
closeOnAnyClick={false}
className="text-center"
>
Join group with {joinListToNaturalString(members)}?
<fetcher.Form
className="stack horizontal justify-center sm mt-4"
method="post"
>
<SubmitButton _action="JOIN_TEAM" state={fetcher.state}>
Join
</SubmitButton>
<Button onClick={close} variant="destructive">
No thanks
</Button>
</fetcher.Form>
</Dialog>
);
}
function ActiveSeasonInfo({
season,
}: {
season: SerializeFrom<RankingSeason>;
}) {
const isMounted = useIsMounted();
const starts = new Date(season.starts);
const ends = new Date(season.ends);
const dateToString = (date: Date) =>
date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
return (
<div
className={clsx("text-lighter text-xs", {
invisible: !isMounted,
})}
>
Season {season.nth} open{" "}
{isMounted ? (
<b>
{dateToString(starts)} - {dateToString(ends)}
</b>
) : null}
</div>
);
}
function UpcomingSeasonInfo({
season,
}: {
season: SerializeFrom<RankingSeason>;
}) {
const isMounted = useIsMounted();
if (!isMounted) return null;
const starts = new Date(season.starts);
const dateToString = (date: Date) =>
date.toLocaleString("en-US", {
month: "long",
day: "numeric",
hour: "numeric",
});
return (
<div className="font-semi-bold text-center text-sm">
It&apos;s off-season!
<br />
Join Season {season.nth} starting {dateToString(starts)}
</div>
);
}
function StartRank() {
const fetcher = useFetcher();
return (
<fetcher.Form method="post" className="stack md items-start">
<div>
<label>Starting rank</label>
{["higher", "default", "lower"].map((tier) => {
return (
<div key={tier} className="stack sm horizontal items-center">
<input
type="radio"
name="tier"
id={tier}
value={tier}
defaultChecked={tier === "default"}
/>
<label htmlFor={tier} className="mb-0 text-capitalize">
{tier}
</label>
</div>
);
})}
<FormMessage type="info">
Decides your starting SP (MMR). &quot;Higher&quot; is recommended for
Plus Server level players. &quot;Lower&quot; for Low Ink eligible
players. &quot;Default&quot; for everyone else.
</FormMessage>
<FormMessage type="info" className="font-bold">
Setting initial SP is mandatory before you can join SendouQ.
</FormMessage>
</div>
<SubmitButton _action="SET_INITIAL_SP" state={fetcher.state}>
Submit
</SubmitButton>
</fetcher.Form>
);
}
const MAP_PREFERENCE_LOCAL_STORAGE_KEY = "q_mapPreference";
function MapPreference() {
const [value, setValue] = React.useState<string | null>(null);
const { t } = useTranslation(["q"]);
React.useEffect(() => {
const storedValue = localStorage.getItem(MAP_PREFERENCE_LOCAL_STORAGE_KEY);
if (storedValue) {
setValue(storedValue);
} else {
setValue("NO_PREFERENCE");
}
}, []);
return (
<div className="stack">
<label>Maplist preference</label>
{MAP_LIST_PREFERENCE_OPTIONS.map((option) => {
return (
<div key={option} className="stack sm horizontal items-center">
<input
type="radio"
name="mapListPreference"
id={option}
value={option}
checked={value === option}
onChange={() => {
setValue(option);
localStorage.setItem(MAP_PREFERENCE_LOCAL_STORAGE_KEY, option);
}}
/>
<label htmlFor={option} className="q__map-preference-label">
<ModePreferenceIcons preference={option} />
{t(`q:mapListPreference.${option}`)}
</label>
</div>
);
})}
{value === "SZ_ONLY" || value === "ALL_MODES_ONLY" ? (
<div className="text-xs text-lighter mt-2">
{t("q:mapListPreference.note", {
optionOne:
value === "SZ_ONLY"
? t("q:mapListPreference.ALL_MODES_ONLY")
: t("q:mapListPreference.SZ_ONLY"),
optionTwo:
value === "SZ_ONLY"
? t("q:mapListPreference.PREFER_SZ")
: t("q:mapListPreference.PREFER_ALL_MODES"),
})}
</div>
) : null}
</div>
);
}
const MAP_POOL_LOCAL_STORAGE_KEY = "q_mapPool";
function MapPoolSelector() {
const { t } = useTranslation(["game-misc"]);
const [mapPool, setMapPool] = React.useState<MapPool>(new MapPool([]));
React.useEffect(() => {
try {
const mapPool = localStorage.getItem(MAP_POOL_LOCAL_STORAGE_KEY);
if (mapPool) {
setMapPool(new MapPool(JSON.parse(mapPool)));
}
} catch (e) {
console.error(e);
}
}, []);
return (
<div className="q__map-pool-grid">
<RequiredHiddenInput
value={mapPool.serialized}
isValid={mapPoolOk(mapPool)}
name="mapPool"
/>
<div />
<div />
{rankedModesShort.map((modeShort) => {
return <ModeImage key={modeShort} mode={modeShort} size={22} />;
})}
<div />
{stageIds.map((stageId) => {
return (
<React.Fragment key={stageId}>
<div>
<Image
alt=""
path={stageImageUrl(stageId)}
width={32}
height={18}
className="q__map-pool-grid__stage-image"
/>
</div>
<div>{t(`game-misc:STAGE_${stageId}`)}</div>
{rankedModesShort.map((modeShort) => {
const id = `${stageId}-${modeShort}`;
return (
<input
key={id}
type="checkbox"
id={id}
checked={mapPool.has({ stageId, mode: modeShort })}
onChange={(e) => {
const checked = e.target.checked;
setMapPool((prev) => {
let newMapPool: MapPool;
if (checked) {
newMapPool = new MapPool([
...prev.stageModePairs,
{ stageId, mode: modeShort },
]);
} else {
newMapPool = new MapPool([
...prev.stageModePairs.filter(
(pair) =>
pair.stageId !== stageId ||
pair.mode !== modeShort
),
]);
}
localStorage.setItem(
MAP_POOL_LOCAL_STORAGE_KEY,
JSON.stringify(newMapPool.stageModePairs)
);
return newMapPool;
});
}}
/>
);
})}
<div
className={clsx("text-warning", {
invisible:
mapPool.stageModePairs.filter((p) => p.stageId === stageId)
.length <= SENDOUQ.MAX_STAGE_REPEAT_COUNT,
})}
>
max {SENDOUQ.MAX_STAGE_REPEAT_COUNT}
</div>
</React.Fragment>
);
})}
<div />
<div />
<div
className={clsx({
"text-warning": mapPool.countMapsByMode("SZ") > SENDOUQ.SZ_MAP_COUNT,
"text-success":
mapPool.countMapsByMode("SZ") === SENDOUQ.SZ_MAP_COUNT,
})}
>
{mapPool.countMapsByMode("SZ")}/{SENDOUQ.SZ_MAP_COUNT}
</div>
<div
className={clsx({
"text-warning":
mapPool.countMapsByMode("TC") > SENDOUQ.OTHER_MODE_MAP_COUNT,
"text-success":
mapPool.countMapsByMode("TC") === SENDOUQ.OTHER_MODE_MAP_COUNT,
})}
>
{mapPool.countMapsByMode("TC")}/{SENDOUQ.OTHER_MODE_MAP_COUNT}
</div>
<div
className={clsx({
"text-warning":
mapPool.countMapsByMode("RM") > SENDOUQ.OTHER_MODE_MAP_COUNT,
"text-success":
mapPool.countMapsByMode("RM") === SENDOUQ.OTHER_MODE_MAP_COUNT,
})}
>
{mapPool.countMapsByMode("RM")}/{SENDOUQ.OTHER_MODE_MAP_COUNT}
</div>
<div
className={clsx({
"text-warning":
mapPool.countMapsByMode("CB") > SENDOUQ.OTHER_MODE_MAP_COUNT,
"text-success":
mapPool.countMapsByMode("CB") === SENDOUQ.OTHER_MODE_MAP_COUNT,
})}
>
{mapPool.countMapsByMode("CB")}/{SENDOUQ.OTHER_MODE_MAP_COUNT}
</div>
<div />
</div>
);
}

View File

@ -11,6 +11,21 @@
text-align: right;
}
.placements__table__name {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 160px;
}
.placements__tier-header {
display: flex;
align-items: center;
gap: var(--s-2);
margin-block: var(--s-2);
color: var(--text-lighter);
}
.placements__table__row {
background-color: var(--bg-lighter);
display: flex;

View File

@ -10,16 +10,17 @@ import invariant from "tiny-invariant";
import { removeDuplicates } from "~/utils/arrays";
import type { FinalStanding } from "./finalStandings.server";
import type { Rating } from "openskill/dist/types";
// hacky workaround to stop db from being imported in tests
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { rate, userIdsToIdentifier } from "~/features/mmr/mmr-utils";
import shuffle from "just-shuffle";
import type { Unpacked } from "~/utils/types";
export interface TournamentSummary {
skills: Omit<Skill, "tournamentId" | "id" | "ordinal">[];
mapResultDeltas: MapResult[];
playerResultDeltas: PlayerResult[];
skills: Omit<
Skill,
"tournamentId" | "id" | "ordinal" | "season" | "groupMatchId"
>[];
mapResultDeltas: Omit<MapResult, "season">[];
playerResultDeltas: Omit<PlayerResult, "season">[];
tournamentResults: Omit<TournamentResult, "tournamentId" | "isHighlight">[];
}
@ -44,12 +45,14 @@ export function tournamentSummary({
teams,
finalStandings,
queryCurrentTeamRating,
queryTeamPlayerRatingAverage,
queryCurrentUserRating,
}: {
results: AllMatchResult[];
teams: TeamsArg;
finalStandings: FinalStandingsArg;
queryCurrentTeamRating: (identifier: string) => Rating;
queryTeamPlayerRatingAverage: (identifier: string) => Rating;
queryCurrentUserRating: (userId: number) => Rating;
}): TournamentSummary {
const userIdsToTeamId = userIdsToTeamIdRecord(teams);
@ -60,6 +63,7 @@ export function tournamentSummary({
userIdsToTeamId,
queryCurrentTeamRating,
queryCurrentUserRating,
queryTeamPlayerRatingAverage,
}),
mapResultDeltas: mapResultDeltas({ results, userIdsToTeamId }),
playerResultDeltas: playerResultDeltas({ results, userIdsToTeamId }),
@ -86,6 +90,7 @@ function skills(args: {
results: AllMatchResult[];
userIdsToTeamId: UserIdToTeamId;
queryCurrentTeamRating: (identifier: string) => Rating;
queryTeamPlayerRatingAverage: (identifier: string) => Rating;
queryCurrentUserRating: (userId: number) => Rating;
}) {
const result: TournamentSummary["skills"] = [];
@ -168,10 +173,12 @@ function calculateTeamSkills({
results,
userIdsToTeamId,
queryCurrentTeamRating,
queryTeamPlayerRatingAverage,
}: {
results: AllMatchResult[];
userIdsToTeamId: UserIdToTeamId;
queryCurrentTeamRating: (identifier: string) => Rating;
queryTeamPlayerRatingAverage: (identifier: string) => Rating;
}) {
const teamRatings = new Map<string, Rating>();
const teamMatchesCount = new Map<string, number>();
@ -206,10 +213,16 @@ function calculateTeamSkills({
});
const loserTeamIdentifier = selectMostPopular(loserTeamIdentifiers);
const [[ratedWinner], [ratedLoser]] = rate([
[getTeamRating(winnerTeamIdentifier)],
[getTeamRating(loserTeamIdentifier)],
]);
const [[ratedWinner], [ratedLoser]] = rate(
[
[getTeamRating(winnerTeamIdentifier)],
[getTeamRating(loserTeamIdentifier)],
],
[
[queryTeamPlayerRatingAverage(winnerTeamIdentifier)],
[queryTeamPlayerRatingAverage(loserTeamIdentifier)],
]
);
teamRatings.set(winnerTeamIdentifier, ratedWinner);
teamRatings.set(loserTeamIdentifier, ratedLoser);
@ -268,8 +281,8 @@ function mapResultDeltas({
}: {
results: AllMatchResult[];
userIdsToTeamId: UserIdToTeamId;
}): MapResult[] {
const result: MapResult[] = [];
}): TournamentSummary["mapResultDeltas"] {
const result: TournamentSummary["mapResultDeltas"] = [];
const addMapResult = (
mapResult: Pick<MapResult, "stageId" | "mode" | "userId"> & {
@ -324,10 +337,12 @@ function playerResultDeltas({
}: {
results: AllMatchResult[];
userIdsToTeamId: UserIdToTeamId;
}): PlayerResult[] {
const result: PlayerResult[] = [];
}): TournamentSummary["playerResultDeltas"] {
const result: TournamentSummary["playerResultDeltas"] = [];
const addPlayerResult = (playerResult: PlayerResult) => {
const addPlayerResult = (
playerResult: TournamentSummary["playerResultDeltas"][number]
) => {
const existingResult = result.find(
(r) =>
r.type === playerResult.type &&

View File

@ -102,6 +102,7 @@ function summarize({ results }: { results?: AllMatchResult[] } = {}) {
],
queryCurrentTeamRating: () => rating(),
queryCurrentUserRating: () => rating(),
queryTeamPlayerRatingAverage: () => rating(),
});
}

View File

@ -2,6 +2,7 @@ import { sql } from "~/db/sql";
import type { TournamentSummary } from "../core/summarizer.server";
import { ordinal } from "openskill";
import type { Skill } from "~/db/types";
import { identifierToUserIds } from "~/features/mmr/mmr-utils.server";
const addSkillStm = sql.prepare(/* sql */ `
insert into "Skill" (
@ -11,7 +12,8 @@ const addSkillStm = sql.prepare(/* sql */ `
"ordinal",
"userId",
"identifier",
"matchesCount"
"matchesCount",
"season"
)
values (
@tournamentId,
@ -20,7 +22,8 @@ const addSkillStm = sql.prepare(/* sql */ `
@ordinal,
@userId,
@identifier,
@matchesCount + coalesce((select max("matchesCount") from "Skill" where "userId" = @userId or "identifier" = @identifier group by "userId", "identifier"), 0)
@matchesCount + coalesce((select max("matchesCount") from "Skill" where "userId" = @userId or "identifier" = @identifier group by "userId", "identifier"), 0),
@season
) returning *
`);
@ -40,14 +43,16 @@ const addMapResultDeltaStm = sql.prepare(/* sql */ `
"stageId",
"userId",
"wins",
"losses"
"losses",
"season"
) values (
@mode,
@stageId,
@userId,
@wins,
@losses
) on conflict ("userId", "stageId", "mode") do
@losses,
@season
) on conflict ("userId", "stageId", "mode", "season") do
update
set
"wins" = "wins" + @wins,
@ -62,7 +67,8 @@ const addPlayerResultDeltaStm = sql.prepare(/* sql */ `
"mapLosses",
"setWins",
"setLosses",
"type"
"type",
"season"
) values (
@ownerUserId,
@otherUserId,
@ -70,8 +76,9 @@ const addPlayerResultDeltaStm = sql.prepare(/* sql */ `
@mapLosses,
@setWins,
@setLosses,
@type
) on conflict ("ownerUserId", "otherUserId", "type") do
@type,
@season
) on conflict ("ownerUserId", "otherUserId", "type", "season") do
update
set
"mapWins" = "mapWins" + @mapWins,
@ -100,9 +107,11 @@ export const addSummary = sql.transaction(
({
tournamentId,
summary,
season,
}: {
tournamentId: number;
summary: TournamentSummary;
season: number;
}) => {
for (const skill of summary.skills) {
const insertedSkill = addSkillStm.get({
@ -113,13 +122,14 @@ export const addSummary = sql.transaction(
userId: skill.userId,
identifier: skill.identifier,
matchesCount: skill.matchesCount,
season,
}) as Skill;
if (insertedSkill.identifier) {
for (const userIdString of insertedSkill.identifier.split("-")) {
for (const userId of identifierToUserIds(insertedSkill.identifier)) {
addSkillTeamUserStm.run({
skillId: insertedSkill.id,
userId: Number(userIdString),
userId,
});
}
}
@ -132,6 +142,7 @@ export const addSummary = sql.transaction(
userId: mapResultDelta.userId,
wins: mapResultDelta.wins,
losses: mapResultDelta.losses,
season,
});
}
@ -144,6 +155,7 @@ export const addSummary = sql.transaction(
setWins: playerResultDelta.setWins,
setLosses: playerResultDelta.setLosses,
type: playerResultDelta.type,
season,
});
}

View File

@ -74,7 +74,12 @@ import { tournamentSummary } from "../core/summarizer.server";
import invariant from "tiny-invariant";
import { allMatchResultsByTournamentId } from "../queries/allMatchResultsByTournamentId.server";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { queryCurrentTeamRating, queryCurrentUserRating } from "~/features/mmr";
import {
currentSeason,
queryCurrentTeamRating,
queryCurrentUserRating,
} from "~/features/mmr";
import { queryTeamPlayerRatingAverage } from "~/features/mmr/mmr-utils.server";
export const links: LinksFunction = () => {
return [
@ -165,15 +170,27 @@ export const action: ActionFunction = async ({ params, request }) => {
const results = allMatchResultsByTournamentId(tournamentId);
invariant(results.length > 0, "No results found");
// TODO: support tournaments outside of seasons as well as unranked tournaments
const _currentSeason = currentSeason(new Date());
validate(_currentSeason, "No current season found");
addSummary({
tournamentId,
summary: tournamentSummary({
teams,
finalStandings: _finalStandings,
results,
queryCurrentTeamRating,
queryCurrentUserRating,
queryCurrentTeamRating: (identifier) =>
queryCurrentTeamRating({ identifier, season: _currentSeason.nth }),
queryCurrentUserRating: (userId) =>
queryCurrentUserRating({ userId, season: _currentSeason.nth }),
queryTeamPlayerRatingAverage: (identifier) =>
queryTeamPlayerRatingAverage({
identifier,
season: _currentSeason.nth,
}),
}),
season: _currentSeason.nth,
});
return null;

View File

@ -0,0 +1,82 @@
import type { RefObject } from "react";
import { useEffect, useRef } from "react";
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
// MediaQueryList Event based useEventListener interface
function useEventListener<K extends keyof MediaQueryListEventMap>(
eventName: K,
handler: (event: MediaQueryListEventMap[K]) => void,
element: RefObject<MediaQueryList>,
options?: boolean | AddEventListenerOptions
): void;
// Window Event based useEventListener interface
function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
element?: undefined,
options?: boolean | AddEventListenerOptions
): void;
// Element Event based useEventListener interface
function useEventListener<
K extends keyof HTMLElementEventMap,
T extends HTMLElement = HTMLDivElement
>(
eventName: K,
handler: (event: HTMLElementEventMap[K]) => void,
element: RefObject<T>,
options?: boolean | AddEventListenerOptions
): void;
// Document Event based useEventListener interface
function useEventListener<K extends keyof DocumentEventMap>(
eventName: K,
handler: (event: DocumentEventMap[K]) => void,
element: RefObject<Document>,
options?: boolean | AddEventListenerOptions
): void;
function useEventListener<
KW extends keyof WindowEventMap,
KH extends keyof HTMLElementEventMap,
KM extends keyof MediaQueryListEventMap,
T extends HTMLElement | MediaQueryList | void = void
>(
eventName: KW | KH | KM,
handler: (
event:
| WindowEventMap[KW]
| HTMLElementEventMap[KH]
| MediaQueryListEventMap[KM]
| Event
) => void,
element?: RefObject<T>,
options?: boolean | AddEventListenerOptions
) {
// Create a ref that stores handler
const savedHandler = useRef(handler);
useIsomorphicLayoutEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
// Define the listening target
const targetElement: T | Window = element?.current ?? window;
if (!(targetElement && targetElement.addEventListener)) return;
// Create event listener that calls handler function stored in ref
const listener: typeof handler = (event) => savedHandler.current(event);
targetElement.addEventListener(eventName, listener, options);
// Remove event listener on cleanup
return () => {
targetElement.removeEventListener(eventName, listener, options);
};
}, [eventName, element, options]);
}
export { useEventListener };

View File

@ -0,0 +1,4 @@
import { useEffect, useLayoutEffect } from "react";
export const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;

View File

@ -0,0 +1,32 @@
import { useState } from "react";
import { useEventListener } from "./useEventListener";
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
interface WindowSize {
width: number;
height: number;
}
export function useWindowSize(): WindowSize {
const [windowSize, setWindowSize] = useState<WindowSize>({
width: 0,
height: 0,
});
const handleSize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
useEventListener("resize", handleSize);
// Set size at the first client-side load
useIsomorphicLayoutEffect(() => {
handleSize();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return windowSize;
}

View File

@ -95,6 +95,14 @@ export class MapPool {
return Object.values(this.parsed).every((stages) => stages.length === 0);
}
countMapsByMode(mode: ModeShort): number {
return this.parsed[mode].length;
}
get length() {
return this.stageModePairs.length;
}
getClonedObject(): MapPoolObject {
return clone(this.parsed) as MapPoolObject;
}

View File

@ -1,14 +1,29 @@
import { MapPool } from "../map-pool-serializer";
export const DEFAULT_MAP_POOL = new MapPool([
{ mode: "SZ", stageId: 10 },
{ mode: "SZ", stageId: 1 },
{ mode: "SZ", stageId: 6 },
{ mode: "SZ", stageId: 8 },
{ mode: "SZ", stageId: 9 },
{ mode: "SZ", stageId: 15 },
{ mode: "SZ", stageId: 17 },
{ mode: "TC", stageId: 1 },
{ mode: "TC", stageId: 2 },
{ mode: "TC", stageId: 6 },
{ mode: "TC", stageId: 10 },
{ mode: "TC", stageId: 14 },
{ mode: "TC", stageId: 16 },
{ mode: "RM", stageId: 0 },
{ mode: "RM", stageId: 3 },
{ mode: "RM", stageId: 9 },
{ mode: "RM", stageId: 10 },
{ mode: "RM", stageId: 2 },
{ mode: "RM", stageId: 17 },
{ mode: "CB", stageId: 0 },
{ mode: "CB", stageId: 1 },
{ mode: "CB", stageId: 8 },
{ mode: "CB", stageId: 3 },
{ mode: "CB", stageId: 14 },
{ mode: "CB", stageId: 16 },
]);
export const sourceTypes = ["DEFAULT", "TIEBREAKER", "BOTH"] as const;

View File

@ -5,6 +5,7 @@ import type { RankedModeShort } from "../in-game-lists";
import { rankedModesShort } from "../in-game-lists/modes";
import { MapPool } from "../map-pool-serializer";
import type { TournamentMaplistInput } from "./types";
import { DEFAULT_MAP_POOL } from "./constants";
const TournamentMapListGenerator = suite("Tournament map list generator");
const TournamentMapListGeneratorOneMode = suite(
@ -31,6 +32,16 @@ const team2Picks = new MapPool([
{ mode: "CB", stageId: 2 },
{ mode: "CB", stageId: 3 },
]);
const team2PicksNoOverlap = new MapPool([
{ mode: "SZ", stageId: 11 },
{ mode: "SZ", stageId: 9 },
{ mode: "TC", stageId: 2 },
{ mode: "TC", stageId: 8 },
{ mode: "RM", stageId: 17 },
{ mode: "RM", stageId: 1 },
{ mode: "CB", stageId: 2 },
{ mode: "CB", stageId: 3 },
]);
const tiebreakerPicks = new MapPool([
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 11 },
@ -351,6 +362,72 @@ TournamentMapListGenerator("No map picked by same team twice in row", () => {
}
});
TournamentMapListGenerator(
"Calculates all mode maps without tiebreaker",
() => {
const mapList = generateMaps({
teams: [
{
id: 1,
maps: team1Picks,
},
{
id: 2,
maps: team2Picks,
},
],
bestOf: 7,
tiebreakerMaps: new MapPool([]),
});
// the one map both of them picked
assert.equal(mapList[6].stageId, 7);
assert.equal(mapList[6].mode, "RM");
}
);
TournamentMapListGenerator(
"Calculates all mode maps without tiebreaker (no overlap)",
() => {
const mapList = generateMaps({
teams: [
{
id: 1,
maps: team1Picks,
},
{
id: 2,
maps: team2PicksNoOverlap,
},
],
bestOf: 7,
tiebreakerMaps: new MapPool([]),
});
// default map pool contains the tiebreaker
assert.ok(
DEFAULT_MAP_POOL.stageModePairs.some(
(pair) =>
pair.stageId === mapList[6].stageId && pair.mode === mapList[6].mode
)
);
// neither teams map pool contains the tiebreaker
assert.not.ok(
team1Picks.stageModePairs.some(
(pair) =>
pair.stageId === mapList[6].stageId && pair.mode === mapList[6].mode
)
);
assert.not.ok(
team2PicksNoOverlap.stageModePairs.some(
(pair) =>
pair.stageId === mapList[6].stageId && pair.mode === mapList[6].mode
)
);
}
);
// TODO: figure out how to handle this
// checks for case were there is complete overlap in one mode but not others
// which means with forced tiebreaker the map list would become unbalanced
@ -665,5 +742,26 @@ TournamentMapListGeneratorOneMode("Handles one team submitted no maps", () => {
}
});
TournamentMapListGeneratorOneMode(
'Throws if including modes not specified in "modesIncluded"',
() => {
assert.throws(() =>
generateMaps({
teams: [
{
id: 1,
maps: team1Picks,
},
{
id: 2,
maps: new MapPool([]),
},
],
modesIncluded: ["SZ"],
})
);
}
);
TournamentMapListGenerator.run();
TournamentMapListGeneratorOneMode.run();

View File

@ -15,11 +15,7 @@ const OPTIMAL_MAPLIST_SCORE = 0;
export function createTournamentMapList(
input: TournamentMaplistInput
): Array<TournamentMapListMap> {
invariant(
input.modesIncluded.length === 1 ||
input.tiebreakerMaps.stageModePairs.length > 0,
"Must include tiebreaker maps if there are multiple modes"
);
validateInput(input);
const { shuffle } = seededRandom(input.seed);
const stages = shuffle(resolveCommonStages());
@ -43,10 +39,8 @@ export function createTournamentMapList(
}
const stageList =
mapList.length < input.bestOf - 1 ||
// in 1 mode only the tiebreaker is not a thing
tournamentIsOneModeOnly()
? resolveOneModeOnlyStages()
mapList.length < input.bestOf - 1 || input.tiebreakerMaps.length === 0
? resolveStages()
: input.tiebreakerMaps.stageModePairs.map((p) => ({
...p,
score: 0,
@ -125,27 +119,53 @@ export function createTournamentMapList(
);
}
function resolveOneModeOnlyStages() {
if (utilizeOtherStageIdsInOneModeOnlyTournament()) {
function resolveStages() {
if (utilizeOtherStageIdsWhenNoTiebreaker()) {
// no overlap so we need to use a random map for tiebreaker
return shuffle([...stageIds])
.filter(
(stageId) =>
!input.teams[0].maps.hasStage(stageId) &&
!input.teams[1].maps.hasStage(stageId)
)
.map((stageId) => ({
stageId,
mode: input.modesIncluded[0]!,
score: 0,
source: "TIEBREAKER" as const,
}));
if (tournamentIsOneModeOnly()) {
return shuffle([...stageIds])
.filter(
(stageId) =>
!input.teams[0].maps.hasStage(stageId) &&
!input.teams[1].maps.hasStage(stageId)
)
.map((stageId) => ({
stageId,
mode: input.modesIncluded[0]!,
score: 0,
source: "TIEBREAKER" as const,
}));
} else {
return DEFAULT_MAP_POOL.stageModePairs
.filter(
(pair) =>
!input.teams[0].maps.has(pair) && !input.teams[1].maps.has(pair)
)
.map((pair) => ({
stageId: pair.stageId,
mode: pair.mode,
score: 0,
source: "TIEBREAKER" as const,
}));
}
}
return stages;
}
function utilizeOtherStageIdsInOneModeOnlyTournament() {
function validateInput(input: TournamentMaplistInput) {
invariant(
input.teams.every((t) =>
t.maps.stageModePairs.every((pair) =>
input.modesIncluded.includes(pair.mode)
)
),
"Maps submitted for modes not included in the tournament"
);
}
function utilizeOtherStageIdsWhenNoTiebreaker() {
if (mapList.length < input.bestOf - 1) return false;
if (
@ -271,8 +291,8 @@ export function createTournamentMapList(
}
function wouldPreventTiebreaker(stage: StageValidatorInput) {
// tiebreaker always guaranteed if not one mode
if (!tournamentIsOneModeOnly()) return false;
// tiebreaker always guaranteed if maps are explicitly set
if (input.tiebreakerMaps.length > 0) return false;
const commonMaps = input.teams[0].maps.stageModePairs.filter(
({ stageId, mode }) =>

Some files were not shown because too many files have changed in this diff Show More