mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
SendouQ (#1455)
* Tables * Clocks * Maplist preference selector * Fix SSR * Nav icon * RankedOrScrim * Map pool * Create group * Redirect logic * Persist map pool * Advance from preparing page * Rename query * Fix merge * Fix migration order * Seed groups * Find looking groups SQL * Renders something * More UI work * Back to 30min * Likes/dislikes * Always return own group * Fix like order * 3 tc/rm/cb -> 2 * Show only 3 weapons * Pass group size * Handle both liked and liked by same group * Fix SQL * Group preference frontend work * Morphing * Styling * Don't show group controls if not manager * Give/remove manager * Leave group * Leave with confirm * Delete likes when morphing groups * Clocks consistency * Remove bad invariant * Persist settings to local storage * Fix initial value flashing * Fix never resolving loading indicator * REFRESH_GROUP * Flip animations * Tweaks * Auto refresh logic * Groups of 4 seed * Reduce throwing * Load full groups initial * Create match * Match UI initial * Score reporter initial * Push footer down on match page * Score reporter knows when set ended * Score reporting untested * Show score after report * Align better * Look again with same group functionality * More migrations * Team on match page * Show confirmer before reporting score * Report weapons * Report weapos again by admin + skill changing * Handle no tiebreaker given to MapPool * Remove unranked * Remove support for "team id skill" * no-wrap -> nowrap * Preparing page work * Use common GroupCard component * Add some metas * MemberAdder in looking page * Fix GroupCard actions * Fix SZ only map list including other modes * Add season info * Prompt login * Joining team * Manage group on preparing page * Manage group on preparing page * Seed past matches * Add to seed * No map list preference when full group + fix expiry * Fix skill matchesCount calculation * Tiers initial work * Some progress on tiers * Tiering logic * MMR in group cards * Name to challenge * Team MMR * Big team rank icons * Adjust todos * Match score report with confirm * Allow regular members to report score * Handle reporting weapons edge cases * Add tier images * Improve GroupCard spacing * Refactor looking page * Looking mobile UI * Calculate skill only for current season * Divide groups visually when reporting weapons * Fix match page weapons sorting * Add cache to user skills+tier calculation * Admin report match score * Initial leaderboard * Cached leaderboard * Weapon category lb's * Populate SkillTeamUser in SendouQ * Team leaderboard filtered down * Add TODOs * Seasons initlal * Season weapons initial * Weapons stylized * Show rest weapons as + * Hide peak if same as current * Load matches SQL initial * Season matches UI initial * Take user id in account * Add weapons * Paginated matches * Fix pages count logic * Scroll top on data change * Day headers for matches * Link from user page to user seasons page * Summarize maps + ui initial * Map stats * Player info tabs * MMR chart * Chart adjustments * Handle basing team MMR on player MMR * Set initial MMR * Add info about discord to match page * Season support to tournaments * Get tournament skills as well for the graph * WIP * New team rating logic + misc other * tiered -> tiered.server * Update season starting time * TODOs * Add rules page * Hide elements correctly when off-season * Fix crash when only one player with skill * How-to video * Fix StartRank showing when not logged in * Make user leaderboard the default * Make Skill season non-nullable * Add suggested pass to match * Add rule * identifierToUserIds helper * Fix tiers not showing
This commit is contained in:
parent
f738b2aba7
commit
e7bbb565be
|
|
@ -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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
51
app/components/Chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
18
app/components/icons/ArchiveBox.tsx
Normal file
18
app/components/icons/ArchiveBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
app/components/icons/ArrowsPointingIn.tsx
Normal file
18
app/components/icons/ArrowsPointingIn.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
app/components/icons/RefreshArrows.tsx
Normal file
18
app/components/icons/RefreshArrows.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
app/components/icons/Undo.tsx
Normal file
16
app/components/icons/Undo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
}
|
||||
|
|
|
|||
72
app/features/leaderboards/core/leaderboards.server.ts
Normal file
72
app/features/leaderboards/core/leaderboards.server.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -4,3 +4,5 @@ export {
|
|||
} from "./mmr-utils.server";
|
||||
|
||||
export { rate, userIdsToIdentifier, ordinalToSp } from "./mmr-utils";
|
||||
|
||||
export { currentSeason } from "./season";
|
||||
|
|
|
|||
80
app/features/mmr/mmr-constants.ts
Normal file
80
app/features/mmr/mmr-constants.ts
Normal 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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("-");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
56
app/features/mmr/queries/orderedMMRBySeason.server.ts
Normal file
56
app/features/mmr/queries/orderedMMRBySeason.server.ts
Normal 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">
|
||||
>;
|
||||
}
|
||||
38
app/features/mmr/queries/seasonAllMMRByUserId.server.ts
Normal file
38
app/features/mmr/queries/seasonAllMMRByUserId.server.ts
Normal 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;
|
||||
}>;
|
||||
}
|
||||
59
app/features/mmr/season.ts
Normal file
59
app/features/mmr/season.ts
Normal 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];
|
||||
}
|
||||
147
app/features/mmr/tiered.server.ts
Normal file
147
app/features/mmr/tiered.server.ts
Normal 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;
|
||||
}
|
||||
195
app/features/sendouq/components/GroupCard.tsx
Normal file
195
app/features/sendouq/components/GroupCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
app/features/sendouq/components/MemberAdder.tsx
Normal file
79
app/features/sendouq/components/MemberAdder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
app/features/sendouq/components/ModePrefenceIcons.tsx
Normal file
44
app/features/sendouq/components/ModePrefenceIcons.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
202
app/features/sendouq/core/groups.server.ts
Normal file
202
app/features/sendouq/core/groups.server.ts
Normal 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;
|
||||
}
|
||||
39
app/features/sendouq/core/groups.ts
Normal file
39
app/features/sendouq/core/groups.ts
Normal 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";
|
||||
}
|
||||
104
app/features/sendouq/core/match.server.ts
Normal file
104
app/features/sendouq/core/match.server.ts
Normal 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";
|
||||
}
|
||||
22
app/features/sendouq/core/match.ts
Normal file
22
app/features/sendouq/core/match.ts
Normal 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;
|
||||
}
|
||||
105
app/features/sendouq/core/skills.server.ts
Normal file
105
app/features/sendouq/core/skills.server.ts
Normal 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;
|
||||
}
|
||||
150
app/features/sendouq/core/summarizer.server.ts
Normal file
150
app/features/sendouq/core/summarizer.server.ts
Normal 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;
|
||||
}
|
||||
32
app/features/sendouq/q-constants.ts
Normal file
32
app/features/sendouq/q-constants.ts
Normal 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";
|
||||
110
app/features/sendouq/q-schemas.server.ts
Normal file
110
app/features/sendouq/q-schemas.server.ts
Normal 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))
|
||||
),
|
||||
}),
|
||||
]);
|
||||
38
app/features/sendouq/q-types.ts
Normal file
38
app/features/sendouq/q-types.ts
Normal 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[];
|
||||
}
|
||||
89
app/features/sendouq/q-utils.ts
Normal file
89
app/features/sendouq/q-utils.ts
Normal 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
154
app/features/sendouq/q.css
Normal 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;
|
||||
}
|
||||
}
|
||||
34
app/features/sendouq/queries/addInitialSkill.server.ts
Normal file
34
app/features/sendouq/queries/addInitialSkill.server.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
17
app/features/sendouq/queries/addLike.server.ts
Normal file
17
app/features/sendouq/queries/addLike.server.ts
Normal 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 });
|
||||
}
|
||||
18
app/features/sendouq/queries/addManagerRole.server.ts
Normal file
18
app/features/sendouq/queries/addManagerRole.server.ts
Normal 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 });
|
||||
}
|
||||
34
app/features/sendouq/queries/addMapResults.server.ts
Normal file
34
app/features/sendouq/queries/addMapResults.server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
23
app/features/sendouq/queries/addMember.server.ts
Normal file
23
app/features/sendouq/queries/addMember.server.ts
Normal 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 });
|
||||
}
|
||||
36
app/features/sendouq/queries/addPlayerResults.server.ts
Normal file
36
app/features/sendouq/queries/addPlayerResults.server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
app/features/sendouq/queries/addReportedWeapons.server.ts
Normal file
20
app/features/sendouq/queries/addReportedWeapons.server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
65
app/features/sendouq/queries/addSkills.server.ts
Normal file
65
app/features/sendouq/queries/addSkills.server.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
111
app/features/sendouq/queries/createGroup.server.ts
Normal file
111
app/features/sendouq/queries/createGroup.server.ts
Normal 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;
|
||||
}
|
||||
);
|
||||
59
app/features/sendouq/queries/createMatch.server.ts
Normal file
59
app/features/sendouq/queries/createMatch.server.ts
Normal 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;
|
||||
}
|
||||
);
|
||||
17
app/features/sendouq/queries/deleteLike.server.ts
Normal file
17
app/features/sendouq/queries/deleteLike.server.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
|
@ -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];
|
||||
}
|
||||
21
app/features/sendouq/queries/findLikes.ts
Normal file
21
app/features/sendouq/queries/findLikes.ts
Normal 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;
|
||||
}
|
||||
51
app/features/sendouq/queries/findMatchById.server.ts
Normal file
51
app/features/sendouq/queries/findMatchById.server.ts
Normal 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;
|
||||
}
|
||||
69
app/features/sendouq/queries/findPreparingGroup.server.ts
Normal file
69
app/features/sendouq/queries/findPreparingGroup.server.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
33
app/features/sendouq/queries/findTeamByInviteCode.server.ts
Normal file
33
app/features/sendouq/queries/findTeamByInviteCode.server.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
64
app/features/sendouq/queries/groupForMatch.server.ts
Normal file
64
app/features/sendouq/queries/groupForMatch.server.ts
Normal 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;
|
||||
}
|
||||
14
app/features/sendouq/queries/groupSize.server.ts
Normal file
14
app/features/sendouq/queries/groupSize.server.ts
Normal 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;
|
||||
}
|
||||
27
app/features/sendouq/queries/groupSuccessorOwner.ts
Normal file
27
app/features/sendouq/queries/groupSuccessorOwner.ts
Normal 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;
|
||||
};
|
||||
45
app/features/sendouq/queries/leaveGroup.server.ts
Normal file
45
app/features/sendouq/queries/leaveGroup.server.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
18
app/features/sendouq/queries/likeExists.server.ts
Normal file
18
app/features/sendouq/queries/likeExists.server.ts
Normal 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 }));
|
||||
}
|
||||
93
app/features/sendouq/queries/lookingGroups.server.ts
Normal file
93
app/features/sendouq/queries/lookingGroups.server.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
14
app/features/sendouq/queries/mapPoolByGroupId.server.ts
Normal file
14
app/features/sendouq/queries/mapPoolByGroupId.server.ts
Normal 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">>;
|
||||
}
|
||||
41
app/features/sendouq/queries/morphGroups.server.ts
Normal file
41
app/features/sendouq/queries/morphGroups.server.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
12
app/features/sendouq/queries/refreshGroup.server.ts
Normal file
12
app/features/sendouq/queries/refreshGroup.server.ts
Normal 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 });
|
||||
}
|
||||
18
app/features/sendouq/queries/removeManagerRole.server.ts
Normal file
18
app/features/sendouq/queries/removeManagerRole.server.ts
Normal 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 });
|
||||
}
|
||||
52
app/features/sendouq/queries/reportScore.server.ts
Normal file
52
app/features/sendouq/queries/reportScore.server.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
195
app/features/sendouq/queries/seasonMatchesByUserId.server.ts
Normal file
195
app/features/sendouq/queries/seasonMatchesByUserId.server.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 }>;
|
||||
}
|
||||
50
app/features/sendouq/queries/seasonStagesByUserId.server.ts
Normal file
50
app/features/sendouq/queries/seasonStagesByUserId.server.ts
Normal 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;
|
||||
}
|
||||
>
|
||||
>
|
||||
>
|
||||
>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
>;
|
||||
}>;
|
||||
}
|
||||
12
app/features/sendouq/queries/setGroupAsActive.server.ts
Normal file
12
app/features/sendouq/queries/setGroupAsActive.server.ts
Normal 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 });
|
||||
}
|
||||
11
app/features/sendouq/queries/setGroupAsInactive.server.ts
Normal file
11
app/features/sendouq/queries/setGroupAsInactive.server.ts
Normal 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 });
|
||||
}
|
||||
32
app/features/sendouq/queries/syncGroupTeamId.server.ts
Normal file
32
app/features/sendouq/queries/syncGroupTeamId.server.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
24
app/features/sendouq/queries/userHasSkill.server.ts
Normal file
24
app/features/sendouq/queries/userHasSkill.server.ts
Normal 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;
|
||||
}
|
||||
29
app/features/sendouq/queries/usersInActiveGroup.server.ts
Normal file
29
app/features/sendouq/queries/usersInActiveGroup.server.ts
Normal 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));
|
||||
}
|
||||
597
app/features/sendouq/routes/q.looking.tsx
Normal file
597
app/features/sendouq/routes/q.looking.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
982
app/features/sendouq/routes/q.match.$id.tsx
Normal file
982
app/features/sendouq/routes/q.match.$id.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
app/features/sendouq/routes/q.preparing.tsx
Normal file
158
app/features/sendouq/routes/q.preparing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
app/features/sendouq/routes/q.rules.tsx
Normal file
89
app/features/sendouq/routes/q.rules.tsx
Normal 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'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'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's/team'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>
|
||||
);
|
||||
}
|
||||
688
app/features/sendouq/routes/q.tsx
Normal file
688
app/features/sendouq/routes/q.tsx
Normal 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'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'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). "Higher" is recommended for
|
||||
Plus Server level players. "Lower" for Low Ink eligible
|
||||
players. "Default" 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ function summarize({ results }: { results?: AllMatchResult[] } = {}) {
|
|||
],
|
||||
queryCurrentTeamRating: () => rating(),
|
||||
queryCurrentUserRating: () => rating(),
|
||||
queryTeamPlayerRatingAverage: () => rating(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
82
app/hooks/useEventListener.ts
Normal file
82
app/hooks/useEventListener.ts
Normal 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 };
|
||||
4
app/hooks/useIsomorphicLayoutEffect.ts
Normal file
4
app/hooks/useIsomorphicLayoutEffect.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { useEffect, useLayoutEffect } from "react";
|
||||
|
||||
export const useIsomorphicLayoutEffect =
|
||||
typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
||||
32
app/hooks/useWindowSize.ts
Normal file
32
app/hooks/useWindowSize.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue
Block a user