User page initial with SQLite3 (#822)

* Clean away prisma migrations

* Way to migrate WIP

* SQLite3 seeding script initial

* Fetch tournament data in loader

* CheckinActions new loader data model

* Virtual banner text color columns

* Logged in user

* Count teams

* ownTeam

* Map pool tab fully working

* Teams tab

* Fix timestamp default

* Register page

* Manage team page

* Camel case checkedInTimestamp

* Clean slate

* Add .nvmrc

* Add favicon

* Package lock file version 2

* Update tsconfig

* Add Tailwind

* Add StrictMode

* Add background color

* Auth without DB

* Revert "Add Tailwind"

This reverts commit 204713c602.

* Auth with DB

* Switch back to tilde absolute import

* Import layout

* Camel case for database columns

* Move auth routes to folder

* User popover links working

* Import linters

* User page initial

* User edit page with country

* Script to delete db files before migration in dev

* Remove "youtubeName" column

* Correct avatar size on desktop

* Fix SubNav not spanning the whole page

* Remove duplicate files

* Update README
This commit is contained in:
Kalle 2022-05-16 17:52:54 +03:00 committed by GitHub
parent 1e1f02fb2a
commit 185295d54e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
443 changed files with 7630 additions and 117366 deletions

View File

@ -1,15 +1,7 @@
// replace with your own PostgreSQL database connection string
DATABASE_URL=postgresql://sendou@localhost:5432/sendou_ink?schema=public
PORT=5800
BASE_URL=http://localhost:5800
// uncomment below if you have Redis running
// REDIS_URL=redis://localhost:6379
// these are needed for logging in.
// you can get them by making an application on https://discord.com/developers
// auth
SESSION_SECRET=secret
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
COOKIE_SECRET=
FRONT_PAGE_URL=http://localhost:3000/
LANISTA_TOKEN=lanista
LANISTA_URL=
LANISTA_URL_TOKEN=
DISCORD_CLIENT_SECRET=

View File

@ -13,6 +13,8 @@ module.exports = {
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
"@remix-run/eslint-config",
"@remix-run/eslint-config/node",
],
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],

View File

@ -19,7 +19,5 @@ jobs:
run: npm run lint:ts
- name: Stylelint
run: npm run lint:styles
- name: Unit tests
run: npm run test:unit
- name: Typecheck
run: npm run typecheck

8
.gitignore vendored
View File

@ -1,8 +1,8 @@
node_modules
.env
/.cache
/server/build
/public/build
/build
backup.sql
/public/build
.env
db.sqlite3*

2
.nvmrc
View File

@ -1 +1 @@
v16.14.2
v16.15.0

View File

@ -1 +1 @@
build
build

View File

@ -671,4 +671,4 @@ into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@ -4,45 +4,28 @@ Note: This is the WIP Splatoon 3 version of the site. To see the current live ve
Prerequisites: [nvm](https://github.com/nvm-sh/nvm)
1. Use `nvm use` to switch to the correct Node version.
2. Run `npm i` to install the dependencies.
There is a sequence of commands you need to run:
1. `nvm use` to switch to the correct Node version.
2. `npm i` to install the dependencies.
3. Make a copy of `.env.example` that's called `.env` and fill it with values.
4. `npm run migrate` to set up the database tables.
5. `npm run dev` to run both the server and frontend.
- You can check [Prisma's guide](https://www.prisma.io/dataguide/postgresql/setting-up-a-local-postgresql-database) on how to get PostgreSQL 14 set up and running locally.
- Run `npm run migration:apply:dev` to set up the tables of your database.
- Run `npm run seed` to seed the database with some test data.
4. Run `npm run dev` to run both the server and frontend.
## File structure
## Project structure
```
sendou.ink/
├── app/
│ ├── components/ -- Components shared between many routes
│ ├── components/ -- React components
│ ├── core/ -- Core business logic
│ ├── db/ -- Database layer
│ ├── hooks/ -- React hooks
│ ├── models/ -- Calls to database
│ ├── routes/ -- Routes see: https://remix.run/docs/en/v1/guides/routing
│ ├── styles/ -- All .css files of the project for styling
── utils/ -- Random helper functions used in many places
│ └── constants.ts -- Global constants of the projects
── utils/ -- Random helper functions used in many places
├── migrations/ -- Database migrations
├── cypress/ -- see: https://docs.cypress.io/guides/core-concepts/writing-and-organizing-tests#Folder-structure
├── prisma/ -- Prisma related files
│ ├── migrations/ -- Database migrations via Prisma Migrate
│ ├── seed/ -- Seeding logic for tests and development
│ ├── client.ts -- Global import of the Prisma object
│ └── schema.prisma -- Database table schema
├── public/ -- Images, built assets etc. static files to be served as is
└── server/ -- Express.js server-side logic that is not handled in Remix e.g. auth
└── scripts/ -- Stand-alone scripts to be run outside of the app
```
## Seeding script variations
You can give a variation as a flag to the seeding script changing what exactly is put in the database. For example `npm run seed -- -v=check-in` seeds the database with a variation where check-in is in progress.
## Commands
### Convert .png to .webp
`cwebp -q 80 image.png -o image.webp`

View File

@ -1,109 +0,0 @@
import { Form } from "@remix-run/react";
import { useBaseURL, useTimeoutState } from "~/hooks/common";
import { FindManyByTrustReceiverId } from "~/models/TrustRelationship.server";
import { Button } from "./Button";
import { FormErrorMessage } from "./FormErrorMessage";
import { Label } from "./Label";
import { SubmitButton } from "./SubmitButton";
export function AddPlayers({
pathname,
inviteCode,
addUserError,
trustingUsers,
hiddenInputs,
tinyButtons = false,
legendText,
}: {
pathname: string;
inviteCode: string;
addUserError?: string;
trustingUsers: FindManyByTrustReceiverId;
hiddenInputs: { name: string; value: string }[];
tinyButtons?: boolean;
legendText: string;
}) {
const baseURL = useBaseURL();
const urlWithInviteCode = `${baseURL}${pathname}?code=${inviteCode}`;
return (
<fieldset className="add-players__actions">
<legend>{legendText}</legend>
<div className="add-players__actions__section">
<Label htmlFor="inviteCodeInput">Share this URL</Label>
<input
id="inviteCodeInput"
className="add-players__input"
disabled
value={urlWithInviteCode}
/>
<CopyToClipboardButton
urlWithInviteCode={urlWithInviteCode}
tiny={tinyButtons}
/>
</div>
{trustingUsers.length > 0 && (
<div className="add-players__actions__section">
<Form method="post">
{hiddenInputs.map((input) => (
<input
key={input.value}
name={input.name}
value={input.value}
type="hidden"
/>
))}
<Label htmlFor="userId">
Add players you previously played with
</Label>
<select className="add-players__select" name="userId" id="userId">
{trustingUsers.map(({ trustGiver }) => (
<option key={trustGiver.id} value={trustGiver.id}>
{trustGiver.discordName}
</option>
))}
</select>
<FormErrorMessage errorMsg={addUserError} />
<SubmitButton
className="add-players__input__button"
actionType="ADD_PLAYER"
loadingText="Adding..."
data-cy="add-to-roster-button"
tiny={tinyButtons}
>
Add
</SubmitButton>
</Form>
</div>
)}
</fieldset>
);
}
function CopyToClipboardButton({
urlWithInviteCode,
tiny,
}: {
urlWithInviteCode: string;
tiny: boolean;
}) {
const [showCopied, setShowCopied] = useTimeoutState(false);
return (
<Button
className="add-players__input__button"
onClick={() => {
navigator.clipboard
.writeText(urlWithInviteCode)
.then(() => setShowCopied(true))
.catch((e) => console.error(e));
}}
type="button"
data-cy="copy-to-clipboard-button"
tiny={tiny}
variant="outlined"
>
{showCopied ? "Copied!" : "Copy to clipboard"}
</Button>
);
}

View File

@ -1,25 +0,0 @@
import clsx from "clsx";
import { AlertIcon } from "./icons/Alert";
import { SuccessIcon } from "./icons/Success";
// TODO: should flex-dir column on mobile
export function Alert(props: {
children: React.ReactNode;
type: "warning" | "info" | "success";
className?: string;
rightAction?: React.ReactNode;
"data-cy"?: string;
}) {
return (
<div
data-type={props.type}
className={clsx("alert", props.className)}
data-cy={props["data-cy"]}
>
{(props.type === "warning" || props.type === "info") && <AlertIcon />}
{props.type === "success" && <SuccessIcon />}
{props.children}
{props.rightAction}
</div>
);
}

View File

@ -1,27 +1,30 @@
import clsx from "clsx";
import { MyCSSProperties } from "~/utils";
import type { User } from "~/db/types";
export function Avatar({
user,
discordId,
discordAvatar,
size,
}: {
user: { discordId: string; discordAvatar: string | null };
size?: "tiny" | "mini";
className,
}: Pick<User, "discordId" | "discordAvatar"> & {
className?: string;
size?: "lg";
}) {
const style: MyCSSProperties = {
"--_avatar-size":
size === "tiny" ? "2rem" : size === "mini" ? "1.5rem" : undefined,
};
// TODO: just show text... my profile?
// TODO: also show this if discordAvatar is stale and 404's
if (!discordAvatar) return <div className="avatar" />;
const dimensions = size === "lg" ? 125 : 44;
return (
<div style={style} className={clsx("avatar__placeholder", { tiny: size })}>
{user.discordAvatar && (
<img
alt=""
className={clsx("avatar__img", { tiny: size })}
loading="lazy"
src={`https://cdn.discordapp.com/avatars/${user.discordId}/${user.discordAvatar}.png?size=80`}
/>
)}
</div>
<img
className={clsx("avatar", className, { lg: size === "lg" })}
src={`https://cdn.discordapp.com/avatars/${discordId}/${discordAvatar}.png${
size === "lg" ? "" : "?size=80"
}`}
alt="My avatar"
width={dimensions}
height={dimensions}
/>
);
}

View File

@ -27,6 +27,7 @@ export function Button(props: ButtonProps) {
tiny,
className,
icon,
type = "button",
...rest
} = props;
return (
@ -44,6 +45,7 @@ export function Button(props: ButtonProps) {
tiny,
})}
disabled={props.disabled || loading}
type={type}
{...rest}
>
{icon && React.cloneElement(icon, { className: "button-icon" })}

View File

@ -1,49 +0,0 @@
import { useCatch, useLocation } from "@remix-run/react";
import { getLogInUrl } from "~/utils";
import { useUser } from "~/hooks/common";
import { Button } from "./Button";
import { discordUrl } from "~/utils/urls";
// TODO: some nice art
export function Catcher() {
const caught = useCatch();
const user = useUser();
const location = useLocation();
switch (caught.status) {
case 401:
return (
<div className="four-zero-one__container">
<h2 className="four-zero-one__status-header">401 Unauthorized</h2>
{user ? (
<p>
If you need assistance you can ask for help on{" "}
<a className="four-zero-one__link" href={discordUrl()}>
our Discord
</a>
</p>
) : (
<form action={getLogInUrl(location)} method="post">
<p className="button-text-paragraph">
You should try{" "}
<Button type="submit" variant="minimal">
logging in
</Button>
</p>
</form>
)}
</div>
);
// case 404:
// message = (
// <p>Oops! Looks like you tried to visit a page that does not exist.</p>
// );
// break;
default:
console.error(caught);
throw new Error(
`${caught.status} - ${caught.statusText},${String(caught.data).trim()}`
);
}
}

View File

@ -1,35 +0,0 @@
import clsx from "clsx";
import { ChatLoaderData } from "~/routes/chat";
import { Unpacked, ValueOf } from "~/utils";
import { ChatProps } from ".";
export function Message({
data,
sending,
user,
}: {
data: Omit<Unpacked<ChatLoaderData["messages"]>, "roomId" | "id">;
sending?: boolean;
user: ValueOf<ChatProps["users"]>;
}) {
return (
<li className="chat__message">
<div className="chat__message__message-header">
<div className="chat__message__sender">{user.name}</div>
{user.info && (
<div className="chat__message__extra-info">{user.info}</div>
)}
</div>
<div className={clsx("chat__message__content", { sending })}>
<time className="chat__message__time mr-2">
{new Date(data.createdAtTimestamp).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
})}
</time>
{data.content}
</div>
</li>
);
}

View File

@ -1,92 +0,0 @@
import { MAX_CHAT_MESSAGE_LENGTH } from "~/constants";
import { useUser } from "~/hooks/common";
import { chatRoute } from "~/utils/urls";
import { Button } from "../Button";
import { CrossIcon } from "../icons/Cross";
import { SpeechBubbleIcon } from "../icons/SpeechBubble";
import { Message } from "./Message";
import useChat from "./useChat";
export interface ChatProps {
id: string;
users: { [id: string]: { info?: string | null; name: string } };
}
export function Chat({ id, users }: ChatProps) {
const user = useUser();
const {
messages,
sentMessage,
containerRef,
formRef,
inputRef,
actionFetcher,
isOpen,
toggleOpen,
unreadCount,
} = useChat(id);
if (!user || !messages) return null;
return (
<>
{isOpen && (
<div className="chat__window">
<ul className="chat__messages" ref={containerRef}>
{messages
?.filter((message) => users[message.sender.id])
.map((message) => (
<Message
key={message.id}
data={message}
user={users[message.sender.id]}
/>
))}
{sentMessage && users[user.id] && (
<Message
data={{
createdAtTimestamp: new Date().getTime(),
content: sentMessage,
sender: {
id: user.id,
},
}}
user={users[user.id]}
sending
/>
)}
</ul>
<actionFetcher.Form
className="chat__input-container"
ref={formRef}
method="post"
action={chatRoute()}
>
<input type="hidden" name="roomId" value={id} />
<input
ref={inputRef}
className="chat__message__input"
name="message"
maxLength={MAX_CHAT_MESSAGE_LENGTH}
required
autoComplete="off"
/>
<Button tiny variant="outlined" type="submit">
Send
</Button>
</actionFetcher.Form>
</div>
)}
<button type="button" className="chat__fab" onClick={toggleOpen}>
{isOpen ? (
<CrossIcon className="chat__fab__icon" />
) : (
<SpeechBubbleIcon className="chat__fab__icon" />
)}
</button>
{unreadCount > 0 && (
<div className="chat__fab__unread">{unreadCount}</div>
)}
</>
);
}

View File

@ -1,117 +0,0 @@
import * as React from "react";
import { useFetcher } from "@remix-run/react";
import invariant from "tiny-invariant";
import { useUser } from "~/hooks/common";
import { useSocketEvent } from "~/hooks/useSocketEvent";
import { ChatActionData, ChatLoaderData } from "~/routes/chat";
import { Unpacked } from "~/utils";
import { chatRoute } from "~/utils/urls";
export default function useChat(id: string) {
const [isOpen, setIsOpen] = React.useState(false);
const [messagesAfterLoad, setMessagesAfterLoad] = React.useState<
ChatLoaderData["messages"]
>([]);
const [unreadCount, setUnreadCount] = React.useState(0);
const loaderFetcher = useFetcher<ChatLoaderData>();
const actionFetcher = useFetcher<ChatActionData>();
const containerRef = React.useRef<HTMLUListElement>(null);
const formRef = React.useRef<HTMLFormElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const user = useUser();
invariant(user, "!user");
const eventHandler = React.useCallback(
(data: Unpacked<ChatLoaderData["messages"]>) => {
if (data.sender.id === user.id) return;
setMessagesAfterLoad((messages) => [...messages, data]);
if (!isOpen) {
setUnreadCount((count) => count + 1);
}
},
[isOpen, user.id]
);
useSocketEvent(`chat-${id}`, eventHandler);
React.useEffect(() => {
loaderFetcher.load(chatRoute([id]));
}, [id]);
// open chat on data load if there are messages
React.useEffect(() => {
if (!loaderFetcher.data) return;
if (loaderFetcher.data.messages.length > 0) {
setIsOpen(true);
}
}, [loaderFetcher.data]);
// after sending message reset and refocus input so user can keep typing
React.useEffect(() => {
if (actionFetcher.submission) {
formRef.current?.reset();
inputRef.current?.focus();
}
}, [actionFetcher.submission]);
React.useEffect(() => {
if (!actionFetcher.data) return;
setMessagesAfterLoad((messagesAfterLoad) => {
if (!actionFetcher.data) return [...messagesAfterLoad];
return [...messagesAfterLoad, actionFetcher.data.createdMessage];
});
}, [actionFetcher.data]);
const messages = React.useMemo(
() =>
loaderFetcher.data
? [...loaderFetcher.data.messages, ...messagesAfterLoad].sort(
(a, b) => a.createdAtTimestamp - b.createdAtTimestamp
)
: undefined,
[loaderFetcher.data, messagesAfterLoad]
);
const sentMessage = React.useMemo(() => {
const newMessageContent = actionFetcher.submission?.formData.get("message");
if (
typeof newMessageContent !== "string" ||
actionFetcher.state !== "submitting"
) {
return;
}
return newMessageContent;
}, [actionFetcher]);
const toggleOpen = React.useCallback(() => {
setIsOpen((open) => {
if (!open) {
setUnreadCount(0);
return true;
}
return false;
});
}, []);
React.useEffect(() => {
if (!containerRef.current) return;
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}, [messages, sentMessage, isOpen]);
return {
messages,
sentMessage,
containerRef,
formRef,
inputRef,
actionFetcher,
isOpen,
toggleOpen,
unreadCount,
};
}

View File

@ -1,58 +0,0 @@
import { Combobox as HeadlessCombobox } from "@headlessui/react";
import * as React from "react";
import Fuse from "fuse.js";
import clsx from "clsx";
const MAX_RESULTS_SHOWN = 6;
export function Combobox({
options,
onChange,
inputName,
placeholder,
}: {
options: string[] | readonly string[];
onChange: (value: string) => void;
inputName: string;
placeholder: string;
}) {
const [query, setQuery] = React.useState("");
const filteredOptions = (() => {
if (!query) return [];
const fuse = new Fuse(options);
return fuse
.search(query)
.slice(0, MAX_RESULTS_SHOWN)
.map((res) => res.item);
})();
return (
<HeadlessCombobox value="" onChange={onChange}>
<HeadlessCombobox.Input
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
className="combobox-input"
name={inputName}
/>
<HeadlessCombobox.Options
className={clsx("combobox-options", {
invisible: filteredOptions.length === 0,
})}
>
{filteredOptions.map((option) => (
<HeadlessCombobox.Option
key={option}
value={option}
as={React.Fragment}
>
{({ active }) => (
<li className={clsx("combobox-item", { active })}>{option}</li>
)}
</HeadlessCombobox.Option>
))}
</HeadlessCombobox.Options>
</HeadlessCombobox>
);
}

View File

@ -1,35 +0,0 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import * as React from "react";
export function Draggable({
id,
disabled,
liClassName,
children,
}: {
id: string;
disabled: boolean;
liClassName: string;
children: React.ReactNode;
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id, disabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li
className={liClassName}
style={style}
ref={setNodeRef}
{...listeners}
{...attributes}
>
{children}
</li>
);
}

View File

@ -1,9 +0,0 @@
export function FormErrorMessage({ errorMsg }: { errorMsg?: string }) {
if (!errorMsg) return null;
return (
<p className="form-validation-error" role="alert">
{errorMsg}
</p>
);
}

View File

@ -1,5 +0,0 @@
import * as React from "react";
export function FormInfoText({ children }: { children: React.ReactNode }) {
return <p className="form-info-text">{children}</p>;
}

View File

@ -1,10 +0,0 @@
import clsx from "clsx";
export interface LabelProps
extends React.LabelHTMLAttributes<HTMLLabelElement> {
className?: string;
}
export function Label({ className, ...rest }: LabelProps) {
return <label className={clsx(className, "label")} {...rest} />;
}

View File

@ -1,48 +0,0 @@
import { useLocation } from "@remix-run/react";
import { useUser } from "~/hooks/common";
import { getLogInUrl } from "~/utils";
import { Button } from "../Button";
import { DiscordIcon } from "../icons/Discord";
import { LogOutIcon } from "../icons/LogOut";
import { Popover } from "../Popover";
export function UserItem() {
const user = useUser();
const location = useLocation();
if (user && user.discordAvatar)
return (
<Popover
trigger={
<img
className="layout__avatar"
src={`https://cdn.discordapp.com/avatars/${user.discordId}/${user.discordAvatar}.png?size=80`}
/>
}
>
<form method="post" action="/logout">
<Button tiny variant="outlined" icon={<LogOutIcon />}>
Log out
</Button>
</form>
</Popover>
);
// TODO: just show text... my profile?
// TODO: also show this if discordAvatar is stale and 404's
if (user) {
return <div className="layout__header__logo-container" />;
}
return (
<form action={getLogInUrl(location)} method="post" data-cy="log-in-form">
<button
type="submit"
className="layout__log-in-button"
data-cy="log-in-button"
>
<DiscordIcon /> Log in
</button>
</form>
);
}

5
app/components/Main.tsx Normal file
View File

@ -0,0 +1,5 @@
import type * as React from "react";
export const Main = ({ children }: { children: React.ReactNode }) => (
<main className="layout__main">{children}</main>
);

View File

@ -1,49 +0,0 @@
import * as React from "react";
import { Link, useNavigate } from "@remix-run/react";
import { useOnClickOutside } from "~/hooks/common";
const ESC_BUTTON = "Escape";
export default function Modal({
closeUrl,
title,
children,
}: {
closeUrl: string;
title: React.ReactNode;
children: React.ReactNode;
}) {
const navigate = useNavigate();
const ref = React.useRef<HTMLDivElement>(null);
const navigateBack = React.useCallback(
() => navigate(closeUrl),
[closeUrl, navigate]
);
useOnClickOutside(ref, navigateBack);
React.useEffect(() => {
function handleEscButtonPress(e: KeyboardEvent) {
if (e.key === ESC_BUTTON) {
navigateBack();
}
}
document.addEventListener("keydown", handleEscButtonPress);
return () => {
document.removeEventListener("keydown", handleEscButtonPress);
};
});
return (
<div className="modal">
<div ref={ref}>
<Link to={closeUrl} className="modal-close">
Close
</Link>
<h2>{title}</h2>
<div>{children}</div>
</div>
</div>
);
}

View File

@ -1,19 +0,0 @@
import { Mode } from "@prisma/client";
import { modesShortToLong } from "~/core/stages/stages";
import { modeToImageUrl } from "~/utils";
export interface ModeImageProps
extends React.ButtonHTMLAttributes<HTMLImageElement> {
mode: Mode;
}
export function ModeImage({ mode, ...props }: ModeImageProps) {
return (
<img
src={modeToImageUrl(mode)}
alt={modesShortToLong[mode]}
title={modesShortToLong[mode]}
{...props}
/>
);
}

View File

@ -1,11 +0,0 @@
import { useNavigate } from "@remix-run/react";
import * as React from "react";
export function Navigate({ to }: { to: string }) {
const navigate = useNavigate();
React.useEffect(() => {
navigate(to);
}, [navigate, to]);
return null;
}

View File

@ -1,23 +0,0 @@
import { useLocation } from "@remix-run/react";
import { getLogInUrl } from "~/utils";
import { Button } from "./Button";
export function PleaseLogin({
texts,
}: {
texts: [beforeButton: string, buttonText: string, afterButton: string];
}) {
const location = useLocation();
return (
<form action={getLogInUrl(location)} method="post">
<p className="button-text-paragraph">
{texts[0]}{" "}
<Button type="submit" variant="minimal">
{texts[1]}
</Button>{" "}
{texts[2]}
</p>
</form>
);
}

View File

@ -1,7 +1,7 @@
import { NavLink } from "@remix-run/react";
import { RemixNavLinkProps } from "@remix-run/react/components";
import type { RemixNavLinkProps } from "@remix-run/react/components";
import clsx from "clsx";
import React from "react";
import type * as React from "react";
import { ArrowUpIcon } from "./icons/ArrowUp";
export function SubNav({ children }: { children: React.ReactNode }) {

View File

@ -1,51 +0,0 @@
import { useEffect } from "react";
import { useActionData, useTransition } from "@remix-run/react";
import { useTimeoutState } from "~/hooks/common";
import { Button, ButtonProps } from "./Button";
export function SubmitButton(
_props: ButtonProps & {
actionType: string;
successText?: string;
onSuccess?: () => void;
}
) {
const { actionType, successText, onSuccess, children, ...rest } = _props;
const actionData = useActionData<{ ok?: string }>();
const transition = useTransition();
const [showSuccess, setShowSuccess] = useTimeoutState(false);
useEffect(() => {
// did this submit button's action happen?
if (actionData?.ok !== actionType) return;
// this is essentially to ensure this only fires once per mutation
if (transition.type !== "actionReload") return;
onSuccess?.();
setShowSuccess(true);
}, [actionData?.ok, transition.type, actionType, setShowSuccess]);
const isLoading = (): boolean => {
// is there an action happening at the moment?
if (!["actionSubmission", "actionReload"].includes(transition.type)) {
return false;
}
// is it the action of this submit button?
const _action = transition.submission?.formData.get("_action");
if (_action !== actionType) return false;
return true;
};
return (
<Button
type="submit"
loading={isLoading()}
variant={showSuccess && successText ? "success" : rest.variant}
{...rest}
>
{showSuccess && successText ? successText : children}
</Button>
);
}

View File

@ -1,44 +0,0 @@
import { Tab as HeadlessUITab } from "@headlessui/react";
import clsx from "clsx";
import * as React from "react";
export function Tab({
tabs,
containerClassName,
tabListClassName,
defaultIndex,
}: {
tabs: {
id: string;
title: React.ReactNode;
content: React.ReactNode;
}[];
containerClassName?: string;
tabListClassName?: string;
defaultIndex?: number;
}) {
return (
<div className={clsx(containerClassName)}>
<HeadlessUITab.Group
defaultIndex={defaultIndex}
onChange={() => scrollTo(0, 0)}
>
<HeadlessUITab.List className={clsx("tab-list", tabListClassName)}>
{tabs.map(({ id, title }) => (
<HeadlessUITab
key={id}
className={({ selected }) => clsx("tab", { selected })}
>
{title}
</HeadlessUITab>
))}
</HeadlessUITab.List>
<HeadlessUITab.Panels className="mt-2">
{tabs.map(({ id, content }) => (
<HeadlessUITab.Panel key={id}>{content}</HeadlessUITab.Panel>
))}
</HeadlessUITab.Panels>
</HeadlessUITab.Group>
</div>
);
}

View File

@ -1,18 +0,0 @@
interface WeaponImageProps
extends React.ButtonHTMLAttributes<HTMLImageElement> {
weapon: string;
}
export function WeaponImage(props: WeaponImageProps) {
return (
<img
src={`/img/weapons/${encodeURIComponent(
props.weapon.replaceAll(".", "")
)}.webp`}
alt={props.weapon}
title={props.weapon}
loading="lazy"
{...props}
/>
);
}

View File

@ -1,4 +1,4 @@
import { CSSProperties } from "react";
import type { CSSProperties } from "react";
export function ArrowUpIcon({
className,

View File

@ -0,0 +1,14 @@
export function TwitchIcon({ className }: { className?: string }) {
return (
<svg
stroke="currentColor"
fill="currentColor"
strokeWidth="0"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M391.17,103.47H352.54v109.7h38.63ZM285,103H246.37V212.75H285ZM120.83,0,24.31,91.42V420.58H140.14V512l96.53-91.42h77.25L487.69,256V0ZM449.07,237.75l-77.22,73.12H294.61l-67.6,64v-64H140.14V36.58H449.07Z"></path>
</svg>
);
}

View File

@ -0,0 +1,16 @@
export function UserIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@ -0,0 +1,14 @@
export function YouTubeIcon({ className }: { className?: string }) {
return (
<svg
stroke="currentColor"
fill="currentColor"
strokeWidth="0"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M8.051 1.999h.089c.822.003 4.987.033 6.11.335a2.01 2.01 0 0 1 1.415 1.42c.101.38.172.883.22 1.402l.01.104.022.26.008.104c.065.914.073 1.77.074 1.957v.075c-.001.194-.01 1.108-.082 2.06l-.008.105-.009.104c-.05.572-.124 1.14-.235 1.558a2.007 2.007 0 0 1-1.415 1.42c-1.16.312-5.569.334-6.18.335h-.142c-.309 0-1.587-.006-2.927-.052l-.17-.006-.087-.004-.171-.007-.171-.007c-1.11-.049-2.167-.128-2.654-.26a2.007 2.007 0 0 1-1.415-1.419c-.111-.417-.185-.986-.235-1.558L.09 9.82l-.008-.104A31.4 31.4 0 0 1 0 7.68v-.123c.002-.215.01-.958.064-1.778l.007-.103.003-.052.008-.104.022-.26.01-.104c.048-.519.119-1.023.22-1.402a2.007 2.007 0 0 1 1.415-1.42c.487-.13 1.544-.21 2.654-.26l.17-.007.172-.006.086-.003.171-.007A99.788 99.788 0 0 1 7.858 2h.193zM6.4 5.209v4.818l4.157-2.408L6.4 5.209z"></path>
</svg>
);
}

View File

@ -360,11 +360,13 @@ export function DrawingSection({ type }: { type: "girl" | "boy" }) {
<img
className={clsx("menu__img", type)}
src={`/img/layout/new_${type}_dark.png`}
alt=""
/>
<img
className={clsx("menu__img-bg", type)}
src={`/img/layout/new_${type}_bg.png`}
style={{ filter: getFilters(hexCode) }}
alt=""
/>
</div>
);

View File

@ -12,6 +12,7 @@ export function HamburgerButton({
className="layout__burger"
onClick={onClick}
data-cy="hamburger-button"
type="button"
>
<svg
width="32"

View File

@ -1,8 +1,4 @@
import { Link } from "@remix-run/react";
import { navItemsGrouped } from "~/constants";
import { useOnClickOutside } from "~/hooks/common";
import { layoutIcon } from "~/utils";
import { discordUrl, gitHubUrl, patreonUrl, twitterUrl } from "~/utils/urls";
import { Button } from "../Button";
import { CrossIcon } from "../icons/Cross";
import { DiscordIcon } from "../icons/Discord";
@ -11,6 +7,15 @@ import { PatreonIcon } from "../icons/Patreon";
import { TwitterIcon } from "../icons/Twitter";
import DrawingSection from "./DrawingSection";
import * as React from "react";
import { navItemsGrouped } from "~/constants";
import { useOnClickOutside } from "~/hooks/useOnClickOutside";
import {
SENDOU_INK_DISCORD_URL,
SENDOU_INK_GITHUB_URL,
SENDOU_INK_PATREON_URL,
SENDOU_INK_TWITTER_URL,
} from "~/utils/urls";
import { layoutIcon } from "~/utils/images";
export function Menu({ close }: { close: () => void }) {
const ref = React.useRef(null);
@ -22,7 +27,12 @@ export function Menu({ close }: { close: () => void }) {
<DrawingSection type="boy" />
<div className="menu__top-extras">
<div className="menu__logo-container">
<img height="20" width="20" src={layoutIcon("logo")} />
<img
height="20"
width="20"
src={layoutIcon("logo")}
alt="Logo of sendou.ink"
/>
sendou.ink
</div>
<Button onClick={close} variant="minimal" aria-label="Close menu">
@ -44,22 +54,23 @@ export function Menu({ close }: { close: () => void }) {
src={layoutIcon(navItem.name.replace(" ", ""))}
width="32"
height="32"
alt=""
/>
{navItem.displayName ?? navItem.name}
</Link>
))}
</nav>
<div className="menu__icons-container">
<a href={gitHubUrl()}>
<a href={SENDOU_INK_GITHUB_URL}>
<GitHubIcon className="menu__icon" />
</a>
<a href={discordUrl()}>
<a href={SENDOU_INK_DISCORD_URL}>
<DiscordIcon className="menu__icon" />
</a>
<a href={twitterUrl()}>
<a href={SENDOU_INK_TWITTER_URL}>
<TwitterIcon className="menu__icon" />
</a>
<a href={patreonUrl()}>
<a href={SENDOU_INK_PATREON_URL}>
<PatreonIcon className="menu__icon" />
</a>
</div>

View File

@ -37,6 +37,7 @@ export function MobileMenu({
disabled: navItem.disabled,
})}
src={`/img/layout/${navItem.name.replace(" ", "")}.webp`}
alt={navItem.name}
/>
<div>{navItem.displayName ?? navItem.name}</div>
</Link>

View File

@ -2,7 +2,6 @@ import clsx from "clsx";
import * as React from "react";
export function SearchInput() {
// TODO: search input that searches
if (process.env.NODE_ENV !== "development") return <div />;
return <SearchInputDev />;
}

View File

@ -0,0 +1,49 @@
import { Link } from "@remix-run/react";
import { useUser } from "~/hooks/useUser";
import { LOG_IN_URL, LOG_OUT_URL, userPage } from "~/utils/urls";
import { Avatar } from "../Avatar";
import { Button } from "../Button";
import { DiscordIcon } from "../icons/Discord";
import { LogOutIcon } from "../icons/LogOut";
import { UserIcon } from "../icons/User";
import { Popover } from "../Popover";
export function UserItem() {
const user = useUser();
if (user && user.discordAvatar)
return (
<Popover trigger={<Avatar {...user} />}>
<div className="layout__user-popover">
{/* TODO: make the Button component transformable to Link instead of creating a wrapper */}
<Link to={userPage(user.discordId)}>
<Button
className="w-full"
tiny
variant="outlined"
icon={<UserIcon />}
>
Profile
</Button>
</Link>
<form method="post" action={LOG_OUT_URL}>
<Button tiny variant="outlined" icon={<LogOutIcon />} type="submit">
Log out
</Button>
</form>
</div>
</Popover>
);
return (
<form action={LOG_IN_URL} method="post" data-cy="log-in-form">
<button
type="submit"
className="layout__log-in-button"
data-cy="log-in-button"
>
<DiscordIcon /> Log in
</button>
</form>
);
}

View File

@ -1,7 +1,6 @@
import { useMatches } from "@remix-run/react";
import * as React from "react";
import { PAGE_TITLE_KEY } from "~/constants";
import { useWindowSize } from "~/hooks/common";
import { useWindowSize } from "~/hooks/useWindowSize";
import { HamburgerButton } from "./HamburgerButton";
import { Menu } from "./Menu";
import { MobileMenu } from "./MobileMenu";
@ -21,14 +20,16 @@ export const Layout = React.memo(function Layout({
}: LayoutProps) {
const matches = useMatches();
const pageTitleKey = "pageTitle";
// you can set this page title from any loader
// deeper routes take precedence
const pageTitle = matches
.map((match) => match.data)
.filter(Boolean)
.reduceRight((acc: Nullable<string>, routeData) => {
if (!acc && typeof routeData[PAGE_TITLE_KEY] === "string") {
return routeData[PAGE_TITLE_KEY] as string;
.reduceRight((acc: string | null, routeData) => {
if (!acc && typeof routeData[pageTitleKey] === "string") {
return routeData[pageTitleKey] as string;
}
return acc;
@ -50,7 +51,7 @@ export const Layout = React.memo(function Layout({
</div>
</header>
<ScreenWidthSensitiveMenu menuOpen={menuOpen} setMenuOpen={setMenuOpen} />
<main className="layout__main">{children}</main>
{children}
</>
);
});

View File

@ -1,50 +0,0 @@
import { LFGMatchLoaderData } from "~/routes/play/match.$id";
import { Unpacked } from "~/utils";
import { WeaponImage } from "../WeaponImage";
import SplatnetIcon from "./SplatnetIcon";
export function DetailedPlayers({
players,
bravo,
}: {
players: Unpacked<
NonNullable<Unpacked<LFGMatchLoaderData["mapList"]>["detail"]>["teams"]
>["players"];
bravo?: boolean;
}) {
return (
<div className="play-match__teams-players">
{players
.sort((a, b) => b.assists + b.kills - (a.assists + a.kills))
.map((player) => (
<div key={player.principalId} className="play-match__player-row">
<WeaponImage
className="play-match__player-row__weapon"
weapon={player.weapon}
/>
<div className="play-match__player-row__name">
{player.name}
<div className="play-match__player-row__paint">
{player.paint}p
</div>
</div>
<div className="play-match__player-row__splat-net-icons">
<SplatnetIcon
icon="kills"
count={player.kills + player.assists}
smallCount={player.assists}
bravo={bravo}
/>
<SplatnetIcon icon="deaths" count={player.deaths} bravo={bravo} />
<SplatnetIcon
// @ts-expect-error Elsewhere making sure player.weapon is in fact a weapon
icon={player.weapon}
count={player.specials}
bravo={bravo}
/>
</div>
</div>
))}
</div>
);
}

View File

@ -1,41 +0,0 @@
import { Form, useLoaderData } from "@remix-run/react";
import { LookingLoaderData } from "~/routes/play/looking";
import { Button } from "../Button";
import { Chat } from "../Chat";
import { GroupCard } from "./GroupCard";
export function FinishedGroup() {
const data = useLoaderData<LookingLoaderData>();
return (
<div>
{data.ownGroup.members && (
<Chat
id={data.ownGroup.id}
users={Object.fromEntries(
data.ownGroup.members.map((m) => [
m.id,
{ name: m.discordName, info: m.friendCode },
])
)}
/>
)}
<div className="play-looking__waves">
<GroupCard group={data.ownGroup} showAction={false} />
</div>
<div className="play-looking__waves-button">
<Form method="post">
<Button
type="submit"
name="_action"
value="LOOK_AGAIN"
tiny
variant="outlined"
>
Look again
</Button>
</Form>
</div>
</div>
);
}

View File

@ -1,209 +0,0 @@
import clsx from "clsx";
import { useFetcher } from "@remix-run/react";
import { Button, ButtonProps } from "~/components/Button";
import type {
LookingActionSchema,
LookingLoaderDataGroup,
} from "~/routes/play/looking";
import { ArrowUpIcon } from "../icons/ArrowUp";
import { GroupMembers } from "./GroupMembers";
export function GroupCard({
group,
action,
showAction,
ranked,
ownGroup,
isOwnGroup = false,
}: {
group: LookingLoaderDataGroup;
action?: Exclude<LookingActionSchema["_action"], "UNEXPIRE">;
showAction: boolean;
ranked?: boolean;
ownGroup?: {
ranked?: boolean;
league: boolean;
};
isOwnGroup?: boolean;
}) {
const fetcher = useFetcher();
const buttonText = (otherGroupRanked = false) => {
switch (action) {
case "LEAVE_GROUP":
return "Leave group";
case "LIKE":
if (ownGroup?.league) return "Let's play league?";
// when we ask for other team to group up it takes their ranked status
return otherGroupRanked ? "Let's play ranked?" : "Let's scrim?";
case "UNLIKE":
return "Undo";
case "UNITE_GROUPS":
if (ownGroup?.league) return "Group up";
// when we group up the new group takes our ranked status
return ownGroup?.ranked ? "Group up (ranked)" : "Group up (scrim)";
case "MATCH_UP":
if (ownGroup?.league) return "Match up";
// when we match up with other group it takes their ranked status
return otherGroupRanked ? "Match up (ranked)" : "Match up (scrim)";
case "LOOK_AGAIN":
return "Stop looking";
default:
throw new Error(`Invalid group action type: ${action ?? "UNDEFINED"}`);
}
};
const buttonVariant = (): ButtonProps["variant"] => {
switch (action) {
case "LEAVE_GROUP":
case "LOOK_AGAIN":
return "minimal-destructive";
case "UNLIKE":
return "destructive";
default:
return undefined;
}
};
return (
<fetcher.Form method="post">
<div className="play__card">
{typeof ranked === "boolean" && (
<div className={clsx("play__card__ranked-text", { ranked })}>
{ranked ? "Ranked" : "Scrim"}
</div>
)}
<GroupMembers members={group.members} />
{group.MMRRelation && !group.replay && (
<MMRRelation relation={group.MMRRelation} />
)}
{group.replay && <div className="play__card__replay">Replay</div>}
<input type="hidden" name="targetGroupId" value={group.id} />
{action === "UNITE_GROUPS" && (
<input
type="hidden"
name="targetGroupSize"
value={group.members?.length ?? -1}
/>
)}
{showAction && (
<Button
className={clsx({ "play__card__button-small": isOwnGroup })}
type="submit"
name="_action"
value={action}
tiny
variant={buttonVariant()}
loading={fetcher.state !== "idle"}
>
{buttonText(group.ranked)}
</Button>
)}
</div>
</fetcher.Form>
);
}
function MMRRelation({
relation,
}: {
relation: NonNullable<LookingLoaderDataGroup["MMRRelation"]>;
}) {
const labelText = () => {
switch (relation) {
case "LOT_LOWER": {
return "A lot lower";
}
case "LOWER": {
return "Lower";
}
case "BIT_LOWER": {
return "A bit lower";
}
case "CLOSE": {
return "Close";
}
case "BIT_HIGHER": {
return "A bit higher";
}
case "HIGHER": {
return "Higher";
}
case "LOT_HIGHER": {
return "A lot higher";
}
}
};
const gridColumn = () => {
const relationsOrdered: NonNullable<
LookingLoaderDataGroup["MMRRelation"]
>[] = [
"LOT_LOWER",
"LOWER",
"BIT_LOWER",
"CLOSE",
"BIT_HIGHER",
"HIGHER",
"LOT_HIGHER",
];
return {
gridColumn: `${relationsOrdered.indexOf(relation) + 1} / ${
relationsOrdered.indexOf(relation) + 2
}`,
};
};
return (
<div className="play__card__mmr-relation-bar__container">
<div className="play__card__mmr-relation-bar__label">
{labelText()} SP
</div>
<div className="play__card__mmr-relation-bar">
<div
className={clsx("play__card__mmr-relation-bar__1", {
"play__card__mmr-relation-bar__active": relation === "LOT_LOWER",
})}
/>
<div
className={clsx("play__card__mmr-relation-bar__2", {
"play__card__mmr-relation-bar__active": relation === "LOWER",
})}
/>
<div
className={clsx("play__card__mmr-relation-bar__3", {
"play__card__mmr-relation-bar__active": relation === "BIT_LOWER",
})}
/>
<div
className={clsx("play__card__mmr-relation-bar__4", {
"play__card__mmr-relation-bar__active": relation === "CLOSE",
})}
/>
<div
className={clsx("play__card__mmr-relation-bar__5", {
"play__card__mmr-relation-bar__active": relation === "BIT_HIGHER",
})}
/>
<div
className={clsx("play__card__mmr-relation-bar__6", {
"play__card__mmr-relation-bar__active": relation === "HIGHER",
})}
/>
<div
className={clsx("play__card__mmr-relation-bar__7", {
"play__card__mmr-relation-bar__active": relation === "LOT_HIGHER",
})}
/>
</div>
<div className="play__card__mmr-relation-bar">
<ArrowUpIcon
className="play__card__mmr-relation-bar__indicator"
style={gridColumn()}
/>
</div>
</div>
);
}

View File

@ -1,105 +0,0 @@
import type { LookingLoaderDataGroup } from "~/routes/play/looking";
import { layoutIcon } from "~/utils";
import { oldSendouInkUserProfile } from "~/utils/urls";
import { Avatar } from "../Avatar";
import { Popover } from "../Popover";
import { WeaponImage } from "../WeaponImage";
export function GroupMembers({
members,
}: {
members: LookingLoaderDataGroup["members"];
}) {
return (
<div className="play__card__members">
<Contents members={members} />
</div>
);
}
function Contents({ members }: { members: LookingLoaderDataGroup["members"] }) {
if (!members) {
return (
<>
{new Array(4).fill(null).map((_, i) => {
return (
<div key={i} className="play__card__member-card">
<Avatar
size="tiny"
user={{ discordId: "", discordAvatar: null }}
/>
<span className="play__card__member-name">???</span>
</div>
);
})}
</>
);
}
return (
<>
{members?.map((member) => {
return (
<div key={member.id} className="play__card__member-card">
<a
href={oldSendouInkUserProfile({ discordId: member.discordId })}
target="_blank"
rel="noopener noreferrer"
className="play__card__member-link"
>
<Avatar size="tiny" user={member} />
<span className="play__card__member-name">
{member.discordName}{" "}
{member.captain && (
<span className="play__card__captain">C</span>
)}
</span>
</a>
{member.MMR && (
<div className="play__card__member-mmr">SP: {member.MMR}</div>
)}
{member.peakXP && (
<div className="play__card__member-power">
<img
src={layoutIcon("top500")}
width="18"
height="18"
title="Peak X Power"
/>{" "}
{member.peakXP}
</div>
)}
{member.peakLP && (
<div className="play__card__member-power league">
<img
// TODO: actual league icon
src={layoutIcon("top500")}
width="18"
height="18"
title="Peak League Power"
/>{" "}
{member.peakLP}
</div>
)}
{member.weapons && (
<div className="play__card__member-weapons">
{member.weapons.map((wpn) => (
<WeaponImage
className="play__card__member-weapon"
key={wpn}
weapon={wpn}
/>
))}{" "}
</div>
)}
<div className="play__card__info">
{member.miniBio && (
<Popover trigger="INFO">{member.miniBio}</Popover>
)}
</div>
</div>
);
})}
</>
);
}

View File

@ -1,128 +0,0 @@
import * as React from "react";
import { RadioGroup } from "@headlessui/react";
import { layoutIcon } from "~/utils";
import clsx from "clsx";
import { PlayFrontPageLoader } from "~/routes/play/index";
import { UsersIcon } from "../icons/Users";
const OPTIONS = [
{
type: "VERSUS-RANKED",
image: "rotations",
text: "Versus",
explanation: "Ranked",
},
{
type: "VERSUS-UNRANKED",
image: "rotations",
text: "Versus",
explanation: "Scrim",
},
{
type: "TWIN",
image: "rotations",
text: "Twin",
explanation: "League Battle",
},
{
type: "QUAD",
image: "rotations",
text: "Quad",
explanation: "League Battle",
},
] as const;
type Type = "VERSUS-RANKED" | "VERSUS-UNRANKED" | "TWIN" | "QUAD";
export function LFGGroupSelector({
counts,
}: {
counts: PlayFrontPageLoader["counts"];
}) {
const [selectedType, setSelectedType] = React.useState<Type>("VERSUS-RANKED");
const count = (
type: "VERSUS-RANKED" | "VERSUS-UNRANKED" | "TWIN" | "QUAD"
) => {
switch (type) {
case "VERSUS-RANKED":
case "VERSUS-UNRANKED":
return counts["VERSUS"];
case "QUAD":
return counts["QUAD"];
case "TWIN":
return counts["TWIN"];
}
};
return (
<>
<input type="hidden" name="type" value={selectedType} />
<RadioGroup
className="play__type-radio-group"
value={selectedType}
onChange={setSelectedType}
>
{OPTIONS.map((option, i) => {
return (
<RadioGroup.Option
key={i}
className={clsx({ "mt-3": i > 1 })}
value={option.type}
>
{({ checked }) => (
<div
className={clsx("play__type-radio-group__item", {
checked,
"top-half": i === 0,
"bottom-half": i === 1,
})}
>
<label className="play__type-radio-group__label">
{option.text}
<span
className={clsx(
"play__type-radio-group__label__explanation",
{ checked }
)}
>
{option.explanation}
</span>
<span
className={clsx("play__type-radio-group__label__count", {
checked,
"scooted-over": i === 0,
invisible: i === 1,
})}
>
<UsersIcon className="play__type-radio-group__label__icon" />
<span
className={clsx("z-10", {
"play__forced-black-number":
option.type === "VERSUS-RANKED" &&
selectedType === "VERSUS-UNRANKED",
"play__forced-white-number":
option.type === "VERSUS-RANKED" &&
selectedType === "VERSUS-RANKED",
})}
>
{count(option.type)}
</span>
</span>
</label>
{/* TODO: remove */}
<img
className={clsx("play__type-radio-group__image", {
checked,
})}
src={layoutIcon(option.image)}
/>
</div>
)}
</RadioGroup.Option>
);
})}
</RadioGroup>
</>
);
}

View File

@ -1,65 +0,0 @@
import clsx from "clsx";
import * as React from "react";
import { Form, useLoaderData } from "@remix-run/react";
import {
groupExpirationStatus,
groupWillBeInactiveAt,
} from "~/core/play/utils";
import { LookingLoaderData } from "~/routes/play/looking";
import { Button } from "../Button";
const CONTAINER_CLASSNAME = "play-looking__info-text";
export function LookingInfoText({ lastUpdated }: { lastUpdated: Date }) {
const [, forceUpdate] = React.useState(Math.random());
const data = useLoaderData<LookingLoaderData>();
React.useEffect(() => {
const timer = setInterval(() => {
forceUpdate(Math.random());
}, 10000); // 10 seconds
return () => clearInterval(timer);
}, []);
if (groupExpirationStatus(data.lastActionAtTimestamp)) {
const text =
groupExpirationStatus(data.lastActionAtTimestamp) === "EXPIRED"
? "Your group has been hidden due to inactivity"
: `Without any activity your group will be hidden at ${groupWillBeInactiveAt(
data.lastActionAtTimestamp
).toLocaleTimeString("en", { hour: "numeric", minute: "numeric" })}`;
return (
<Form method="post">
<div
className={clsx(CONTAINER_CLASSNAME, {
expired:
groupExpirationStatus(data.lastActionAtTimestamp) === "EXPIRED",
})}
>
{text}. Click{" "}
<Button
className="play-looking__info-button"
variant="minimal"
name="_action"
value="UNEXPIRE"
>
here
</Button>{" "}
if you are still looking.
</div>
</Form>
);
}
return (
<div className={CONTAINER_CLASSNAME}>
Last updated:{" "}
{lastUpdated.toLocaleTimeString("en", {
hour: "numeric",
minute: "numeric",
second: "numeric",
})}
</div>
);
}

View File

@ -1,238 +0,0 @@
import { Mode } from "@prisma/client";
import clsx from "clsx";
import clone from "just-clone";
import * as React from "react";
import { Form, useLoaderData } from "@remix-run/react";
import { scoreValid } from "~/core/play/validators";
import { LFGMatchLoaderData } from "~/routes/play/match.$id";
import { userFullDiscordName } from "~/utils";
import { Button } from "../Button";
import { ModeImage } from "../ModeImage";
import { SubmitButton } from "../SubmitButton";
const NO_RESULT = "NO_RESULT";
interface MapListProps {
mapList: {
name: string;
mode: Mode;
}[];
reportedWinnerIds: string[];
canSubmitScore: boolean;
groupIds: {
our: string;
their: string;
};
}
export function MapList({
mapList,
reportedWinnerIds,
canSubmitScore,
groupIds,
}: MapListProps) {
const [winners, setWinners] = React.useState<string[]>(reportedWinnerIds);
const [cancelModeEnabled, setCancelModeEnabled] = React.useState(false);
const updateWinners = (winnerId: string, index: number) => {
const newWinners = clone(winners);
// we make sure this option is only available for the last score
if (winnerId === NO_RESULT) {
newWinners.pop();
} else if (index === newWinners.length) {
newWinners.push(winnerId);
} else {
newWinners[index] = winnerId;
}
setWinners(newWinners);
};
const selectInvisible = (index: number) => {
if (scoreValid(winners, mapList.length) && winners.length <= index) {
return true;
}
if (index > winners.length) return true;
return false;
};
if (cancelModeEnabled)
return (
<CancelMatch disableCancelMode={() => setCancelModeEnabled(false)} />
);
return (
<ol className="play-match__stages">
<h2 className="play-match__map-list-header">Map list</h2>
<div className="play-match__best-of">Best of {mapList.length}</div>
{canSubmitScore && (
<li className="play-match__select-column-header">
<span>Winner</span>
</li>
)}
{mapList.map((stage, i) => {
return (
<li key={`${stage.name}-${stage.mode}`} className="play-match__stage">
{canSubmitScore && (
<select
className={clsx("play-match__select", {
invisible: selectInvisible(i),
})}
onChange={(e) => updateWinners(e.target.value, i)}
value={winners[i] ?? NO_RESULT}
>
<option value={NO_RESULT}></option>
<option value={groupIds.our}>Us</option>
<option value={groupIds.their}>Them</option>
</select>
)}
<ModeImage className="play-match__mode" mode={stage.mode} />
{i + 1}){" "}
<span className="play-match__stage-name">{stage.name}</span>
</li>
);
})}
{canSubmitScore && (
<Submitter
mapList={mapList}
winners={winners}
groupIds={groupIds}
isFirstTimeReporting={reportedWinnerIds.length === 0}
enableCancelMode={() => setCancelModeEnabled(true)}
/>
)}
</ol>
);
}
function Submitter({
mapList,
winners,
groupIds,
isFirstTimeReporting,
enableCancelMode,
}: {
mapList: MapListProps["mapList"];
winners: string[];
groupIds: {
our: string;
their: string;
};
isFirstTimeReporting: boolean;
enableCancelMode: () => void;
}) {
const warningText = scoreValid(winners, mapList.length)
? undefined
: "Report more maps to submit the score";
if (warningText) {
return (
<div className="play-match__error-text">
{warningText}
<div>
<div className="flex flex-col">
or{" "}
<Button
variant="minimal-destructive"
tiny
onClick={enableCancelMode}
>
Cancel Match
</Button>
</div>
</div>
</div>
);
}
const score = winners.reduce(
(acc: [number, number], winnerId) => {
if (winnerId === groupIds.our) acc[0]++;
else acc[1]++;
return acc;
},
[0, 0]
);
return (
<div className="play-match__score-submit-button">
<Form method="post">
<input type="hidden" name="winnerIds" value={JSON.stringify(winners)} />
<SubmitButton
type="submit"
name="_action"
value={isFirstTimeReporting ? "REPORT_SCORE" : "EDIT_REPORTED_SCORE"}
actionType={
isFirstTimeReporting ? "REPORT_SCORE" : "EDIT_REPORTED_SCORE"
}
loadingText="Submitting..."
>
Submit {score.join("-")}
</SubmitButton>
</Form>
</div>
);
}
function CancelMatch({ disableCancelMode }: { disableCancelMode: () => void }) {
const data = useLoaderData<LFGMatchLoaderData>();
return (
<div className="play-match__cancel-match">
<h2 className="play-match__map-list-header">Cancel match</h2>
<p>
You should only cancel the match if one player can&apos;t be reached
(give them at least 15 minutes to answer) or becomes unavailable to play
(either before the set or in the middle of it).
</p>
<p>
When canceling the match the team with 4 players available to play gains
SP as if they had played and won the set. The player who is not
available to play loses SP as if they played and lost the set. The
teammates of the player who left will not have a change in their
SP&apos;s.
</p>
<Form className="play-match__cancel-match__form" method="post">
<h4>Choose missing player</h4>
<div className="play-match__cancel-match__radios">
{data.groups
.flatMap((g) => g.members)
.map((m) => (
<span
key={m.id}
title={userFullDiscordName(m)}
className="flex items-center"
>
<input
id={m.id}
type="radio"
name="cancelCausingUserId"
value={m.id}
required
className="mr-1"
/>
<label htmlFor={m.id}>{m.discordName}</label>
</span>
))}
</div>
<div className="flex items-center mt-2">
<Button
type="submit"
variant="destructive"
name="_action"
value="CANCEL_MATCH"
tiny
className="mr-3"
>
Cancel match
</Button>
<Button tiny type="button" onClick={disableCancelMode}>
Nevermind
</Button>
</div>
</Form>
</div>
);
}

View File

@ -1,144 +0,0 @@
import clsx from "clsx";
import { useLoaderData } from "@remix-run/react";
import { LFGMatchLoaderData } from "~/routes/play/match.$id";
import { userFullDiscordName } from "~/utils";
import { weaponsInGameOrder } from "~/utils/sorters";
import {
oldSendouInkPlayerProfile,
oldSendouInkUserProfile,
} from "~/utils/urls";
import { Avatar } from "../Avatar";
import { WeaponImage } from "../WeaponImage";
// TODO: make the whole thing one grid to align stuff better
export function MatchTeams() {
const data = useLoaderData<LFGMatchLoaderData>();
const detailedGroups = (() => {
if (!data.mapList.some((map) => map.detail)) return;
const result: {
[groupId: string]: {
weapons: Set<string>;
name: string;
principalId: string;
}[];
} = { [data.groups[0].id]: [], [data.groups[1].id]: [] };
for (const stage of data.mapList) {
if (typeof stage.winner !== "number") break;
for (const team of stage.detail?.teams ?? []) {
const groupId =
data.groups[team.isWinner ? stage.winner : Number(!stage.winner)].id;
for (const player of team.players) {
const playerObj = result[groupId].find(
(p) => p.principalId === player.principalId
);
if (playerObj) playerObj.weapons.add(player.weapon);
else {
result[groupId].push({
name: player.name,
principalId: player.principalId,
weapons: new Set([player.weapon]),
});
}
}
}
}
return result;
})();
return (
<div className="play-match__teams">
{data.groups.map((g, i) => {
return (
<div
key={g.id}
className="play-match__waves-section play-match__team-info"
>
{detailedGroups ? (
<>
{detailedGroups[g.id].map((player) => (
<a
key={player.principalId}
href={oldSendouInkPlayerProfile({
principalId: player.principalId,
})}
target="_blank"
rel="noopener noreferrer"
>
<div className="play-match__player">
<span className="play-match__player-name">
{player.name}
</span>
<span className="play-match__weapons">
{Array.from(player.weapons)
.sort(weaponsInGameOrder)
.map((weapon) => (
<WeaponImage
className="play-match__weapon-img"
key={weapon}
weapon={weapon}
/>
))}
</span>
</div>
</a>
))}
<div className="play-match__player-list">
{g.members.map((user) => (
<a
key={user.id}
href={oldSendouInkUserProfile({
discordId: user.discordId,
})}
target="_blank"
rel="noopener noreferrer"
>
<div className="play-match__player row">
<span className="play-match__player-name">
{user.discordName}#{user.discordDiscriminator}
</span>
</div>
</a>
))}
</div>
</>
) : (
g.members.map((user) => (
<a
key={user.id}
href={oldSendouInkUserProfile({
discordId: user.discordId,
})}
target="_blank"
rel="noopener noreferrer"
title={userFullDiscordName(user)}
>
<div className="play-match__player">
<Avatar user={user} />
<span className="play-match__player-name">
{user.discordName}
</span>
</div>
</a>
))
)}
{data.scores && (
<div
className={clsx("play-match__score", {
winner: data.scores[i] === Math.max(...data.scores),
})}
>
{data.scores[i]}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@ -1,244 +0,0 @@
export function AutoBombRush() {
return (
<svg
className="special-icon-svg special-icon6-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 107.96 107.96"
>
<g>
<rect
className="area"
width="107.96"
height="107.96"
rx="29.84"
ry="29.84"
></rect>
<ellipse
className="bg1"
cx="71.95"
cy="19.66"
rx="9.21"
ry="11.77"
transform="rotate(-22.97 71.955 19.668)"
></ellipse>
<ellipse
cx="70.15"
cy="20.33"
rx="5.04"
ry="7.11"
transform="rotate(-22.97 70.156 20.327)"
></ellipse>
<path d="M69.59 16.37c-1.78-2-1.09-2.25-3.1-2.88a24.83 24.83 0 0 0-5.49-.67S43.34 17.56 42.15 19s-3 7.41 7.36 6.69l20.08-9.32zm-7.43 14.89s1.58 1.94-1.33 4.4-18.55 3.82-20-1.58l1.33-6.87z"></path>
<path
className="gr30"
d="M41.65 48.24c.46-.21.44-.53 0-.7a2.58 2.58 0 0 0-1.7.07L36.55 49c-1.38-.79-1.36-1.53-.33-2l11.31-4.4a7.11 7.11 0 0 1 5.41-.13c1.83.47 2.15 2.12.74 2.8l-9.17 4.95c-2.31 1.07-4.19 1.43-6.7-.33z"
></path>
<path
className="gr29"
d="M51.11 39.57v2.35c0 1 1.22 2.83.17 3.2s-4-.56-4-2.71v-2.77a32.16 32.16 0 0 0 3.83-.07z"
></path>
<path
className="gr28"
d="M53.63 38.7v4.21a2.43 2.43 0 0 1-1.9 2.09 1.53 1.53 0 0 1-1.9-1.64v-4.3c1.27-.06 2.53-.21 3.8-.36z"
></path>
<path
className="gr27"
d="M61.4 34.08a27.66 27.66 0 0 1-13 1.66c-3.53-.4-6.25-1.41-7.93-2.81a22.47 22.47 0 0 1-1-3.69 12.49 12.49 0 0 1 2.25-9.82c-.36 1.3-.15 2.07 1.31 2.33a31.91 31.91 0 0 0 13.58-.29c1.43-.37 7.49-3.73 10.11-5a1.46 1.46 0 0 0 .3-2.44 6 6 0 0 0-1.78-.87A9.94 9.94 0 0 1 71.6 17c1.46 2.1 3 6.08.82 9.76-1.46 2.49-8.66 5-10.39 6.46a3.24 3.24 0 0 0-.63.86z"
></path>
<path
className="gr26"
d="M47.11 36.76a28.57 28.57 0 0 0 8.14 0A21.92 21.92 0 0 0 61 35.22a7.82 7.82 0 0 1-3.12 4.37 11.85 11.85 0 0 1-11.51.77c-2.59-1.11-4.37-3.54-5.52-6.28a12.62 12.62 0 0 0 6.26 2.68z"
></path>
<path
className="bg1"
d="M65.39 13.55a1.46 1.46 0 0 1-.25 2.45c-2.5 1.33-6 3.61-7.46 4-3.92 1.18-12.9.63-14 .27s-1.5-1.23-1.48-3.19v-4.09c0-1.37.17-3.08 2.36-3.47a66.06 66.06 0 0 1 13.1.39 17.55 17.55 0 0 1 7.73 3.64z"
></path>
<path d="M53.6 11a5.1 5.1 0 0 1 3.1 5c-.18 2.1-1.05 3.22-4 3.33a53.86 53.86 0 0 1-8.46-.33c-.94-.23-1.27-.88-1.34-2.63v-2.73c0-1.72.15-3 2.15-3.1 2.55-.14 6.68-.46 8.55.46z"></path>
<path
className="gr25"
d="M58.63 52.9c.43-.26.39-.61-.08-.77a2.25 2.25 0 0 0-1.65.19L53.77 54c-1.39-.78-1.47-1.53-.5-2.15L64 46.26a6.2 6.2 0 0 1 5.25-.49c1.81.4 2.22 2.19.9 3l-8.6 6c-2.51 1.66-4.53 1.5-6.53.07z"
></path>
<path
className="gr24"
d="M67.25 48.64c1-.36-.17-2.15-.17-3.2V33.5a1.31 1.31 0 0 0-1.9-1.24 3 3 0 0 0-1.9 2.56v11.11c0 2.16 2.93 3.07 3.97 2.71z"
></path>
<path
className="gr23"
d="M67.69 48.57a2.43 2.43 0 0 0 1.9-2.13V34.48a1.31 1.31 0 0 0-1.9-1.24 3 3 0 0 0-1.9 2.56v11.12a1.53 1.53 0 0 0 1.9 1.65z"
></path>
<ellipse
className="gr22"
cx="66.48"
cy="33.75"
rx="6.72"
ry="4.98"
transform="rotate(-60 66.48 33.752)"
></ellipse>
<ellipse
className="gr21"
cx="67.76"
cy="34.34"
rx="6.72"
ry="4.98"
transform="rotate(-60 67.758 34.34)"
></ellipse>
<ellipse cx="56.72" cy="27.71" rx="1.47" ry="1.99"></ellipse>
<ellipse cx="39.24" cy="25.34" rx="1.06" ry="1.49"></ellipse>
<path
className="bg1"
d="M62.91 53.9l-1.33.93c-2.51 1.66-4.53 1.5-6.53.07l3.57-2c.43-.26.39-.61-.08-.77a2.25 2.25 0 0 0-1.65.19L53.77 54c-1.39-.78-1.47-1.53-.5-2.15l2.36-1.24zm-21.3-6.36a2.59 2.59 0 0 0-1.7.07L36.55 49c-1.38-.79-1.36-1.53-.33-2l2.5-1 7.17 3.43-1.39.75c-2.31 1.07-4.19 1.43-6.7-.33l3.83-1.65c.47-.2.46-.49-.02-.66z"
></path>
<ellipse
className="bg1"
cx="45.01"
cy="51.99"
rx="9.76"
ry="12.48"
transform="rotate(-22.97 45.016 51.99)"
></ellipse>
<ellipse
cx="43.1"
cy="52.71"
rx="5.34"
ry="7.54"
transform="rotate(-22.97 43.103 52.704)"
></ellipse>
<path d="M42.51 48.51c-1.88-2.08-1.16-2.39-3.29-3.05a26.33 26.33 0 0 0-5.82-.71s-18.73 5-20 6.59-3.14 7.86 7.81 7.09l21.3-9.92zM34.63 64.3s1.67 2.05-1.41 4.7S13.55 73 12 67.29L13.41 60z"></path>
<path
className="gr20"
d="M12.87 82.3c.48-.22.47-.56 0-.74a2.74 2.74 0 0 0-1.8.08l-3.6 1.51C6 82.31 6 81.53 7.12 81l12-4.68a7.54 7.54 0 0 1 5.74-.14c1.94.5 2.28 2.24.78 3l-9.73 5.25c-2.45 1.14-4.45 1.52-7.1-.35z"
></path>
<path
className="gr19"
d="M22.91 73.11v2.49c0 1.11 1.29 3 .18 3.39s-4.21-.59-4.21-2.87v-2.94a34.1 34.1 0 0 0 4.03-.07z"
></path>
<path
className="gr18"
d="M25.58 72.18v4.47a2.58 2.58 0 0 1-2 2.26 1.62 1.62 0 0 1-2-1.74v-4.6c1.32-.07 2.66-.23 4-.39z"
></path>
<path
className="gr17"
d="M33.82 67.29A29.33 29.33 0 0 1 20 69c-3.74-.43-6.63-1.5-8.41-3a23.83 23.83 0 0 1-1-3.91 13.24 13.24 0 0 1 2.38-10.41c-.38 1.38-.15 2.2 1.39 2.47a33.84 33.84 0 0 0 14.4-.31c1.52-.39 7.94-4 10.72-5.27a1.54 1.54 0 0 0 .32-2.59 6.38 6.38 0 0 0-1.89-.93 10.54 10.54 0 0 1 6.76 4.08c1.55 2.23 3.16 6.45.87 10.35-1.55 2.64-9.18 5.32-11 6.84a3.44 3.44 0 0 0-.72.97z"
></path>
<path
className="gr16"
d="M18.67 70.13a30.3 30.3 0 0 0 8.63 0 23.25 23.25 0 0 0 6.07-1.67 8.3 8.3 0 0 1-3.31 4.64 12.56 12.56 0 0 1-12.21.82c-2.74-1.18-4.64-3.75-5.85-6.66a13.38 13.38 0 0 0 6.67 2.87z"
></path>
<path
className="bg1"
d="M38 45.52a1.55 1.55 0 0 1-.26 2.56c-2.65 1.41-6.39 3.83-7.91 4.28-4.11 1.25-13.63.64-14.83.29s-1.59-1.3-1.57-3.38v-4.34c0-1.45.18-3.27 2.5-3.68s11.23-.1 13.89.41A18.61 18.61 0 0 1 38 45.52z"
></path>
<path d="M25.55 42.83a5.4 5.4 0 0 1 3.29 5.33c-.19 2.23-1.12 3.41-4.22 3.53a57.12 57.12 0 0 1-9-.37c-1-.25-1.35-.93-1.42-2.79v-2.9c0-1.83.16-3.16 2.28-3.29 2.71-.16 7.09-.5 9.07.49z"></path>
<path
className="gr15"
d="M30.88 87.24c.46-.28.42-.64-.09-.81a2.38 2.38 0 0 0-1.75.2l-3.31 1.83c-1.48-.83-1.56-1.63-.53-2.28l11.42-6a6.57 6.57 0 0 1 5.57-.52c1.92.43 2.36 2.32 1 3.21L34 89.29c-2.66 1.76-4.8 1.59-6.93.07z"
></path>
<path
className="gr14"
d="M40 82.73c1.11-.39-.18-2.28-.18-3.39V66.67a1.38 1.38 0 0 0-2-1.31 3.14 3.14 0 0 0-2 2.72v11.77c-.01 2.29 3.1 3.26 4.18 2.88z"
></path>
<path
className="gr13"
d="M40.5 82.65a2.58 2.58 0 0 0 2-2.26V67.71a1.38 1.38 0 0 0-2-1.31 3.14 3.14 0 0 0-2 2.72V80.9a1.62 1.62 0 0 0 2 1.75z"
></path>
<ellipse
className="gr12"
cx="39.21"
cy="66.94"
rx="7.13"
ry="5.28"
transform="rotate(-60 39.202 66.94)"
></ellipse>
<ellipse
className="gr11"
cx="40.56"
cy="67.56"
rx="7.13"
ry="5.28"
transform="rotate(-60 40.563 67.563)"
></ellipse>
<ellipse cx="28.85" cy="60.53" rx="1.55" ry="2.11"></ellipse>
<ellipse cx="10.32" cy="58.01" rx="1.13" ry="1.58"></ellipse>
<path
className="bg1"
d="M35.43 88.3l-1.41 1c-2.66 1.76-4.8 1.59-6.93.07l3.78-2.12c.46-.28.42-.64-.09-.81a2.38 2.38 0 0 0-1.75.2l-3.31 1.83c-1.48-.83-1.56-1.63-.53-2.28l2.5-1.31zm-22.6-6.74a2.74 2.74 0 0 0-1.8.08l-3.56 1.51C6 82.31 6 81.53 7.12 81l2.65-1 7.6 3.64-1.47.79c-2.45 1.14-4.45 1.52-7.1-.35l4.07-1.75c.49-.26.47-.59-.04-.77z"
></path>
<ellipse
className="bg1"
cx="90.56"
cy="61.92"
rx="9.9"
ry="12.65"
transform="rotate(-22.97 90.56 61.91)"
></ellipse>
<ellipse
cx="88.63"
cy="62.65"
rx="5.41"
ry="7.64"
transform="rotate(-22.97 88.642 62.648)"
></ellipse>
<path d="M88 58.39c-1.88-2.11-1.15-2.39-3.3-3.09a26.68 26.68 0 0 0-5.9-.72s-19 5.09-20.27 6.68-3.18 8 7.91 7.19L88 58.39zm-8 16s1.7 2.08-1.43 4.73-19.94 4.1-21.51-1.7L58.53 70z"></path>
<path
className="gr10"
d="M58 92.64c.49-.23.47-.56 0-.75a2.78 2.78 0 0 0-1.83.08l-3.66 1.53c-1.48-.85-1.46-1.64-.35-2.18l12.15-4.74a7.64 7.64 0 0 1 5.82-.14c2 .51 2.31 2.27.79 3l-9.86 5.32c-2.48 1.15-4.51 1.54-7.2-.35z"
></path>
<path
className="gr9"
d="M68.17 83.32v2.52c0 1.12 1.31 3 .19 3.44s-4.27-.6-4.27-2.91v-3a34.56 34.56 0 0 0 4.08-.05z"
></path>
<path
className="gr8"
d="M70.87 82.38v4.53a2.62 2.62 0 0 1-2 2.29 1.64 1.64 0 0 1-2-1.77v-4.65c1.28-.07 2.64-.24 4-.4z"
></path>
<path
className="gr7"
d="M79.22 77.42a29.72 29.72 0 0 1-14 1.78c-3.79-.43-6.72-1.52-8.52-3a24.15 24.15 0 0 1-1.06-4A13.42 13.42 0 0 1 58 61.67c-.39 1.39-.16 2.23 1.41 2.51A34.3 34.3 0 0 0 74 63.87c1.54-.4 8.05-4 10.87-5.34a1.57 1.57 0 0 0 .32-2.63 6.47 6.47 0 0 0-1.86-.9 10.68 10.68 0 0 1 6.85 4.14c1.57 2.26 3.2 6.53.88 10.49-1.57 2.68-9.3 5.39-11.16 6.94a3.49 3.49 0 0 0-.68.85z"
></path>
<path
className="gr6"
d="M63.86 80.3a30.71 30.71 0 0 0 8.75 0 23.56 23.56 0 0 0 6.15-1.7 8.41 8.41 0 0 1-3.36 4.7 12.73 12.73 0 0 1-12.4.87c-2.78-1.2-4.7-3.8-5.93-6.75a13.56 13.56 0 0 0 6.79 2.88z"
></path>
<path
className="bg1"
d="M83.51 55.36a1.57 1.57 0 0 1-.27 2.6c-2.69 1.43-6.48 3.88-8 4.34-4.21 1.27-13.86.68-15.08.29s-1.61-1.32-1.59-3.43v-4.4c0-1.47.19-3.31 2.53-3.73s11.38-.11 14.07.42a18.87 18.87 0 0 1 8.34 3.91z"
></path>
<path d="M70.84 52.64A5.48 5.48 0 0 1 74.17 58C74 60.3 73 61.49 69.9 61.62a57.89 57.89 0 0 1-9.11-.38c-1-.25-1.36-.94-1.44-2.83v-2.94c0-1.85.16-3.2 2.32-3.33 2.72-.14 7.16-.51 9.17.5z"></path>
<path
className="gr5"
d="M76.24 97.65c.46-.28.42-.65-.09-.82a2.42 2.42 0 0 0-1.77.2L71 98.88c-1.5-.84-1.58-1.65-.54-2.31l11.57-6.06a6.66 6.66 0 0 1 5.68-.51c1.94.43 2.39 2.35 1 3.26l-9.25 6.48c-2.69 1.79-4.87 1.61-7 .07z"
></path>
<path
className="gr4"
d="M85.51 93.07c1.12-.39-.19-2.31-.19-3.44V76.79a1.4 1.4 0 0 0-2-1.33 3.18 3.18 0 0 0-2 2.76v11.94c-.08 2.31 3.07 3.3 4.19 2.91z"
></path>
<path
className="gr3"
d="M86 93a2.62 2.62 0 0 0 2-2.29V77.86a1.4 1.4 0 0 0-2-1.33 3.18 3.18 0 0 0-2 2.76v11.93A1.64 1.64 0 0 0 86 93z"
></path>
<ellipse
className="gr2"
cx="84.68"
cy="77.07"
rx="7.23"
ry="5.36"
transform="rotate(-60 84.682 77.066)"
></ellipse>
<ellipse
className="gr1"
cx="86.05"
cy="77.7"
rx="7.23"
ry="5.36"
transform="rotate(-60 86.06 77.7)"
></ellipse>
<ellipse cx="74.19" cy="70.58" rx="1.58" ry="2.14"></ellipse>
<ellipse cx="55.4" cy="68.02" rx="1.14" ry="1.6"></ellipse>
<path
className="bg1"
d="M80.85 98.72l-1.42 1c-2.69 1.79-4.87 1.61-7 .08l3.83-2.15c.46-.28.42-.65-.09-.82a2.42 2.42 0 0 0-1.77.2L71 98.88c-1.5-.84-1.58-1.65-.54-2.31L73 95.24zM58 91.89a2.78 2.78 0 0 0-1.83.08l-3.66 1.53c-1.48-.85-1.46-1.64-.35-2.18l2.69-1L62.56 94l-1.49.8c-2.48 1.15-4.51 1.54-7.2-.35L58 92.64c.48-.23.47-.57 0-.75z"
></path>
</g>
</svg>
);
}

View File

@ -1,38 +0,0 @@
export function Baller() {
return (
<svg
className="special-icon-svg special-icon11-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 107.96 107.96"
>
<g>
<rect
className="area"
width="107.96"
height="107.96"
rx="29.84"
ry="29.84"
></rect>
<path
className="bg1"
d="M90.56,92.92c-1.89-.45-6.67.1-6.7-.69.11-.55,3.59-.51,5-.59,2.58-.16,5.9-.66,5.35-1.51s-4.11-.86-6.6-.64S80.19,90,80.66,89c.32-.41,1.45-.38,2.25-.53C85.68,88,87,86.73,86,85.73s-4.41-1.69-7.6-1.55a57.08,57.08,0,0,0-7.61,1.22c-2.52.43-6.07.56-7.8-.18a8.61,8.61,0,0,1-1.58-1.13c-2-1.29-8.67-1.27-10.62,0a8,8,0,0,1-1,.71c-1.58.79-5.23.7-7.77.26a63.43,63.43,0,0,0-7.57-1.29c-2.81-.17-6.35.61-5.47,1.51.65.67,3.41,1.18,2.53,1.82-.61.44-2.43.45-3.88.41a32.92,32.92,0,0,0-8.13.54c-2.25.57-3,1.84-.8,2.43A29.26,29.26,0,0,0,23,91c1.49.13,3.09.49,3,1s-1.25.75-2.13,1.09c-1.94.73-2.45,1.84-1.27,2.74s4,1.52,6.95,1.53c2.15,0,4.22-.27,6.37-.3s4.84.08,4.86.79a1.77,1.77,0,0,0,1.06,1.37,10.53,10.53,0,0,0,2.93.82A26.29,26.29,0,0,0,54,99.89c4-.79,1.49-1.92,3-2.31a20.69,20.69,0,0,1,8.22.19,52.12,52.12,0,0,0,7.69,1.37c2.88.17,6.49-.55,5.92-1.5-.3-.5-1.58-.85-2.4-1.28s-.93-1.13.48-1.32a9.07,9.07,0,0,1,1.68,0,63.73,63.73,0,0,0,9.4.3C91.09,95,93.14,93.53,90.56,92.92Z"
></path>
<g className="mask1">
<circle className="gr1-2" cx="53.53" cy="49.5" r="41.9"></circle>
</g>
<path
className="bg1"
d="M28.83,72.48c2.71,1.44,4.8,3.85,7.68,5,3.12,1.26,6.32.79,9.48-.07,2.32-.63,4.63-1.59,7.07-1.64s4.89.86,7.27,1.63c3.24,1,6.45,1.74,9.85,1,4.26-.89,7.34-4.09,11.41-5.3a12.69,12.69,0,0,1,3.32-.5,39.48,39.48,0,0,1-64.45-2.52A22.79,22.79,0,0,1,28.83,72.48Z"
></path>
<path
className="gr1-3"
d="M22.47,19.82S47.56,60.53,94,61.07A34.27,34.27,0,0,1,89.8,72.83,83.87,83.87,0,0,1,40.26,58.67C23.16,47,15.24,29,15.24,29Z"
></path>
<path
className="gr1-4"
d="M15.61,70.8s45.17-15.73,55.7-61a34.27,34.27,0,0,1,10.58,6.66A83.87,83.87,0,0,1,57.38,61.79C42.33,76,23,79.83,23,79.83Z"
></path>
</g>
</svg>
);
}

View File

@ -1,83 +0,0 @@
export function BooyahBomb() {
return (
<svg
className="special-icon-svg special-icon17-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 123.1 115.94"
>
<g>
<rect
className="area"
x="5.43"
y="0.19"
width="107.96"
height="107.96"
rx="29.84"
ry="29.84"
></rect>
<path
className="gr2"
d="M79,102.85c-1-2,.41-3.49-1.49-3.3-2.16.22-6.8-.22-7.85-1.36s-2.53-4.89-3.27-8.86a22.13,22.13,0,0,1,7.13-.54,10.48,10.48,0,0,0,0,3.44c.41,2.55,3,3.14,6.42,3.87,1,.2,2.23-1.85,4-1.28,1.58.52,2.41,1.72,1.94,3.94a9.53,9.53,0,0,1-2.64,4.43A2.76,2.76,0,0,1,79,102.85Z"
></path>
<path
className="gr3"
d="M68.22,81.47l-.24,0a8.54,8.54,0,0,1,1.52-4l-8.28-3A8.71,8.71,0,0,1,60,78.45C62.67,80.05,65.37,81.54,68.22,81.47Z"
></path>
<path
className="bg1"
d="M73.38,57.82h0C69,55.94,62,54.19,57.05,61.65S53.1,78.73,47.4,84.31c-4.23,4.13-3.19,10.17,4.4,7.75,5.16-1.65,7.13-6.76,7.89-12C51,73.66,61.21,63.8,61.21,63.8A57.49,57.49,0,0,0,69,68a57.27,57.27,0,0,0,8.41,2.76s0,14-10.5,12.37C63.82,87.18,61.6,92,63.67,96.86c3.55,8.32,9.19,4.28,9.1-1.88-.14-8.88,7.2-14,9.14-22.72S77.8,59.71,73.38,57.82Z"
></path>
<path
className="gr4"
d="M49.63,81.38a27.32,27.32,0,0,0,2.54-6.49c-.73-.7-1.48-1.39-2.17-2.13a10.49,10.49,0,0,1-2.92-4.94,5.51,5.51,0,0,1-.16-3,22.9,22.9,0,0,1,1.36-3.56c.39-.89,2.8-.75,3.35-2.51.5-1.59,0-3-2.06-3.87a9.64,9.64,0,0,0-5.15-.43,2.77,2.77,0,0,0-2.16,3.62c1,2,3.08,1.69,1.82,3.12-1.41,1.61-3.29,4.28-3.46,6.49a10.34,10.34,0,0,0,1.17,4.12,20.59,20.59,0,0,0,2.62,4.43A28.7,28.7,0,0,0,49.63,81.38Z"
></path>
<path
className="gr5"
d="M52.23,72.39a3.1,3.1,0,0,0,.54.28,40.71,40.71,0,0,1,3.41-9.57A41.37,41.37,0,0,1,50,65.45C48.68,65.57,47.72,69.4,52.23,72.39Z"
></path>
<path
className="gr6"
d="M83.38,79.69a31.49,31.49,0,0,1-2.19-5A45.06,45.06,0,0,1,77,82.9C82.13,84,84.18,80.7,83.38,79.69Z"
></path>
<path
className="gr7"
d="M57.65,78.09c.14.19.29.37.45.55C57.94,78.46,57.79,78.28,57.65,78.09Z"
></path>
<path
className="gr8"
d="M58.77,79.3c-.2-.18-.39-.36-.56-.54C58.38,78.94,58.57,79.12,58.77,79.3Z"
></path>
<path
className="gr9"
d="M57.18,77.4a4.53,4.53,0,0,0,.3.46A4.53,4.53,0,0,1,57.18,77.4Z"
></path>
<path
className="gr10"
d="M59,79.47c.23.2.47.4.73.59C59.43,79.87,59.19,79.67,59,79.47Z"
></path>
<path className="gr11" d="M56.63,76.26h0l0,.1Z"></path>
<path
className="gr12"
d="M63.67,96.86c-2.07-4.85.15-9.68,3.26-13.77h0a16.25,16.25,0,0,1-3.87-1.16,16.93,16.93,0,0,1-3.37-1.87c-.76,5.24-2.73,10.35-7.89,12a11.42,11.42,0,0,1-2.3.5c-.32,2-.81,4.14-1.35,6.15-.21.75-.42,1.48-.64,2.18a38.18,38.18,0,0,0,8.57,2.82c7.64,1.9,13.79,1,14.8.56,0-.41,0-.82,0-1.25,0-.91.11-1.86.19-2.81C69.19,102.45,66,102.28,63.67,96.86Z"
></path>
<path className="gr13" d="M70.91,82.9l.1,0Z"></path>
<path className="gr14" d="M69.06,83.22l.16,0Z"></path>
<path
className="gr15"
d="M56.79,76.66a4.11,4.11,0,0,0,.21.42A4.11,4.11,0,0,1,56.79,76.66Z"
></path>
<path className="gr16" d="M68.49,83.23h0Z"></path>
<path
className="gr17"
d="M77.43,70.72A57.27,57.27,0,0,1,69,68a57.49,57.49,0,0,1-7.81-4.16s-12.5,12,1.85,18.13S77.43,70.72,77.43,70.72Z"
></path>
<g className="mask">
<path
className="bg1"
d="M111.46,55.37,75.11,38.93a22,22,0,0,0,2.09-9.38,21.11,21.11,0,0,0-.12-2.26l26.85-3.11L88.65,8.09,73.52,17.35A22,22,0,0,0,39.93,13.6L34.68,8l-4.55,3.24,7.18,5.36a21.93,21.93,0,0,0-4.15,11.27l-14.9-1.39-5.19,9.75,20.32-3.05a21.94,21.94,0,0,0,4.44,10.05l-27.36,21L16.7,82,42.22,47.42a22,22,0,0,0,29-2.8l34.84,31.86Z"
></path>
</g>
</g>
</svg>
);
}

View File

@ -1,23 +0,0 @@
export function BubbleBlower() {
return (
<svg
className="special-icon-svg special-icon12-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 107.96 107.96"
>
<g>
<circle className="bg1" cx="71.64" cy="95.08" r="5.39"></circle>
<path
className="gr1"
d="M14.27 87.1c2.55.88 6.31-1.36 7.79-4.53s13.08-20.71 17.57-23.33c2.45-1.43 5.27-1.79 10.37-3 5.31-1.25 20.24-.53 30.61-5.16 12.13-5.41 13.51-26.86 5.14-37.6-6.71-8.61-14.41-7-24.37-1.11-12.15 7.24-20 16.93-22.63 29.09a75.49 75.49 0 0 1-5.21 14.78C32.36 59 18.69 76.23 15 78.92s-3.28 7.3-.73 8.18z"
></path>
<path
className="bg1"
d="M55.8 20.26c3.8-3.16 17.52-13.11 23.83-5.89 5.15 5.9 8.27 23.68 1.76 30s-21.89 7.34-30.27 7.25-9.35-5-7.84-12.91 8.2-14.86 12.52-18.45z"
></path>
<circle className="bg1" cx="63.09" cy="69.4" r="11.9"></circle>
<circle className="bg1" cx="88.6" cy="75.74" r="8.94"></circle>
</g>
</svg>
);
}

View File

@ -1,91 +0,0 @@
export function BurstBombRush() {
return (
<svg
className="special-icon-svg special-icon4-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 107.96 107.96"
>
<g>
<rect
className="area"
width="107.96"
height="107.96"
rx="29.84"
ry="29.84"
></rect>
<path
className="bg1"
d="M56.67 22.34s-21.5 8.76-21.5 21 7.28 19.36 21.5 19.36 21.5-7.12 21.5-19.36-21.5-21-21.5-21zM55.18 20.19s-4.72-3.06-2.86-10a12.7 12.7 0 0 1 6.25 0A6.28 6.28 0 0 1 62.46 13s-4.55 2.65-4.8 7.19-.83 1.66-2.48 0z"
></path>
<path
className="bg1"
d="M52.32 21.62c0-1.49 1.39-2.59 4-2.57s3.71.83 3.94 2.84c.2 1.75-1.48 2.21-3.86 2.27s-4.07-.29-4.08-2.54z"
></path>
<path
className="gr3"
d="M51.22 8.58c.6-1.38 3.23-1.74 7.25-.49s5.5 2.54 5 4.51c-.39 1.71-3.22 1.34-7 .26s-6.15-2.19-5.25-4.28z"
></path>
<path
className="gr4"
d="M56.27 34.5c6.81 0 10.86-3 12.83-5-1.29-1-2.61-1.88-3.9-2.67a22.75 22.75 0 0 1-8.93 3.85 51.71 51.71 0 0 1-10.75 1.12 22.06 22.06 0 0 0 10.75 2.7z"
></path>
<path
className="gr5"
d="M35.8 39.74c3.17 3.54 11 6.94 20.88 6.67a20.92 20.92 0 0 0 16.39-8.27c-1.94.79-9.8 4.61-18.64 3.72a39.52 39.52 0 0 1-16.89-5.62 10.58 10.58 0 0 0-1.74 3.5z"
></path>
<path
className="gr6"
d="M57.17 57.9c8.13.55 16.75-2.27 19.57-6.23a22.75 22.75 0 0 0 1.41-8.08C76.68 47 71.44 53 57.31 53.72c-9.29.48-18.44-4.62-21.94-7.2.85 2.86 3 4.77 6.39 7.17 3.02 2.16 7.49 3.67 15.41 4.21z"
></path>
<path
className="bg1"
d="M27 48.52S6.93 60.21 8.64 72.33s9.92 18.15 24 16.15S52.93 78.41 51.21 66.3 27 48.52 27 48.52zM25.21 46.6S20.1 44.23 21 37.1a12.7 12.7 0 0 1 6.19-.88 6.28 6.28 0 0 1 4.24 2.24s-4.13 3.26-3.74 7.79-.62 1.75-2.48.35z"
></path>
<path
className="bg1"
d="M22.58 48.42c-.21-1.47 1-2.76 3.55-3.1s3.79.3 4.3 2.26c.44 1.7-1.16 2.39-3.5 2.79s-4.03.27-4.35-1.95z"
></path>
<path
className="gr7"
d="M19.66 35.66c.4-1.45 3-2.17 7.11-1.51s5.8 1.74 5.63 3.76c-.15 1.75-3 1.78-6.88 1.24s-6.46-1.3-5.86-3.49z"
></path>
<path
className="gr8"
d="M28.29 60.62c6.74-1 10.33-4.47 12-6.77-1.42-.79-2.85-1.49-4.23-2.1a22.75 22.75 0 0 1-8.3 5.06 51.71 51.71 0 0 1-10.49 2.65 22.06 22.06 0 0 0 11.02 1.16z"
></path>
<path
className="gr9"
d="M8.76 68.67c3.64 3.06 11.83 5.33 21.61 3.68a20.92 20.92 0 0 0 15.07-10.49c-1.81 1.06-9.05 5.94-17.93 6.3A39.52 39.52 0 0 1 10 65a10.58 10.58 0 0 0-1.24 3.67z"
></path>
<path
className="gr10"
d="M32.47 83.66c8.12-.59 16.26-4.59 18.5-8.91a22.75 22.75 0 0 0 .27-8.19c-1 3.54-5.33 10.24-19.22 12.95-9.13 1.78-18.9-2-22.73-4 1.25 2.71 3.68 4.3 7.33 6.2 3.29 1.65 7.93 2.53 15.85 1.95z"
></path>
<path
className="bg1"
d="M81.73 60.54s-23.12 2.19-26.65 13.9 1.38 20.64 15 24.74 22.64-.62 26.17-12.34-14.52-26.3-14.52-26.3zM80.92 58.05s-3.64-4.29.15-10.4a12.7 12.7 0 0 1 6 1.8A6.28 6.28 0 0 1 90 53.26s-5.12 1.22-6.67 5.5-1.33 1.36-2.41-.71z"
></path>
<path
className="bg1"
d="M77.77 58.6c.43-1.43 2.08-2.08 4.53-1.32s3.32 1.86 3 3.86c-.31 1.73-2.06 1.68-4.35 1.06s-3.82-1.45-3.18-3.6z"
></path>
<path
className="gr11"
d="M80.48 45.79c1-1.15 3.6-.73 7.08 1.62s4.53 4 3.53 5.78c-.87 1.53-3.47.35-6.76-1.77s-5.33-3.89-3.85-5.63z"
></path>
<path
className="gr12"
d="M77.84 72.07c6.52 2 11.26.28 13.74-1.11-1-1.31-2-2.55-3-3.69A22.75 22.75 0 0 1 79 68.39a51.71 51.71 0 0 1-10.63-2 22.06 22.06 0 0 0 9.47 5.68z"
></path>
<path
className="gr13"
d="M56.72 71.18c2 4.3 8.5 9.81 18.07 12.41a20.92 20.92 0 0 0 18.09-3.19c-2.09.2-10.72 1.6-18.88-1.82a39.52 39.52 0 0 1-14.59-10.25 10.58 10.58 0 0 0-2.69 2.85z"
></path>
<path
className="gr14"
d="M71.95 94.73c7.62 2.87 16.69 2.66 20.54-.32a22.75 22.75 0 0 0 3.68-7.32c-2.38 2.79-9.14 7.06-22.88 3.68C64.25 88.55 57 81 54.36 77.55c0 3 1.53 5.45 4.05 8.71 2.27 2.93 6.11 5.67 13.54 8.47z"
></path>
</g>
</svg>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,82 +0,0 @@
export function CurlingBombRush() {
return (
<svg
className="special-icon-svg special-icon5-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 107.96 107.96"
>
<g>
<rect
className="area"
width="107.96"
height="107.96"
rx="29.84"
ry="29.84"
></rect>
<path d="M64 43.17v-11.8h-.11C62.83 36.45 53.88 40 43 40s-19.83-3.54-20.92-8.62H22v11.79a4.67 4.67 0 0 0 0 .83c0 5.57 8.76 10.08 21 10.08S64 49.54 64 44a4.68 4.68 0 0 0 0-.83z"></path>
<path
className="gr-2"
d="M64 31.71c0 4-8.39 9.21-21 9.21-13.08 0-21-6.06-21-9.21 0-5 9.42-9.13 21-9.13s21 4.09 21 9.13z"
></path>
<ellipse
className="gr-3"
cx="43"
cy="29.91"
rx="15.76"
ry="6.49"
></ellipse>
<ellipse cx="43" cy="26.64" rx="9.01" ry="3.4"></ellipse>
<path
className="gr-4"
d="M51.42 13.61a34.71 34.71 0 0 0-10.67.57c-4.64 1.08-5.48 2.1-6 3.9s.48 3.44.88 4.93a6.56 6.56 0 0 1-.44 2.54c.49 1.6 5.13 2.39 6.91.57a6.22 6.22 0 0 0-.57-3.27c-.47-1.17-1.5-1.91-.71-2.68 1-1 3 0 8.23.67 4.29.53 5.17-.38 5.71-3.39.38-2.06-1.19-3.45-3.34-3.84z"
></path>
<path
className="bg1"
d="M29.68 40.39C26 39 23.12 37 21.74 34.56l.3 6.91a5 5 0 0 0-.08.84c0 3.33 2.91 6.08 7.73 7.81zm23.22 1.23A43 43 0 0 1 43 42.7a40 40 0 0 1-9.9-1.22v9.62a45.16 45.16 0 0 0 9.9 1 45.16 45.16 0 0 0 9.9-1zm11.36-7.06c-1.54 2.68-4.36 4.68-7.95 6v9.54c4.82-1.72 7.69-4.47 7.69-7.79a5 5 0 0 0-.08-.84z"
></path>
<path d="M55 82.11V68.6h-.1c-1.24 5.81-11.49 9.86-23.9 9.86S8.26 74.41 7 68.6h-.11L7 82.11a5.35 5.35 0 0 0-.09.92c0 6.37 10 11.53 24.08 11.53S55 89.4 55 83a5.35 5.35 0 0 0 0-.89z"></path>
<path
className="gr-6"
d="M55 69c0 4.53-9.58 10.53-24 10.53-15 0-24.11-6.94-24.11-10.53 0-5.77 10.77-10.45 24.07-10.45S55 63.22 55 69z"
></path>
<ellipse
className="gr-7"
cx="30.96"
cy="66.93"
rx="18.04"
ry="7.42"
></ellipse>
<ellipse cx="30.96" cy="63.18" rx="10.31" ry="3.89"></ellipse>
<path
className="gr-8"
d="M40.59 48.28c-3.11-.56-9.24 0-12.21.65-5.31 1.24-6.27 2.4-6.85 4.46s.55 3.94 1 5.64a7.51 7.51 0 0 1-.53 2.9c.56 1.83 5.87 2.74 7.91.65a7.11 7.11 0 0 0-.66-3.74c-.54-1.33-1.72-2.19-.81-3.07 1.16-1.13 3.43 0 9.42.76 4.9.61 5.91-.43 6.54-3.88.46-2.34-1.34-3.93-3.81-4.37z"
></path>
<path
className="bg1"
d="M15.72 78.92c-4.23-1.63-7.51-3.92-9.09-6.67L7 80.16a5.76 5.76 0 0 0-.09 1c0 3.81 3.33 7 8.84 8.94zm26.57 1.41A49.21 49.21 0 0 1 31 81.56a45.74 45.74 0 0 1-11.33-1.4v11A51.68 51.68 0 0 0 31 92.35a51.68 51.68 0 0 0 11.33-1.16zm13-8.08c-1.77 3.07-5 5.35-9.09 6.89v10.92c5.51-2 8.84-5.13 8.84-8.94a5.76 5.76 0 0 0-.09-1z"
></path>
<path d="M102.31 66.36l.07-12.36h-.12c-1.14 5.33-10.53 9-22 9s-20.81-3.71-22-9h-.12l.07 12.38a4.91 4.91 0 0 0-.08.85c0 5.84 9.19 10.58 22.08 10.58s22.18-4.81 22.18-10.6a4.91 4.91 0 0 0-.08-.85z"></path>
<path
className="gr-9"
d="M102.38 54.34c0 4.16-8.81 9.66-22.07 9.66-13.72 0-22.07-6.36-22.07-9.66 0-5.29 9.88-9.58 22.07-9.58s22.07 4.24 22.07 9.58z"
></path>
<ellipse
className="gr-10"
cx="80.31"
cy="52.45"
rx="16.54"
ry="6.81"
></ellipse>
<ellipse cx="80.31" cy="49.01" rx="9.45" ry="3.57"></ellipse>
<path
className="gr-11"
d="M89.14 35.34a36.42 36.42 0 0 0-11.19.59c-4.87 1.14-5.75 2.2-6.29 4.09s.5 3.61.93 5.17a6.89 6.89 0 0 1-.47 2.67c.51 1.68 5.38 2.51 7.25.6a6.52 6.52 0 0 0-.6-3.43c-.5-1.22-1.58-2-.74-2.82 1.06-1 3.15 0 8.64.7 4.5.56 5.42-.4 6-3.55.38-2.15-1.27-3.61-3.53-4.02z"
></path>
<path
className="bg1"
d="M66.34 63.44c-3.88-1.49-6.89-3.6-8.34-6.12l.31 7.25a5.28 5.28 0 0 0-.08.88c0 3.49 3.06 6.38 8.11 8.2zm24.36 1.29a45.12 45.12 0 0 1-10.39 1.13 41.94 41.94 0 0 1-10.39-1.28v10.1a51.06 51.06 0 0 0 20.78 0zm11.92-7.41c-1.62 2.82-4.62 4.91-8.34 6.32v10c5.05-1.82 8.11-4.71 8.11-8.2a5.28 5.28 0 0 0-.08-.88z"
></path>
</g>
</svg>
);
}

View File

@ -1,29 +0,0 @@
export function Deaths() {
return (
<svg
className="killed-icon-svg"
xmlns="http://www.w3.org/2000/svg"
aria-labelledby="killed-icon-svg-title"
viewBox="0 0 38.04 20"
>
<g>
<path
className="bg1"
d="M17,5.76c-1.36-.46-2.75.6-4.3,1.08-1,.31-1.19-.32-.73-.88.71-.89,2.26-1.44,3-2.31,1.2-1.41.84-3.21-1.52-3.34a3.59,3.59,0,0,0-3,1.31C9.41,3.31,10,3.92,9.37,5.31c-.22.5-.75,1.13-1.43.77-.37-.2-.37-.64-.06-1C9,3.91,9.63,1.78,7.09,1.75a3.58,3.58,0,0,0-3,2.07A5.69,5.69,0,0,0,4,6.77c.33,1.73.8,3.28-2.5,4.55-2.27.88-1.78,1.71.12,1.1,2.9-.89,4.29.11,5,1.46s.77,2.68,2.26,3.58a2.93,2.93,0,0,0,2.91.07,1.58,1.58,0,0,0,.54-2.7c-.68-.65-2.55-1.41-2-1.94s1.73-.5,2.9.37a3,3,0,0,0,3.12.43,1.77,1.77,0,0,0,.55-2.79,3.14,3.14,0,0,0-2.9-.83c-.84.15-1.91.36-1.94-.31,0-.36.51-.64,1-.72,1.42-.26,3,.68,4.15,0A1.77,1.77,0,0,0,17,5.76Z"
></path>
<path
className="white"
d="M30.32,17.69a.84.84,0,0,1-.38-1.5,2.88,2.88,0,0,1,.34-.27l-2.73-4.45-5.82-.83h0a.85.85,0,0,1-1.54.21c-.4-.53-.08-1.07-.61-2.38a2.43,2.43,0,0,0-.76,4.27,3.56,3.56,0,0,0,3.06.74,2.6,2.6,0,0,0-1.43,1.69c1.22.92,2.43,1.62,4,.24l.05-.08-.05.08c-.68,2,.42,2.87,1.73,3.65a2.64,2.64,0,0,0,1-2,3.59,3.59,0,0,0,1.83,2.56,2.43,2.43,0,0,0,3.67-2.3C31.34,17.37,31,17.87,30.32,17.69Z"
></path>
<path
className="white"
d="M33.62,1.87h0C29.29-1,24.2-.58,22.41,3.16A7.48,7.48,0,0,0,21.79,8l4,.55L24,5.63l2-1.87L29.13,9,35,9.85l-1,2.53L30.89,12,32.75,15a7.26,7.26,0,0,0,3.88-2.3C39.43,9.69,37.89,4.82,33.62,1.87Z"
></path>
<path
className="bg1"
d="M29.36,7c1.95,0,2-2.5,1.83-4.12-.14-1.39.55-1.73,1-1.77-3.94-1.9-8.13-1.24-9.74,2,.71.21.93.94,2.29,1.06,2,.18,1.5-1.36,2.28-1.42S26.57,7,29.36,7Z"
></path>
</g>
</svg>
);
}

View File

@ -1,27 +0,0 @@
export function InkArmor() {
return (
<svg
className="special-icon-svg special-icon1-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 107.96 107.96"
>
<g>
<rect
className="area"
width="107.96"
height="107.96"
rx="29.84"
ry="29.84"
></rect>
<path
className="bg1"
d="M77.82 60.67c1.33-1.78 3.4-6.08 3-7.59-.82-2.83-7.28-9.8-12.89-12.08a13.69 13.69 0 0 0-4.93-.74c-.5 0-1 0-1.48.05a23 23 0 0 0-3.16.4 8.74 8.74 0 0 1 .11-4.35h-9a8.73 8.73 0 0 1 .11 4.35 22.51 22.51 0 0 0-2.93-.38c-.77-.05-1.56-.08-2.36-.06A12.79 12.79 0 0 0 40 41c-5.6 2.27-12 9.24-12.84 12.06-.44 1.52 1.63 5.82 3 7.59 1.17 1.56-.91 1.09-2.1 3A2.83 2.83 0 0 0 30 67.56a9.71 9.71 0 0 0 5.28 0c2.19-.76 2.8-2.13 2.41-3.78-.42-1.84-2.89-1.88-3.22-2.82-1.19-3.4-2-5.95 0-7.66 1.69-1.43 4.48-2.71 6.49-2.07l.22.06c1.65.66 1.63 7.44 1 13.15a49.23 49.23 0 0 1-.9 5.78c-.22.92-.42 1.79-.61 2.63-1.07 4.71-1.73 8.17-2 9.5-.55 2.45-1 7.71-1.44 8.63-.32.61-4.68.36-5.66 1.52-1.32 1.55.55 2.65 3.7 3.57a27.52 27.52 0 0 0 7 .82c2.07-.1 1.59-3.14 1.33-3.67-.69-1.38 1.52-6.85 2.72-10.41a38.8 38.8 0 0 1 3.46-7.9c1.16-1.91 2.59-3.39 4.22-3.39s2.93 1.34 4.09 3.12a37.84 37.84 0 0 1 3.6 8.12c1.2 3.56 3.41 9 2.72 10.41-.26.53-.74 3.58 1.33 3.67a27.53 27.53 0 0 0 7-.82c3.15-.92 5-2 3.7-3.57-1-1.16-5.34-.9-5.66-1.52-.48-.92-.89-6.18-1.44-8.63-.3-1.35-1-4.92-2.09-9.78-.17-.76-.35-1.54-.54-2.35a46.67 46.67 0 0 1-.84-5.13c-.62-5.4-.73-12 .61-13.53a1 1 0 0 1 .35-.27c2-.81 5 .54 6.71 2 2 1.71 1.19 4.25 0 7.66-.33.94-2.8 1-3.22 2.82-.38 1.66.23 3 2.41 3.78a9.71 9.71 0 0 0 5.28 0 2.83 2.83 0 0 0 1.91-3.85c-1.19-1.89-3.27-1.42-2.1-2.98zm-7.7-36.8s6.07 6.62 7.51 7.4.66 5.63-6 6.85l-1.51-14.25zm-32.28 0s-6.07 6.62-7.51 7.4-.66 5.63 6 6.85l1.51-14.25zM71.47 54C67 43.86 72.9 34.45 70.84 23.47S60 11.11 54 11.11s-14.76 1.38-16.82 12.36S41 45 37.21 54.13c-2.82 6.8 1.32 13.21 8.83 6.72 9.59-8.28.28-25.07-1.93-31.48s5.56-3.7 9.92-3.7 12.14-2.7 9.92 3.7S53 51.93 62 60.56c8.1 7.78 12.59.44 9.47-6.56zM65 23.9a72 72 0 0 1-11 .95 72 72 0 0 1-11-.95s-8.44 19.86 11 19.86L65 23.9z"
></path>
<path
className="white"
d="M67.28 31.36c-1.38-.08-3.83-.47-4.51-1.15-.86-.86-1.44-3.29-1.44-6a.5.5 0 0 0-1 0c0 2.75-.58 5.18-1.44 6-.68.68-3.1 1.06-4.48 1.15-1.39-.09-3.81-.47-4.48-1.15-.86-.86-1.44-3.29-1.44-6a.5.5 0 0 0-1 0c0 2.75-.58 5.18-1.44 6-.68.68-3.13 1.07-4.52 1.15a.5.5 0 0 0 0 1c1.38.08 3.83.45 4.51 1.13.86.86 1.44 3.29 1.44 6a.5.5 0 0 0 1 0c0-2.75.58-5.18 1.44-6 .68-.68 3.1-1.05 4.48-1.13 1.39.08 3.81.45 4.48 1.13.86.86 1.44 3.29 1.44 6a.5.5 0 0 0 1 0c0-2.75.58-5.18 1.44-6 .68-.68 3.13-1.06 4.51-1.13a.5.5 0 0 0 0-1z"
></path>
</g>
</svg>
);
}

View File

@ -1,43 +0,0 @@
export function InkStorm() {
return (
<svg
className="special-icon-svg special-icon10-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 107.96 107.96"
>
<g>
<rect
className="area"
width="107.96"
height="107.96"
rx="29.84"
ry="29.84"
></rect>
<path
className="bg1"
d="M28.13 29.07s-5-9.53 1.63-15.1c5.46-4.59 13.52-1.41 16 3.2 0 0 2.84-5.34 8.65-6.79s13.72 0 17.06 6.76 0 11 0 11 3.28-8.88 10.29-7.81c7.74 1.18 6.89 9 6.89 9s10.42-3.47 11.56 5.25S90 43.12 90 43.12s5.7 6.5 1.1 12.35-11.72 1-11.72 1a12.11 12.11 0 0 1-11.05 8.03c-10.42.36-13.89-6.81-13.89-6.81s-1.44 6.6-9.84 5.42c-8-1.13-7.34-8.87-7.34-8.87s-4.32 9-12.31 10.35-15.59-2.45-16.3-9.45 6.84-13.8 12.21-13.94c0 0-7.87-3.85-4.94-10.58s10.58-4.52 12.21-1.55z"
></path>
<path
className="gr1"
d="M28.29 54.37S23.53 62 23.53 65.68s2.5 4.39 5.37 4.39c2.32 0 4.15-1.83 4.15-4.88 0-2.69-4.76-10.82-4.76-10.82z"
></path>
<path
className="gr2"
d="M58.19 25.1s-4.76 7.64-4.76 11.3 2.5 4.39 5.37 4.39c2.32 0 4.15-1.83 4.15-4.88 0-2.69-4.76-10.81-4.76-10.81z"
></path>
<path
className="gr3"
d="M39.44 80.48s-4.76 7.64-4.76 11.3 2.5 4.39 5.37 4.39c2.32 0 4.15-1.83 4.15-4.88 0-2.68-4.76-10.81-4.76-10.81z"
></path>
<path
className="gr4"
d="M73.81 40.79s-4.76 7.64-4.76 11.3 2.5 4.39 5.37 4.39c2.32 0 4.15-1.83 4.15-4.88 0-2.68-4.76-10.81-4.76-10.81z"
></path>
<path
className="bg1"
d="M55.79 66.89S51 74.53 51 78.2s2.5 4.39 5.37 4.39c2.32 0 4.15-1.83 4.15-4.88.03-2.71-4.73-10.82-4.73-10.82zM83.33 63.1s-4.76 7.64-4.76 11.3 2.5 4.39 5.37 4.39c2.32 0 4.15-1.83 4.15-4.88 0-2.68-4.76-10.81-4.76-10.81zM73.81 80.48s-4.76 7.64-4.76 11.3 2.5 4.39 5.37 4.39c2.32 0 4.15-1.83 4.15-4.88 0-2.68-4.76-10.81-4.76-10.81z"
></path>
</g>
</svg>
);
}

View File

@ -1,64 +0,0 @@
export function Inkjet() {
return (
<svg
className="special-icon-svg special-icon8-svg"
xmlns="http://www.w3.org/2000/svg"
aria-labelledby="special-icon9-svg-title-a4bcf765-7e15-472f-ae61-fad5ba99e0fb"
viewBox="0 0 107.96 107.96"
>
<g>
<title id="special-icon9-svg-title-a4bcf765-7e15-472f-ae61-fad5ba99e0fb">
Specials used
</title>
<rect
className="area"
width="107.96"
height="107.96"
rx="29.84"
ry="29.84"
></rect>
<path
className="bg1"
d="M16.687 29.708l50.548 4.005-.963 12.153-50.548-4.005z"
></path>
<path
className="gr2"
d="M14.63 28.05h1.32a1.86 1.86 0 0 1 1.86 1.86v11.54a1.86 1.86 0 0 1-1.86 1.86h-1.32a5.38 5.38 0 0 1-5.38-5.38v-4.5a5.38 5.38 0 0 1 5.38-5.38z"
transform="rotate(2.66 13.541 35.67)"
></path>
<path
className="gr3"
d="M73.55 88.6c-1-2.12-1.89-2.62-4.88-4.56-.38-.24-.77-.48-1.18-.72-3.16-1.86-6.92-3.51-8.14-4.5s-.4-2 .67-3.2c2-2.25 4.64-2.24 7.2-3a63.12 63.12 0 0 0-10.74-6.85c-1 .85-2.2 1.83-3.43 3-2.63 2.51-3.8 4.7-4 6.63-.35 2.72.19 5.67 2.92 7.68 4.3 3.18 6.55 3.32 9.46 4.84.35.18.71.38 1.08.62 1.81 1.12 1.28 3-.16 5.11s-3.2 2.07-2.26 4.42c.67 1.68 4 2.15 5.73.37a18 18 0 0 1 5.05-3.81c1.47-.73 4.19-2.68 2.68-6.03z"
></path>
<path
className="gr4"
d="M61.89 39.61c-1.42-2-1.17-3 1.91-5.34 2.27-1.75 8-4.79 6.21-11.46s-8.84-12.44-16.42-10.38c-7.17 1.94-9.29 3.43-10.59 7.8a9.11 9.11 0 0 1-2.37 4.23c-1.4 1.47-1.67 3.51.09 6.4 1.47 2.42 6.89 4.93 10.34 4.58 1.76-.18 3.78.66 4.23 2a6.31 6.31 0 0 1 0 4.89c2.71.82 4.71-.73 6.6-2.72z"
></path>
<path
className="gr6"
d="M72.22 54.29C70.9 50.51 68 47 64 42.19c-.87-1.06-1.69-2.56-2.19-3.27-1.85 2-4.27 4-7 3.16-.24.59 0 1.51-.46 2.24-1.16 1.94-2.11 5.93 1.08 10.83 1.26 1.94 5.05 5.64 3.85 8-.34.66-2.05 1.56-3.42 2.7 4.42 2.2 6.88 5.07 9.5 8.14a14.4 14.4 0 0 0 8-6.71c1.98-4.11.26-8.92-1.14-12.99z"
></path>
<path
className="gr7"
d="M57.25 43c2.47-2.28 7.36-.38 7.33 3.31s-3.27 5.4-6.34 7.07-5.05 2.82-7.51 4-3.47 3.28-8.23-1.43-5.06-6.36-9.37-5.66c-3 .49-4.73-2.16-5.45-3.69-1.58-3.42 2.19-7.63 4.33-9s5.15-.26 7.16 2.75c1.21 1.8.82 2.66.26 3.72-.91 1.74 1 3.3 3.8 5s3.6 2.77 6.29 1.07C52.65 48.06 54 46 57.25 43z"
></path>
<path
className="gr8"
d="M83.9 85c-5.55-2.16-7.33-7.22-5.29-15l.17-.67a10.7 10.7 0 0 0-2.57-9.65c-2.63-3.14-8.73-10.79-9-11.11l7-5.62c.06.08 6.29 7.88 8.85 10.95a19.72 19.72 0 0 1 4.43 17.52c-.06.25-.13.54-.22.85-.76 2.92-.68 3.89-.49 4.19a1.3 1.3 0 0 0 .41.21 1.77 1.77 0 0 0 1.44.1C89.29 76.35 90 75 90.29 73a20 20 0 0 0-1.67-9.2l6.86-3c.42.84 4 7.54 3.22 14.12-.57 4.65-2.16 7.61-5.31 9.53a10.48 10.48 0 0 1-9.25.67z"
></path>
<path
className="bg1"
d="M97.36 48.7c-2.6-5.24-5.37-8-6-10.26-.85-3.27 0-1.93-1.32-7-1.57-5.85-5-6.89-10.73-4.91h-.24c-5.66 2.15-7.63 5.15-5.08 10.65 2.19 4.73 2 3.14 3.46 6.2 1 2.12.65 6 2 11.7 1.14 4.78 5.29 3.75 7.07 6.45C88 63.73 88.7 65.2 92 64l1.38-.5c3.12-1.13 2.95-2.81 2.66-5.31-.34-3.19 3.5-5.09 1.32-9.49zm-28.729-1.702l4.592-1.622 22.334 63.247-4.592 1.622zm.179-13.378c-.69-1.76 1-2.71 2.72-6.53 1.63-3.54.52-9-4.87-13.7A16.26 16.26 0 0 0 50.45 10c-4.65 1.55-5.44 3.44-6.56 5.76 0 0 3.08-.48 10.07 1.38a5.75 5.75 0 0 0 .2-2.44c2.93 9-2.46 17.37-5.9 24.64-2.14 4.56-2.26 11.78 3.11 10.4C57 48.32 60.7 36.9 60.51 33.83a10.28 10.28 0 0 0 3.79 3c1.97.9 6.24 1.17 4.51-3.21z"
></path>
<path
className="gr9"
d="M75.06 15.07c-6.77 2.46-8.47 4.7-7.59 7.09s3.19 7.36 3.56 8.18c1 2.23 3.37 1.45 8.52-.42l1.58-.57c4.88-1.77 7.46-2.77 6.81-5.13-.24-.87-1.7-6.3-2.51-8.56s-3.59-3.05-10.37-.59z"
></path>
<path
className="gr10"
d="M66.7 35.11c3.87-1.33 6.71 1.53 7.76 4.5s1.54 8.22 2.11 10.53A9.36 9.36 0 0 1 69.71 53s-4.25-5.95-5.8-8.71-1.72-7.62 2.79-9.18z"
></path>
</g>
</svg>
);
}

View File

@ -1,32 +0,0 @@
export function Kills() {
return (
<svg
className="kill-icon-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 38.87 20"
>
<g>
<path
className="bg1"
d="M37.6,6.31c-1.44-.49-2.92.63-4.55,1.15-1,.32-1.26-.34-.78-.94C33,5.58,34.67,5,35.46,4.07c1.27-1.49.89-3.4-1.62-3.53A3.81,3.81,0,0,0,30.7,1.93c-1.16,1.79-.55,2.43-1.2,3.91-.24.53-.8,1.19-1.52.8-.4-.2-.4-.67-.06-1,1.15-1.25,1.85-3.51-.83-3.54a3.78,3.78,0,0,0-3.18,2.19,6.06,6.06,0,0,0-.09,3.13c.34,1.84.86,3.49-2.64,4.82-1.23.47-.93,1.33.12,1.17,3.17-.5,4.54.11,5.27,1.54s.82,2.83,2.4,3.79a3.1,3.1,0,0,0,3.08.07,1.66,1.66,0,0,0,.57-2.85c-.72-.69-2.7-1.5-2.14-2.06s1.83-.52,3.07.39a3.2,3.2,0,0,0,3.3.46,1.87,1.87,0,0,0,.59-3,3.31,3.31,0,0,0-3.07-.89c-.89.16-2,.39-2.06-.32,0-.39.55-.68,1-.77,1.5-.27,3.13.72,4.4,0A1.88,1.88,0,0,0,37.6,6.31Z"
></path>
<path
className="white"
d="M12.47,17.69a.85.85,0,0,1-.39-1.5,3.8,3.8,0,0,1-1.55-.63A3.69,3.69,0,0,1,8.92,12,3.74,3.74,0,0,1,5,11.86a3.8,3.8,0,0,1-1.16-1.21.84.84,0,0,1-1.53.21c-.41-.53-.09-1.07-.62-2.38A2.43,2.43,0,0,0,1,12.75,3.58,3.58,0,0,0,4,13.49a2.62,2.62,0,0,0-1.44,1.69c1.22.92,2.44,1.62,4,.24l0-.08,0,.08c-.69,2,.42,2.87,1.73,3.65a2.59,2.59,0,0,0,1-2,3.58,3.58,0,0,0,1.84,2.56,2.43,2.43,0,0,0,3.67-2.3C13.48,17.37,13.11,17.87,12.47,17.69Z"
></path>
<path
className="white"
d="M15.77,1.87h0C11.43-1,6.34-.58,4.56,3.16A7.08,7.08,0,0,0,3.82,7,2.71,2.71,0,0,1,4,6.67a3.72,3.72,0,0,1,5.35-.91A3.22,3.22,0,0,1,11,9a3.21,3.21,0,0,1,3.58.27,3.7,3.7,0,0,1,1.16,5.3c-.08.12-.17.23-.26.34a7.12,7.12,0,0,0,3.32-2.15C21.57,9.69,20,4.82,15.77,1.87Z"
></path>
<path
className="white"
d="M14.08,9.93a2.62,2.62,0,1,0,.69,3.63A2.6,2.6,0,0,0,14.08,9.93Zm-.16,2.32a1.55,1.55,0,0,1-2.56-1.73,1.55,1.55,0,0,1,2.56,1.73Z"
></path>
<path
className="white"
d="M8.93,6.45a2.62,2.62,0,1,0,.69,3.63A2.62,2.62,0,0,0,8.93,6.45Zm.46,2.74a1.47,1.47,0,0,1-2.06.31,1.47,1.47,0,0,1-.49-2,1.47,1.47,0,0,1,2.06-.3A1.46,1.46,0,0,1,9.39,9.19Z"
></path>
</g>
</svg>
);
}

View File

@ -1,55 +0,0 @@
export function Splashdown() {
return (
<svg
className="special-icon-svg special-icon9-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 107.96 107.96"
>
<g>
<rect
className="area"
width="107.96"
height="107.96"
rx="29.84"
ry="29.84"
></rect>
<path
className="gr1"
d="M13.09 78.37c1.63-1 3.43-1.43 5.88-2.21s3.93-1.09 4.17-3.41c.28-2.72-.09-8.46.34-11.61.41-3 2.45-4.61 5.74-6.06a85.87 85.87 0 0 1 9.2-2.69 22.91 22.91 0 0 1 2.83 9.94c-1.69.22-3.42.42-5 .58-2.43.26-3.73.35-4.13 1.73a28.93 28.93 0 0 0-1.47 7 23.78 23.78 0 0 0 .19 5.92c.38 2.36.16 4.45-3.56 5.14-3.32.62-4.31-.42-7.08.45-2.44.76-4.78.94-6.59.11-2.3-1-2.7-3.51-.52-4.89z"
></path>
<path
className="gr2"
d="M45.59 50.14c3.15-1.54 6.26.82 7.28 3.19s1.45 5.25-3.52 7.3c-1.74.72-5.48 2.62-8.73 3-.24-3.52-.48-8.27-2.2-11.29a48.78 48.78 0 0 0 7.17-2.2z"
></path>
<path
className="gr3"
d="M69.75 64.64c-.82 3.22.69 4.33 2.65 2.77C77.22 63.53 80.87 62 84.31 60c3.16-1.8 8.36.11 10.15 2.31 2.3 2.83-.35 7.46 1.42 11.6S93.87 81 88 81.46c-4.32.3-3.16-3-2-5.44s.73-6.69-.65-6.33c-2 .53-4.34 2-7.89 4-2.89 1.68-7.11 5.1-11.06 4.28-3.33-.69-4.28-1.9-5.71-7.38-.37-1.43-.69-3.06-1-4.57a18.81 18.81 0 0 0 10.48-3.45 16.72 16.72 0 0 1-.42 2.07z"
></path>
<path
className="gr4"
d="M37 40.53c-4 2.62-3.25 7-2 12.11 1.45 5.81 2.32 6.6 1.8 10.87-.78 6.4 2.1 11.32 1.54 14.25-.38 2-4 3.65-4.38 5.79-.54 2.88-.14 5.39 4 6.52a15.07 15.07 0 0 0 8.22.08c2.45-.64 2.92-1.68 2.93-5.45 0-3-2.07-4.48-4.17-5.68-3.85-2.19 2.78-10-1.31-18.6-1.24-2.62.37-6.53.85-12.18.52-5.15-4.41-9.67-7.48-7.71z"
></path>
<path
className="bg1"
d="M29.6 82.44c1.46 1.77 1.76 2.74 3.38 4.23a32.78 32.78 0 0 1 .36-10.51 13 13 0 0 0 1.54 4.36 74.74 74.74 0 0 1 3-12l3.05 8.7 1.71-11.61a40.36 40.36 0 0 1 4.76 15.27l1.38-6a21.27 21.27 0 0 1 1.11 9.72 31 31 0 0 1 2.69-3.88 23.86 23.86 0 0 1-.63 10.47 7.86 7.86 0 0 1-1.07 2.45 11.31 11.31 0 0 1-4.12 2.73c-5 1.64-8.54.83-11.8-1.81-2.4-2.13-4.46-5.63-5.36-12.12z"
></path>
<path
className="gr5"
d="M38.2 36.44c.52-3.55 13.23-4.14 17-2.61s7 4.21 7.6 8.19c.83 5.27-.17 8.93 1.41 10.58s4.32 3.11 5.9 5.65a7 7 0 0 1 .89 4.94 30.07 30.07 0 0 1-12.32 3.57c-.56-2.11-.6-3.93-1.58-4.12-11.17-2.12-15.64-10.14-16.1-17-.23-4.37-3.32-5.64-2.8-9.2z"
></path>
<path
className="gr6"
d="M59.91 28.79s10.47-2.56 14.15-2 4.16 1.21 5.28 3.15S81.52 38.33 82 40s.76 1.81 2.47 3 1.69 4.21.78 5.7c-1.25 2.06-5.46 2.43-7.3 2s-3.84-3.21-4-4.74c-.1-1.15 1.4-2.07 1.65-3.73s-1.2-7.59-2.37-8.31c-.85-.52-6.23 1.11-8.73 2.93-2.66 1.94-6 1.51-7.42-1.25s-.89-6.21 2.83-6.81z"
></path>
<path
className="gr7"
d="M53.52 25.46l-.29-2.74a1.5 1.5 0 0 1 1.1-1.61 8.48 8.48 0 0 1 5.07.14c1.31.62-.84 5.51-2.16 6.6 0 0 1.77 3.29-2.8 8.43A20.93 20.93 0 0 1 41 43.06c-4 .47-7.12-1.91-7.9-3.73a7.68 7.68 0 0 1-5.26-2.09c-1.8-1.75 1.29-4.12 2.38-4.38A4 4 0 0 0 32 35.6a57.82 57.82 0 0 0-.8-6.24S34.57 31 40.9 28a29.14 29.14 0 0 0 7.42-4.94l-.56-3.47 1.94 2.11s2.05 2.14 3.82 3.76z"
></path>
<path
className="bg1"
d="M26.13 41.95c2.46-3.09 1.6-5.27 1.73-8.81a15.68 15.68 0 0 1-1.93-10.41c1.1-5.86 11.74-14 20.9-11.57a14.09 14.09 0 0 1 10.3 9.65 10.24 10.24 0 0 0-2.79.3 1.5 1.5 0 0 0-1.1 1.61c3.68 3.41 6.6 6.12 6.26 11.24-.35 5.27-1.77 7.3-5.73 8.77-1 .38-6.29-1.36-4.57-7 1.45-4.73.09-8.59-1.81-11.87A29.12 29.12 0 0 1 40.9 28a15.57 15.57 0 0 1-8 1.71 5.9 5.9 0 0 1-1.73-.38c.59 3 .95 6.1.95 6.1a10.85 10.85 0 0 0 1 3.86 5.92 5.92 0 0 0 2.15 2.35c.36 2.85-.53 4.29-4 6-5.17 2.47-7.65-2.53-5.14-5.69z"
></path>
</g>
</svg>
);
}

View File

@ -1,187 +0,0 @@
export function SplatBombRush() {
return (
<svg
className="special-icon-svg special-icon2-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 107.96 107.96"
>
<g>
<rect
className="area"
width="107.96"
height="107.96"
rx="29.84"
ry="29.84"
></rect>
<path
className="gr2"
d="M40.25,27,27,12.64,20.19,31l-6.6,19.36,20.06-4,20.07-4ZM17.15,47.49,22.45,32l5.29-15.53,10.8,12.35,10.8,12.35L33.25,44.31Z"
></path>
<path
className="bg1"
d="M42.93,38.42,36.7,31.3l-6.27-7.16h0a1.3,1.3,0,0,0-2.31.46h0l-3.07,9-3.05,9a1.3,1.3,0,0,0,1.42,1.86l9.46-1.87,9.46-1.87a1.3,1.3,0,0,0,.6-2.26Z"
></path>
<path
className="gr4"
d="M29.1,20.89l-1,.2a2.4,2.4,0,0,1-2.59-.9l-.77-1.53c-.62-1.24.21-2.78,2-3.13l1.65-.33c1.78-.35,3.1.76,3,2.15l-.17,1.72A2.47,2.47,0,0,1,29.1,20.89Z"
></path>
<path
className="gr5"
d="M29.79,17.89l-3.46.68a3.59,3.59,0,0,1-3.65-2l-.32-.6c-1-1.92-.41-4.2,1.59-4.6l5.32-1.05c2-.4,3.43,1.48,3.22,3.64l-.07.68A3.59,3.59,0,0,1,29.79,17.89Z"
></path>
<ellipse
className="gr6"
cx="26.86"
cy="12.17"
rx="4.9"
ry="1.8"
transform="translate(-1.85 5.44) rotate(-11.19)"
></ellipse>
<path
className="gr7"
d="M17,43.53l.76.29a1.92,1.92,0,0,1,1.28,2l-.24,1.67a2.14,2.14,0,0,1-2.86,1.78l-1.26-.48a2.09,2.09,0,0,1-.91-3.21l1-1.4A2,2,0,0,1,17,43.53Z"
></path>
<path
className="gr8"
d="M15,45.79l2.64,1c1,.38,1.66,1.78,1.46,3.37l-.09.67c-.27,2.12-1.8,3.81-3.32,3.23l-4-1.53c-1.52-.58-1.55-2.85-.34-4.62l.38-.56C12.59,46,14,45.41,15,45.79Z"
></path>
<ellipse
className="gr9"
cx="14.14"
cy="52.07"
rx="1.8"
ry="3.91"
transform="translate(-39.56 46.85) rotate(-69.25)"
></ellipse>
<path
className="gr10"
d="M48.38,37.31l-.59.55a1.92,1.92,0,0,0-.43,2.3l.86,1.45a2.14,2.14,0,0,0,3.33.56l1-.92a2.09,2.09,0,0,0-.38-3.32L50.73,37A2,2,0,0,0,48.38,37.31Z"
></path>
<path
className="gr11"
d="M51,38.66,49,40.59c-.77.73-.86,2.28-.07,3.67l.33.58c1.06,1.86,3.11,2.84,4.3,1.72l3.16-3c1.19-1.11.34-3.23-1.44-4.4l-.56-.37C53.36,38,51.81,37.94,51,38.66Z"
></path>
<ellipse
className="gr12"
cx="54.23"
cy="44.14"
rx="3.91"
ry="1.8"
transform="translate(-15.52 49.01) rotate(-43.14)"
></ellipse>
<path
className="gr13"
d="M89.61,39.94l-6.5-18.48L69.58,35.63,55.84,50.81l20,4.31,20,4.31ZM60.24,49.61l11-12.18,11-12.18,5,15.64,5,15.64L76.31,53.07Z"
></path>
<path
className="bg1"
d="M87.54,51.49l-2.91-9L81.72,33.4h0a1.3,1.3,0,0,0-2.31-.5h0L73,40l-6.36,7a1.3,1.3,0,0,0,.57,2.27l9.44,2,9.44,2a1.3,1.3,0,0,0,1.45-1.84Z"
></path>
<path
className="gr14"
d="M81.78,29.88l-1-.21a2.4,2.4,0,0,1-2-1.85l-.1-1.71C78.58,24.71,80,23.63,81.73,24l1.65.35c1.78.38,2.55,1.93,1.88,3.15L84.42,29A2.47,2.47,0,0,1,81.78,29.88Z"
></path>
<path
className="gr15"
d="M83.61,27.4l-3.45-.74a3.59,3.59,0,0,1-2.58-3.25l-.05-.68c-.17-2.17,1.29-4,3.28-3.6l5.31,1.14c2,.43,2.56,2.72,1.52,4.63l-.33.6A3.59,3.59,0,0,1,83.61,27.4Z"
></path>
<ellipse
className="gr16"
cx="83.18"
cy="20.97"
rx="1.81"
ry="4.9"
transform="translate(45.19 97.88) rotate(-77.86)"
></ellipse>
<path
className="gr17"
d="M61.63,45.88l.58.56a1.92,1.92,0,0,1,.4,2.31l-.89,1.44a2.14,2.14,0,0,1-3.34.51l-1-.94a2.1,2.1,0,0,1,.44-3.32l1.44-.91A2,2,0,0,1,61.63,45.88Z"
></path>
<path
className="gr18"
d="M59,47.2l2,2c.76.74.83,2.29,0,3.67l-.34.58c-1.09,1.84-3.16,2.79-4.34,1.66l-3.12-3c-1.17-1.14-.29-3.24,1.52-4.39l.57-.36C56.63,46.44,58.19,46.46,59,47.2Z"
></path>
<ellipse
className="gr19"
cx="55.66"
cy="52.63"
rx="1.81"
ry="3.92"
transform="translate(-20.87 56) rotate(-45.92)"
></ellipse>
<path
className="gr20"
d="M93,52.63l-.76.27a1.92,1.92,0,0,0-1.31,1.95l.22,1.68A2.14,2.14,0,0,0,94,58.36l1.27-.46a2.1,2.1,0,0,0,1-3.2l-.93-1.42A2,2,0,0,0,93,52.63Z"
></path>
<path
className="gr21"
d="M94.9,54.93l-2.66,1c-1,.36-1.7,1.75-1.52,3.35l.07.67c.24,2.13,1.74,3.84,3.27,3.29l4.08-1.47c1.54-.55,1.6-2.83.42-4.62l-.37-.56C97.33,55.19,95.9,54.57,94.9,54.93Z"
></path>
<ellipse
className="gr22"
cx="95.67"
cy="61.23"
rx="3.92"
ry="1.81"
transform="translate(-15.09 36.04) rotate(-19.81)"
></ellipse>
<path
className="gr23"
d="M61.56,66.25,46.3,49.64,38.41,70.78,30.76,93.08,53.9,88.56,77.05,84ZM34.87,89.81,41,71.92,47.15,54,59.58,68.29,72,82.55,53.44,86.18Z"
></path>
<path
className="bg1"
d="M64.61,79.41l-7.17-8.22-7.21-8.28h0a1.5,1.5,0,0,0-2.67.52h0L44,73.82,40.46,84.13a1.5,1.5,0,0,0,1.63,2.15L53,84.15,63.91,82a1.5,1.5,0,0,0,.7-2.61Z"
></path>
<path
className="gr24"
d="M48.7,59.16l-1.14.22a2.77,2.77,0,0,1-3-1l-.88-1.77c-.71-1.43.25-3.2,2.3-3.6l1.9-.37c2.05-.4,3.57.88,3.41,2.48l-.2,2A2.85,2.85,0,0,1,48.7,59.16Z"
></path>
<path
className="gr25"
d="M49.51,55.71l-4,.78a4.14,4.14,0,0,1-4.21-2.27l-.37-.7c-1.17-2.22-.46-4.84,1.84-5.3L48.93,47c2.31-.45,3.95,1.72,3.7,4.21l-.08.78A4.14,4.14,0,0,1,49.51,55.71Z"
></path>
<ellipse
className="gr26"
cx="46.15"
cy="49.1"
rx="5.65"
ry="2.08"
transform="translate(-8.57 9.78) rotate(-11.08)"
></ellipse>
<path
className="gr27"
d="M34.65,85.24l.87.33A2.21,2.21,0,0,1,37,87.84l-.29,1.93a2.47,2.47,0,0,1-3.31,2.05L32,91.26a2.41,2.41,0,0,1-1-3.71L32,85.94A2.27,2.27,0,0,1,34.65,85.24Z"
></path>
<path
className="gr28"
d="M32.41,87.84l3,1.16c1.14.44,1.91,2.05,1.68,3.89l-.1.77c-.32,2.45-2.08,4.39-3.84,3.72l-4.67-1.78c-1.76-.67-1.78-3.29-.39-5.33l.44-.64C29.62,88.1,31.27,87.41,32.41,87.84Z"
></path>
<ellipse
className="gr29"
cx="31.39"
cy="95.08"
rx="2.08"
ry="4.51"
transform="translate(-68.64 90.54) rotate(-69.13)"
></ellipse>
<path
className="gr3"
d="M70.9,78.14l-.68.64a2.21,2.21,0,0,0-.5,2.66l1,1.68a2.47,2.47,0,0,0,3.83.65l1.13-1.06a2.41,2.41,0,0,0-.43-3.83L73.61,77.8A2.27,2.27,0,0,0,70.9,78.14Z"
></path>
<path
className="gr31"
d="M74,79.71l-2.38,2.22c-.89.83-1,2.62-.09,4.23l.38.67c1.22,2.15,3.58,3.28,5,2l3.65-3.41c1.37-1.28.41-3.72-1.65-5.08l-.65-.43C76.65,78.89,74.86,78.87,74,79.71Z"
></path>
<ellipse
className="gr1"
cx="77.64"
cy="86.03"
rx="4.51"
ry="2.08"
transform="translate(-37.82 76.1) rotate(-43.02)"
></ellipse>
</g>
</svg>
);
}

View File

@ -1,64 +0,0 @@
export function Stingray() {
return (
<svg
className="special-icon-svg special-icon7-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 107.96 110.21"
>
<g>
<rect
className="area"
width="107.96"
height="107.96"
rx="29.84"
ry="29.84"
></rect>
<path
className="bg1"
d="M53.33 66.07a2.29 2.29 0 0 0 .77-4.44l-46-16.4a2.29 2.29 0 1 0-1.53 4.31l46 16.4a2.28 2.28 0 0 0 .76.13z"
></path>
<path
className="gr1"
d="M84 28.62h4.66A12.38 12.38 0 0 1 101 41v18.81A11.19 11.19 0 0 1 89.8 71h-7a11.19 11.19 0 0 1-11.22-11.19V41A12.38 12.38 0 0 1 84 28.62z"
transform="rotate(-168.23 86.283 49.814)"
></path>
<path
className="bg1"
d="M70.61 53.29l.85-4.09c-.81 5.8 4.86 9.58 10.65 10.8l3.76.73c5.91 1.23 12.79.75 14.19-5.09l-1 4.81c-1.4 5.84-8.28 6.32-14.19 5.09l-3.76-.73c-6-1.27-11.76-5.49-10.5-11.52z"
></path>
<path
className="gr2"
d="M40 58.73a131.41 131.41 0 0 1 13.12-2.32c1.92-.21 3.6 0 4.56 1.1a3.91 3.91 0 0 1 .74 3.44l3.31 1.14a6.21 6.21 0 0 0-1.14-6c-1.61-1.87-5.11-2.66-8.09-2.35-6.26.67-13.17 2.26-13.3 2.26z"
></path>
<path
className="gr3"
d="M39.59 58.63a93.3 93.3 0 0 0 8.31 11.11 4.69 4.69 0 0 0 4 1.74 4.5 4.5 0 0 0 3-2.27l3.29 1A7.66 7.66 0 0 1 53 73.89a7.17 7.17 0 0 1-6.93-2.35 111.56 111.56 0 0 1-8.84-11.34z"
></path>
<path
className="gr4"
d="M83 71.08l6.68 3.1c2.26 1 2.8 3.45 2.75 6.26 0 2.5-.93 4.81-2.34 5-1.14.18-1.65-1.14-1.66-3.67 0-.93.15-3.73-1.44-4.32-2-.73-4.13-1.42-6.61-2.09z"
></path>
<path
className="gr5"
d="M60.1 68.73l4.13 1.48 1.99-4.41-4.14-1.56-1.98 4.49z"
></path>
<path
className="gr6"
d="M29.7 58.56l24.44 8.17 1.92-4.7-24.4-9.09-1.96 5.62z"
></path>
<path
className="gr7"
d="M52.62 69.79l6.19 2.14 4.86-11.02-6.25-2.42-4.8 11.3z"
></path>
<path
className="gr8"
d="M29.25 71a23.56 23.56 0 0 1 .14-16 22.07 22.07 0 0 1 9.43-12.19 30.87 30.87 0 0 1 4.09 3.33 19.54 19.54 0 0 0-8.59 11 23.23 23.23 0 0 0 .45 14.7 34.55 34.55 0 0 1-5.52-.84z"
></path>
<path
className="gr9"
d="M66.9 64.63a2 2 0 0 0-1.61 1.21L58 82.16c-1.54 3.45-1 6 2 6.82l11 3.28a6 6 0 0 0 6.18-3c1.22-2.35 1.27-6.41 2.15-8.13a23.16 23.16 0 0 1 3.15-4 7 7 0 0 0 1.11-1.57l1.65-3.21a1.28 1.28 0 0 0-.68-1.78l-1.67-.64L70.1 65a6.72 6.72 0 0 0-3.2-.37zm1.2 8l9.83 3.08a24.17 24.17 0 0 0-3.32 4.74c-1.22 2.44-.65 4.22-1.25 5.65a2.55 2.55 0 0 1-3 1.63l-6.18-2c-1.16-.34-1.84-1-1-2.95z"
></path>
</g>
</svg>
);
}

View File

@ -1,184 +0,0 @@
export function SuctionBombRush() {
return (
<svg
className="special-icon-svg special-icon3-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 107.96 107.96"
>
<g>
<rect
className="area"
width="107.96"
height="107.96"
rx="29.84"
ry="29.84"
></rect>
<path
className="gr2"
d="M36.8,63.71,36.4,62.1a2,2,0,0,0-1.84-1.53l-2.42-10-3.55.86,2.42,10a2,2,0,0,0-.93,2.21l.39,1.62A12.42,12.42,0,0,0,24.9,80.7a24.21,24.21,0,0,0,12.71.15,24.21,24.21,0,0,0,11.23-6,12.42,12.42,0,0,0-12-11.17Z"
></path>
<ellipse
className="gr3"
cx="30.88"
cy="53.14"
rx="12.54"
ry="3.89"
transform="translate(-11.67 8.79) rotate(-13.65)"
></ellipse>
<ellipse
className="gr4"
cx="24.47"
cy="26.74"
rx="12.54"
ry="4.47"
transform="translate(-5.62 6.53) rotate(-13.65)"
></ellipse>
<path
className="bg1"
d="M31.1,54h0C26,55.26,21.72,55.62,21,52.47l-5.2-21.41C15,27.92,19,26.35,24.08,25.12h0c5.07-1.23,9.35-1.68,10.12,1.46L39.39,48C40.16,51.14,36.17,52.8,31.1,54Z"
></path>
<ellipse
className="bg1"
cx="35.76"
cy="72.83"
rx="1.95"
ry="1.87"
transform="translate(-16.18 10.5) rotate(-13.65)"
></ellipse>
<path
className="bg1"
d="M39.87,69.38a1.65,1.65,0,0,0-.66,2.31,1.87,1.87,0,0,0,2.29,1.08,1.65,1.65,0,0,0,.66-2.31A1.87,1.87,0,0,0,39.87,69.38Z"
></path>
<path
className="bg1"
d="M30.53,71.65a1.87,1.87,0,0,0-1.54,2,1.65,1.65,0,0,0,1.65,1.74,1.87,1.87,0,0,0,1.54-2A1.65,1.65,0,0,0,30.53,71.65Z"
></path>
<ellipse
className="bg1"
cx="26.46"
cy="73.45"
rx="1.87"
ry="1.38"
transform="translate(-50.24 88) rotate(-80.93)"
></ellipse>
<ellipse
className="bg1"
cx="44.32"
cy="69.12"
rx="1.38"
ry="1.87"
transform="translate(-32.35 39.74) rotate(-36.37)"
></ellipse>
<path
className="gr6"
d="M80.54,58.18l.35-1.57a2,2,0,0,0-.94-2.12l2.18-9.69L78.68,44,76.5,53.71a2,2,0,0,0-1.76,1.51l-.35,1.57a12,12,0,0,0-11.49,11,24.91,24.91,0,0,0,23.29,5.25,12,12,0,0,0-5.65-14.88Z"
></path>
<ellipse
className="gr7"
cx="79.95"
cy="46.46"
rx="3.77"
ry="12.15"
transform="translate(17.05 114.23) rotate(-77.3)"
></ellipse>
<ellipse
className="gr8"
cx="85.73"
cy="20.78"
rx="4.34"
ry="12.15"
transform="translate(46.61 99.85) rotate(-77.3)"
></ellipse>
<path
className="bg1"
d="M79.75,47.32h0c-4.93-1.11-8.82-2.65-8.13-5.71l4.69-20.83c.69-3.06,4.85-2.69,9.78-1.58h0c4.93,1.11,8.84,2.56,8.15,5.62L89.55,45.66C88.86,48.71,84.68,48.44,79.75,47.32Z"
></path>
<ellipse
className="bg1"
cx="75.71"
cy="65.66"
rx="1.81"
ry="1.89"
transform="translate(-4.99 125.09) rotate(-77.3)"
></ellipse>
<path
className="bg1"
d="M80.76,64.43a1.59,1.59,0,0,0-1.57,1.72,1.81,1.81,0,0,0,1.52,1.92,1.59,1.59,0,0,0,1.57-1.72A1.81,1.81,0,0,0,80.76,64.43Z"
></path>
<path
className="bg1"
d="M71.68,62.38a1.81,1.81,0,0,0-2.2,1.08,1.59,1.59,0,0,0,.68,2.22,1.81,1.81,0,0,0,2.2-1.08A1.59,1.59,0,0,0,71.68,62.38Z"
></path>
<ellipse
className="bg1"
cx="67.37"
cy="62.2"
rx="1.81"
ry="1.34"
transform="translate(-22.36 81.05) rotate(-54.58)"
></ellipse>
<ellipse
className="bg1"
cx="84.74"
cy="66.11"
rx="1.34"
ry="1.81"
transform="translate(-10.21 15.75) rotate(-10.02)"
></ellipse>
<path
className="gr9"
d="M58.8,78.53V76.77a2.13,2.13,0,0,0-1.5-2V63.95H53.45v10.8a2.13,2.13,0,0,0-1.5,2v1.75A13.08,13.08,0,0,0,42.4,93a25.5,25.5,0,0,0,13,3.31,25.5,25.5,0,0,0,13-3.31A13.08,13.08,0,0,0,58.8,78.53Z"
></path>
<ellipse
className="gr10"
cx="55.38"
cy="66.23"
rx="13.21"
ry="4.1"
></ellipse>
<ellipse
className="gr11"
cx="55.38"
cy="37.63"
rx="13.21"
ry="4.71"
></ellipse>
<path
className="bg1"
d="M55.38,67.2h0c-5.49,0-10-.7-10-4.11V39.88c0-3.41,4.5-4,10-4h0c5.49,0,10,.6,10,4V63.09C65.36,66.49,60.87,67.2,55.38,67.2Z"
></path>
<ellipse
className="bg1"
cx="55.48"
cy="87.61"
rx="2.06"
ry="1.97"
></ellipse>
<path
className="bg1"
d="M60.54,85.09a1.73,1.73,0,0,0-1.25,2.19A2,2,0,0,0,61.36,89a1.73,1.73,0,0,0,1.25-2.19A2,2,0,0,0,60.54,85.09Z"
></path>
<path
className="bg1"
d="M50.42,85.09a2,2,0,0,0-2.07,1.67A1.73,1.73,0,0,0,49.6,89a2,2,0,0,0,2.07-1.67A1.73,1.73,0,0,0,50.42,85.09Z"
></path>
<ellipse
className="bg1"
cx="45.8"
cy="85.92"
rx="1.97"
ry="1.45"
transform="translate(-51.15 94.99) rotate(-67.28)"
></ellipse>
<ellipse
className="bg1"
cx="65.15"
cy="85.92"
rx="1.45"
ry="1.97"
transform="translate(-28.13 31.83) rotate(-22.72)"
></ellipse>
</g>
</svg>
);
}

View File

@ -1,143 +0,0 @@
export function TentaMissiles() {
return (
<svg
className="special-icon-svg special-icon0-svg"
xmlns="http://www.w3.org/2000/svg"
aria-labelledby="special-icon0-svg-title-0e502ea9-8b6b-4d7f-b33a-faf5c982de89"
viewBox="0 0 107.96 107.96"
>
<g>
<title id="special-icon0-svg-title-0e502ea9-8b6b-4d7f-b33a-faf5c982de89">
Specials used
</title>
<rect
className="area"
width="107.96"
height="107.96"
rx="29.84"
ry="29.84"
></rect>
<path
className="gr1"
d="M38.63 81.45s4.44 5.13 1.18 9.51c-2.36 3.17-1.2 4.38 2.11 4.47 4 .11 6.64.53 5.91-3.08-.93-4.67 2.09-10 2.09-10z"
></path>
<path
className="gr2"
d="M76.24 72l-8.86-1a61.94 61.94 0 0 1 1.72 12.48l4.54.53a6.85 6.85 0 0 0 7.26-5.32A5.44 5.44 0 0 0 76.24 72z"
></path>
<path
className="gr3"
d="M76.93 70.82a58 58 0 0 0-5.42-5 14.65 14.65 0 0 0-6.46-3s.4.24-.68 0a9 9 0 0 1 3.54 1.88c2.34 2 2.57 3.48 4.48 5.19s1.75 3 1.18 5.31-2.1 9.33-3 11.17c-.65 1.35-1.68 2.63-2.87 2.91 1.62.17 3.31.13 3.4.14 2 .2 3.2-.69 4.08-2.51s2.34-8.55 2.9-10.8.62-3.48-1.15-5.29z"
></path>
<path
className="gr4"
d="M62.8 65c.65-3.26 2.75-2.28 4.55-.9a47.82 47.82 0 0 1 5.08 5.09c1.47 1.53 2.45 3 1.32 7s-1.93 6.49-2.7 9.12c-.57 1.94-1.92 5.44-4.18 3.47a53.65 53.65 0 0 1-5.39-5.79c-1.32-1.44-3.08-2.99-2.14-6.39s2.9-8.79 3.46-11.6z"
></path>
<path
className="bg1"
d="M64.32 83.12l-29.91-4c-3.59-.49-6.06-3.48-5.48-6.67.58-3.18 4-5.38 7.58-4.9l29.91 4C70 72 71.49 74.8 70.91 78c-.58 3.16-3 5.61-6.59 5.12z"
></path>
<path
className="gr5"
d="M49.92 86.81c1.81.42 3-.45 3.93-2.36s2.58-10.57 3.31-13 1-3.81-.56-5.84-2.3-3.2-4.21-5.61-3.55-2.65-5.39-3.46S18.57 54 17.59 53.5s-2.71-.27-3.14.92l2.06 27.49a7.77 7.77 0 0 0 1.71 2c.78.49 31.27 2.8 31.7 2.9z"
></path>
<path
className="gr6"
d="M14 55.6c.8-3.54 2.68-2.21 4.2-.56s4.55 4.27 5.79 5.85 1.79 3.84.58 8.14c-1.28 4.56-2.08 7.53-3 10.77-.61 2.11-2.25 5.78-4.11 3.4-2.36-3-4.5-5.62-5.39-7a7.88 7.88 0 0 1-1.33-7.13c.78-3.31 2.45-9.81 3.26-13.47z"
></path>
<path
className="bg1"
d="M19.91 69.12c-.42 4.89-2.14 8.71-3.83 8.54s-2.72-4.28-2.3-9.18 2.14-8.71 3.83-8.54 2.72 4.29 2.3 9.18z"
></path>
<rect
className="gr7"
x="41.07"
y="37.21"
width="60.79"
height="12.14"
rx="6.07"
ry="6.07"
transform="rotate(12.44 71.425 43.28)"
></rect>
<path
className="gr8"
d="M93.31 57.37l-8.75-1.93c.91 4.39 2.4 8.78 3.37 13.17l2.77.61a6.09 6.09 0 0 0 7.23-4.62 6.09 6.09 0 0 0-4.62-7.23z"
></path>
<path
className="gr9"
d="M97.55 40c-1.88-1.69-12.83-10.62-15.21-12.54a14.65 14.65 0 0 0-6.46-3s.4.24-.68 0a9 9 0 0 1 3.54 1.88c2.34 2 12.36 11 14.26 12.71s1.75 3 1.18 5.31S87.87 71 87 72.81c-.65 1.35-1.68 2.63-2.87 2.91 1.61.08 3.31.13 3.4.14 2 .2 3.2-.69 4.08-2.51s6.55-25.87 7.12-28.13.71-3.55-1.18-5.22z"
></path>
<path
className="gr10"
d="M73.65 26.62c.65-3.26 2.75-2.28 4.55-.9S91.55 37 93.06 38.33s2.45 3 1.32 7-6.15 23.82-6.92 26.45c-.57 1.94-1.92 5.44-4.18 3.47C80.41 72.7 69.21 61.08 68.1 59.9 66.78 58.52 65 57 66 53.57s7.08-24.13 7.65-26.95z"
></path>
<rect
className="bg1"
x="21.23"
y="25.32"
width="60.41"
height="12.44"
rx="6.22"
ry="6.22"
transform="rotate(12.44 51.436 31.531)"
></rect>
<rect
className="bg1"
x="29.8"
y="34.5"
width="62.28"
height="12.44"
rx="6.22"
ry="6.22"
transform="rotate(12.44 60.932 40.71)"
></rect>
<rect
className="bg1"
x="47.62"
y="51.94"
width="40.93"
height="12.44"
rx="6.22"
ry="6.22"
transform="rotate(12.44 68.076 58.128)"
></rect>
<path
className="gr11"
d="M62.49 71.33c2 .2 3.2-.69 4.08-2.51S73.12 43 73.69 40.7s.72-3.54-1.16-5.24-10.8-10.55-13.11-12.55-4.14-2.07-6.21-2.63-26.94-5.87-28.07-6.23-3 0-3.35 1.13l8.1 48.61A7.76 7.76 0 0 0 32 65.46c.85.35 30 5.83 30.49 5.87z"
></path>
<path
className="gr12"
d="M21.44 16.29c.65-3.26 2.85-2.41 4.55-.9S39.35 26.68 40.85 28s2.45 3 1.32 7S36 58.78 35.25 61.41c-.57 1.94-1.92 5.44-4.18 3.47C28.2 62.36 17 50.74 15.89 49.57c-1.31-1.38-3.07-2.95-2.13-6.33s7.12-24.13 7.68-26.95z"
></path>
<ellipse
className="bg1"
cx="24.69"
cy="27.05"
rx="3.39"
ry="8.09"
></ellipse>
<ellipse
className="bg1"
cx="33.35"
cy="35.15"
rx="3.39"
ry="8.09"
></ellipse>
<ellipse
className="bg1"
cx="20.36"
cy="43.24"
rx="3.39"
ry="8.09"
></ellipse>
<ellipse
className="bg1"
cx="29.02"
cy="51.89"
rx="3.39"
ry="8.09"
></ellipse>
</g>
</svg>
);
}

View File

@ -1,179 +0,0 @@
export function UltraStamp() {
return (
<svg
className="special-icon-svg special-icon18-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 107.96 107.96"
>
<g>
<rect
className="area"
width="107.51"
height="107.51"
rx="29.71"
ry="29.71"
></rect>
<path
className="gr1"
d="M45.84,95.17c-1.86-.72-2.44-1.07-4.31-1.78l-1.76,2.13a1.4,1.4,0,0,0,.27,2,6.81,6.81,0,0,0,1.69,1,8.06,8.06,0,0,0,1.9.66,1.4,1.4,0,0,0,1.72-1.14Z"
></path>
<polygon
className="gr2"
points="45.03 83.18 41.41 93.53 45.8 95.41 51.47 86.08 45.03 83.18"
></polygon>
<polygon
className="gr3"
points="57.18 80.46 45.25 75.03 46.6 70.64 59.5 76.59 57.18 80.46"
></polygon>
<polygon
className="gr4"
points="55.71 83.88 43.78 78.46 45.13 74.06 58.02 80.02 55.71 83.88"
></polygon>
<polygon
className="gr5"
points="53.97 87.67 42.03 82.25 43.38 77.85 56.28 83.81 53.97 87.67"
></polygon>
<path
className="bg1"
d="M47.77,67.05,68.92,77a15.26,15.26,0,0,0,9.71,1.25h0a9.25,9.25,0,0,0,6.57-4.92L93,56.86a9.29,9.29,0,0,0-.4-8.21h0A15.29,15.29,0,0,0,85.4,42L64.24,32a15.31,15.31,0,0,0-9.7-1.26h0A9.28,9.28,0,0,0,48,35.66L40.22,52.14a9.26,9.26,0,0,0,.4,8.21h0A15.31,15.31,0,0,0,47.77,67.05Z"
></path>
<path
className="gr6"
d="M47.77,67.05,68.92,77a15.26,15.26,0,0,0,9.71,1.25h0a9.25,9.25,0,0,0,6.57-4.92L93,56.86a9.29,9.29,0,0,0-.4-8.21h0A15.29,15.29,0,0,0,85.4,42L64.24,32a15.31,15.31,0,0,0-9.7-1.26h0A9.28,9.28,0,0,0,48,35.66L40.22,52.14a9.26,9.26,0,0,0,.4,8.21h0A15.31,15.31,0,0,0,47.77,67.05Z"
></path>
<path
className="gr7"
d="M38.66,68.82l21.16,10a13.55,13.55,0,0,0,10,.63h0A12.33,12.33,0,0,0,77.1,73l9.51-20.22a12.33,12.33,0,0,0,.3-9.7h0a13.53,13.53,0,0,0-6.86-7.3l-21.15-10a13.46,13.46,0,0,0-10-.63h0a12.29,12.29,0,0,0-7.28,6.41L32.1,51.81a12.33,12.33,0,0,0-.3,9.7h0A13.51,13.51,0,0,0,38.66,68.82Z"
></path>
<path
className="bg1"
d="M80.35,29.75c-1.92-5.69-5.44-9.64-9.82-11.71h0l-4.13-2L39.24,73.58l3.42,1.61C49.08,78.6,57.51,77.75,65.43,72,78.36,62.56,85,43.66,80.35,29.75Z"
></path>
<path
className="gr8"
d="M80.35,29.75c-1.92-5.69-5.44-9.64-9.82-11.71h0l-4.13-2L39.24,73.58l3.42,1.61C49.08,78.6,57.51,77.75,65.43,72,78.36,62.56,85,43.66,80.35,29.75Z"
></path>
<ellipse
className="bg1"
cx="53.16"
cy="45.01"
rx="31.81"
ry="23.07"
transform="translate(-10.24 73.85) rotate(-64.72)"
></ellipse>
<ellipse
className="gr9"
cx="53.16"
cy="45.01"
rx="31.81"
ry="23.07"
transform="translate(-10.24 73.85) rotate(-64.72)"
></ellipse>
<path
className="bg1"
d="M73.24,26.39c-1.92-5.69-5.45-9.65-9.83-11.71h0l-4.13-1.95L32.13,70.22l3.41,1.61c6.42,3.41,14.85,2.56,22.78-3.22C71.25,59.2,77.92,40.3,73.24,26.39Z"
></path>
<path
className="gr10"
d="M73.24,26.39c-1.92-5.69-5.45-9.65-9.83-11.71h0l-4.13-1.95L32.13,70.22l3.41,1.61c6.42,3.41,14.85,2.56,22.78-3.22C71.25,59.2,77.92,40.3,73.24,26.39Z"
></path>
<ellipse
className="bg1"
cx="46.05"
cy="41.65"
rx="31.81"
ry="23.07"
transform="translate(-11.28 65.49) rotate(-64.72)"
></ellipse>
<ellipse
className="gr11"
cx="46.05"
cy="41.65"
rx="31.81"
ry="23.07"
transform="translate(-11.28 65.49) rotate(-64.72)"
></ellipse>
<path
className="bg1"
d="M65.73,22.85c-1.91-5.69-5.44-9.65-9.82-11.71h0l-4.13-2L24.62,66.67,28,68.29c6.42,3.4,14.85,2.55,22.78-3.22C63.74,55.65,70.42,36.75,65.73,22.85Z"
></path>
<path
className="gr12"
d="M65.73,22.85c-1.91-5.69-5.44-9.65-9.82-11.71h0l-4.13-2L24.62,66.67,28,68.29c6.42,3.4,14.85,2.55,22.78-3.22C63.74,55.65,70.42,36.75,65.73,22.85Z"
></path>
<ellipse
className="bg1"
cx="38.54"
cy="38.1"
rx="31.81"
ry="23.07"
transform="translate(-12.37 56.68) rotate(-64.72)"
></ellipse>
<ellipse
className="gr13"
cx="38.54"
cy="38.1"
rx="31.81"
ry="23.07"
transform="translate(-12.37 56.68) rotate(-64.72)"
></ellipse>
<ellipse
className="gr14"
cx="38.54"
cy="38.1"
rx="27.66"
ry="20.06"
transform="translate(-12.37 56.68) rotate(-64.72)"
></ellipse>
<path
className="gr15"
d="M23.35,39.61,16.9,43.1a27.81,27.81,0,0,0,0,3.42l6.66-3.59A15.24,15.24,0,0,1,23.35,39.61Z"
></path>
<path
className="gr16"
d="M29.48,30.41c3.26,1.51,4.2,10.63,7.1,12,1.76.85,5.25-.62,8.46-1.55l-2-10.84L40.59,16.33l4.11-2.22-2.31-2.4c-6.17.52-12.64,4.35-17.59,10.63L24,25.26,28.15,23Z"
></path>
<path
className="gr17"
d="M27.68,49.17l-9.09,4.91a19.62,19.62,0,0,0,1.34,2.72L30.8,50.93l-.19-1A3.51,3.51,0,0,1,27.68,49.17Z"
></path>
<path
className="gr18"
d="M24.2,45.1l-7,3.79A23.22,23.22,0,0,0,17.87,52l7.93-4.27A8.24,8.24,0,0,1,24.2,45.1Z"
></path>
<path
className="gr19"
d="M44.17,53.05l1.31,7.23a30.72,30.72,0,0,0,2.68-2.12L46.9,51.21A15.38,15.38,0,0,1,44.17,53.05Z"
></path>
<path
className="gr20"
d="M56.64,18.65l-3.24-.31L54.23,23l-9.15,4.94,2.25,12.45a5.2,5.2,0,0,1,2.87.13l6.56-3.54.84,4.61,1.63-2.19C61.09,31.55,60.13,23.94,56.64,18.65Z"
></path>
<path
className="gr21"
d="M39.08,54.19,40.66,63a24.63,24.63,0,0,0,2.82-1.4l-1.39-7.66A8,8,0,0,1,39.08,54.19Z"
></path>
<path
className="gr22"
d="M33.82,48.21a1,1,0,0,0-1.11,0l2.91,16.1a19.54,19.54,0,0,0,3-.66L36.74,53.58C32.37,51.9,35.77,49.15,33.82,48.21Z"
></path>
<path
className="gr23"
d="M40.36,43.13a1.43,1.43,0,0,0-.28,1.14c.15,1.09.77,1.54,1.72,1.33a2.75,2.75,0,0,0,1.77-2.54.91.91,0,0,0-.18-.7l-1.55.34A14.78,14.78,0,0,0,40.36,43.13Z"
></path>
<path
className="gr24"
d="M47.25,44.79c.18-1.94-.76-2.49-2.17-2.51a1.33,1.33,0,0,1,.36,1.24,4.87,4.87,0,0,1-3.77,4.26c-2,.32-3.22-.48-3.48-2.35a2.18,2.18,0,0,1,.39-1.64,3.81,3.81,0,0,0-2,3.91c.35,3,2.18,4.3,5.05,3.83S46.94,48,47.25,44.79Z"
></path>
<path
className="gr25"
d="M33.53,39.82a1.48,1.48,0,0,1-.71.93c-.95.55-1.68.35-2.11-.53a2.74,2.74,0,0,1,.88-3,.9.9,0,0,1,.65-.3l.7,1.43A15,15,0,0,1,33.53,39.82Z"
></path>
<path
className="gr26"
d="M28,35.43c1.39-1.36,2.41-1,3.3.14a1.35,1.35,0,0,0-1.19.49,4.91,4.91,0,0,0-1,5.62c1,1.74,2.37,2.24,4,1.28a2.15,2.15,0,0,0,1-1.33,3.81,3.81,0,0,1-1.81,4c-2.56,1.59-4.7,1-6.13-1.6S25.64,37.66,28,35.43Z"
></path>
</g>
</svg>
);
}

View File

@ -1,192 +0,0 @@
import clsx from "clsx";
import { AutoBombRush } from "./AutoBombRush";
import { Baller } from "./Baller";
import { BooyahBomb } from "./BooyahBomb";
import { BubbleBlower } from "./BubbleBlower";
import { BurstBombRush } from "./BurstBombRush";
import { ColorDefs } from "./ColorDefs";
import { CurlingBombRush } from "./CurlingBombRush";
import { Deaths } from "./Deaths";
import { InkArmor } from "./InkArmor";
import { Inkjet } from "./Inkjet";
import { InkStorm } from "./InkStorm";
import { Kills } from "./Kills";
import { Splashdown } from "./Splashdown";
import { SplatBombRush } from "./SplatBombRush";
import { Stingray } from "./Stingray";
import { SuctionBombRush } from "./SuctionBombRush";
import { TentaMissiles } from "./TentaMissiles";
import { UltraStamp } from "./UltraStamp";
const iconToId = {
"Sploosh-o-matic": Splashdown,
"Neo Sploosh-o-matic": TentaMissiles,
"Sploosh-o-matic 7": UltraStamp,
"Splattershot Jr.": InkArmor,
"Custom Splattershot Jr.": InkStorm,
"Kensa Splattershot Jr.": BubbleBlower,
"Splash-o-matic": Inkjet,
"Neo Splash-o-matic": SuctionBombRush,
"Aerospray MG": CurlingBombRush,
"Aerospray RG": Baller,
"Aerospray PG": BooyahBomb,
Splattershot: Splashdown,
"Hero Shot Replica": Splashdown,
"Tentatek Splattershot": Inkjet,
"Octo Shot Replica": Inkjet,
"Kensa Splattershot": TentaMissiles,
".52 Gal": Baller,
".52 Gal Deco": Stingray,
"Kensa .52 Gal": BooyahBomb,
"N-ZAP '85": InkArmor,
"N-ZAP '89": TentaMissiles,
"N-ZAP '83": InkStorm,
"Splattershot Pro": InkStorm,
"Forge Splattershot Pro": BubbleBlower,
"Kensa Splattershot Pro": BooyahBomb,
".96 Gal": InkArmor,
".96 Gal Deco": Splashdown,
"Jet Squelcher": TentaMissiles,
"Custom Jet Squelcher": Stingray,
"Luna Blaster": Baller,
"Luna Blaster Neo": SuctionBombRush,
"Kensa Luna Blaster": InkStorm,
Blaster: Splashdown,
"Hero Blaster Replica": Splashdown,
"Custom Blaster": Inkjet,
"Range Blaster": InkStorm,
"Custom Range Blaster": BubbleBlower,
"Grim Range Blaster": TentaMissiles,
"Clash Blaster": Stingray,
"Clash Blaster Neo": TentaMissiles,
"Rapid Blaster": SplatBombRush,
"Rapid Blaster Deco": Inkjet,
"Kensa Rapid Blaster": Baller,
"Rapid Blaster Pro": InkStorm,
"Rapid Blaster Pro Deco": InkArmor,
"L-3 Nozzlenose": Baller,
"L-3 Nozzlenose D": Inkjet,
"Kensa L-3 Nozzlenose": UltraStamp,
"H-3 Nozzlenose": TentaMissiles,
"H-3 Nozzlenose D": InkArmor,
"Cherry H-3 Nozzlenose": BubbleBlower,
Squeezer: Stingray,
"Foil Squeezer": BubbleBlower,
"Carbon Roller": InkStorm,
"Carbon Roller Deco": AutoBombRush,
"Splat Roller": Splashdown,
"Hero Roller Replica": Splashdown,
"Krak-On Splat Roller": Baller,
"Kensa Splat Roller": BubbleBlower,
"Dynamo Roller": Stingray,
"Gold Dynamo Roller": InkArmor,
"Kensa Dynamo Roller": BooyahBomb,
"Flingza Roller": SplatBombRush,
"Foil Flingza Roller": TentaMissiles,
Inkbrush: Splashdown,
"Inkbrush Nouveau": Baller,
"Permanent Inkbrush": InkArmor,
Octobrush: Inkjet,
"Herobrush Replica": Inkjet,
"Octobrush Nouveau": TentaMissiles,
"Kensa Octobrush": UltraStamp,
"Classic Squiffer": InkArmor,
"New Squiffer": Baller,
"Fresh Squiffer": Inkjet,
"Splat Charger": Stingray,
"Hero Charger Replica": Stingray,
"Firefin Splat Charger": SuctionBombRush,
"Kensa Charger": Baller,
Splatterscope: Stingray,
"Firefin Splatterscope": SuctionBombRush,
"Kensa Splatterscope": Baller,
"E-liter 4K": InkStorm,
"Custom E-liter 4K": BubbleBlower,
"E-liter 4K Scope": InkStorm,
"Custom E-liter 4K Scope": BubbleBlower,
"Bamboozler 14 Mk I": TentaMissiles,
"Bamboozler 14 Mk II": BurstBombRush,
"Bamboozler 14 Mk III": BubbleBlower,
"Goo Tuber": Splashdown,
"Custom Goo Tuber": Inkjet,
Slosher: TentaMissiles,
"Hero Slosher Replica": TentaMissiles,
"Slosher Deco": Baller,
"Soda Slosher": BurstBombRush,
"Tri-Slosher": InkArmor,
"Tri-Slosher Nouveau": InkStorm,
"Sloshing Machine": Stingray,
"Sloshing Machine Neo": SplatBombRush,
"Kensa Sloshing Machine": Splashdown,
Bloblobber: InkStorm,
"Bloblobber Deco": SuctionBombRush,
Explosher: BubbleBlower,
"Custom Explosher": Baller,
"Mini Splatling": TentaMissiles,
"Zink Mini Splatling": InkStorm,
"Kensa Mini Splatling": UltraStamp,
"Heavy Splatling": Stingray,
"Hero Splatling Replica": Stingray,
"Heavy Splatling Deco": BubbleBlower,
"Heavy Splatling Remix": BooyahBomb,
"Hydra Splatling": Splashdown,
"Custom Hydra Splatling": InkArmor,
"Ballpoint Splatling": Inkjet,
"Ballpoint Splatling Nouveau": InkStorm,
"Nautilus 47": Baller,
"Nautilus 79": Inkjet,
"Dapple Dualies": SuctionBombRush,
"Dapple Dualies Nouveau": InkStorm,
"Clear Dapple Dualies": Splashdown,
"Splat Dualies": TentaMissiles,
"Hero Dualie Replicas": TentaMissiles,
"Enperry Splat Dualies": Inkjet,
"Kensa Splat Dualies": Baller,
"Glooga Dualies": Inkjet,
"Glooga Dualies Deco": Baller,
"Kensa Glooga Dualies": InkArmor,
"Dualie Squelchers": TentaMissiles,
"Custom Dualie Squelchers": InkStorm,
"Dark Tetra Dualies": Splashdown,
"Light Tetra Dualies": AutoBombRush,
"Splat Brella": InkStorm,
"Hero Brella Replica": InkStorm,
"Sorella Brella": SplatBombRush,
"Tenta Brella": BubbleBlower,
"Tenta Sorella Brella": CurlingBombRush,
"Tenta Camo Brella": UltraStamp,
"Undercover Brella": Splashdown,
"Undercover Sorella Brella": Baller,
"Kensa Undercover Brella": InkArmor,
kills: Kills,
deaths: Deaths,
} as const;
const SplatnetIcon = ({
icon,
count,
smallCount,
bravo,
}: {
icon: keyof typeof iconToId;
count: number;
smallCount?: number;
bravo?: boolean;
}) => {
const Component = iconToId[icon];
return (
<div className={clsx("splatnet-icon-container", { bravo })}>
<Component />
<div className="splatnet-icon-text">
<span className="splatnet-icon-x">x</span>
<span>{count}</span>
{smallCount ? (
<span className="splatnet-icon-smallCount">({smallCount})</span>
) : null}
</div>
<ColorDefs />
</div>
);
};
export default SplatnetIcon;

View File

@ -1,35 +0,0 @@
import clsx from "clsx";
import { MyCSSProperties } from "~/utils";
export function ActionSectionWrapper({
children,
icon,
...rest
}: {
children: React.ReactNode;
icon?: "warning" | "info" | "success" | "error";
"justify-center"?: boolean;
"data-cy"?: string;
}) {
// todo: flex-dir: column on mobile
const style: MyCSSProperties | undefined = icon
? {
"--action-section-icon-color": `var(--theme-${icon})`,
}
: undefined;
return (
<section
className="tournament__action-section"
style={style}
data-cy={rest["data-cy"]}
>
<div
className={clsx("tournament__action-section__content", {
"justify-center": rest["justify-center"],
})}
>
{children}
</div>
</section>
);
}

View File

@ -1,111 +0,0 @@
import { Form, useMatches } from "@remix-run/react";
import invariant from "tiny-invariant";
import { allMatchesReported, matchIsOver } from "~/core/tournament/utils";
import type { BracketModified } from "~/services/bracket";
import type { FindTournamentByNameForUrlI } from "~/services/tournament";
import { useUser } from "~/hooks/common";
import { ActionSectionWrapper } from "./ActionSectionWrapper";
import { DuringMatchActions } from "./DuringMatchActions";
import { Button } from "../Button";
import { isTournamentAdmin } from "~/core/tournament/validators";
export function BracketActions({ data }: { data: BracketModified }) {
const user = useUser();
const [, parentRoute] = useMatches();
const tournament = parentRoute.data as FindTournamentByNameForUrlI;
const ownTeam = tournament.teams.find((team) =>
team.members.some(({ member }) => member.id === user?.id)
);
if (
!tournament.concluded &&
isTournamentAdmin({
userId: user?.id,
organization: tournament.organizer,
}) &&
allMatchesReported(data)
)
return (
<Form method="post">
<input type="hidden" name="_action" value="FINISH_TOURNAMENT" />
<div className="flex justify-center">
<Button type="submit">Finish tournament</Button>
</div>
</Form>
);
if (tournament.concluded || !ownTeam) return null;
const allMatches = data.rounds.flatMap((round, roundI) =>
round.matches.map((match) => ({
...match,
round,
isFirstRound: roundI === 0,
}))
);
const currentMatch = allMatches.find((match) => {
const hasBothParticipants = match.participants?.every(
(p) => typeof p === "string"
);
const isOwnMatch = match.participants?.some((p) => p === ownTeam.name);
return (
hasBothParticipants &&
isOwnMatch &&
!matchIsOver({ bestOf: match.round.stages.length, score: match.score })
);
});
if (currentMatch) {
return (
<DuringMatchActions
ownTeam={ownTeam}
currentMatch={currentMatch}
currentRound={currentMatch.round}
/>
);
}
const nextMatch = allMatches.find((match) => {
const participantsCount = match.participants?.reduce(
(acc, cur) => acc + (cur === null ? 1 : 0),
0
);
return (
participantsCount === 1 &&
match.participants?.some((p) => p === ownTeam.name) &&
!match.isFirstRound
);
});
// we are out of the tournament
if (!nextMatch) return null;
const matchWeAreWaitingFor = allMatches.find(
(match) =>
[match.winnerDestinationMatchId, match.loserDestinationMatchId].includes(
nextMatch.id
) && !match.participants?.includes(ownTeam.name)
);
invariant(matchWeAreWaitingFor, "matchWeAreWaitingFor is undefined");
if (matchWeAreWaitingFor.participants?.filter(Boolean).length !== 2) {
return (
<ActionSectionWrapper>
Waiting on match number {matchWeAreWaitingFor.number} (missing teams)
</ActionSectionWrapper>
);
}
return (
<ActionSectionWrapper>
Waiting on <b>{matchWeAreWaitingFor.participants[0]}</b> vs.
<b>{matchWeAreWaitingFor.participants[1]}</b>
<i>
{(matchWeAreWaitingFor.score ?? [0, 0]).join("-")} - Best of{" "}
{matchWeAreWaitingFor.round.stages.length}
</i>
</ActionSectionWrapper>
);
}

View File

@ -1,152 +0,0 @@
// TODO: Warning: Text content did not match. Server: "57" Client: "56"
import * as React from "react";
import { Form, useLoaderData, useTransition } from "@remix-run/react";
import {
checkInClosesDate,
TOURNAMENT_TEAM_ROSTER_MIN_SIZE,
} from "~/constants";
import { TournamentAction } from "~/routes/to/$organization.$tournament";
import type { FindTournamentByNameForUrlI } from "~/services/tournament";
import { useUser } from "~/hooks/common";
import { Button } from "../Button";
import { AlertIcon } from "../icons/Alert";
import { CheckInIcon } from "../icons/CheckIn";
import { ErrorIcon } from "../icons/Error";
import { SuccessIcon } from "../icons/Success";
import { ActionSectionWrapper } from "./ActionSectionWrapper";
// TODO: warning when not registered but check in is open
export function CheckinActions() {
const tournament = useLoaderData<FindTournamentByNameForUrlI>();
const user = useUser();
const transition = useTransition();
const timeInMinutesBeforeCheckInCloses = React.useCallback(() => {
return Math.floor(
(checkInClosesDate(tournament.startTime).getTime() -
new Date().getTime()) /
(1000 * 60)
);
}, [tournament.startTime]);
const [minutesTillCheckInCloses, setMinutesTillCheckInCloses] =
React.useState(timeInMinutesBeforeCheckInCloses());
React.useEffect(() => {
const timeout = setInterval(() => {
setMinutesTillCheckInCloses(timeInMinutesBeforeCheckInCloses());
}, 1000 * 15);
return () => clearTimeout(timeout);
}, []);
const ownTeam = tournament.teams.find((team) =>
team.members.some(
({ member, captain }) => captain && member.id === user?.id
)
);
const tournamentHasStarted = tournament.brackets.some((b) => b.rounds.length);
if (!ownTeam || tournamentHasStarted) {
return null;
}
if (ownTeam.checkedInTime) {
return (
<ActionSectionWrapper icon="success" data-cy="checked-in-alert">
<SuccessIcon /> Your team is checked in!
</ActionSectionWrapper>
);
}
const checkInHasStarted = new Date(tournament.checkInStartTime) < new Date();
const teamHasEnoughMembers =
ownTeam.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE;
if (!checkInHasStarted && !teamHasEnoughMembers) {
return (
<ActionSectionWrapper icon="warning" data-cy="team-size-alert">
<AlertIcon /> You need at least 4 players in your roster to play
</ActionSectionWrapper>
);
}
const differenceInMinutesBetweenCheckInAndStart = Math.floor(
(new Date(tournament.startTime).getTime() -
new Date(tournament.checkInStartTime).getTime()) /
(1000 * 60)
);
if (!checkInHasStarted && teamHasEnoughMembers) {
return (
<ActionSectionWrapper icon="info">
<AlertIcon /> Check-in starts{" "}
{differenceInMinutesBetweenCheckInAndStart} minutes before the
tournament starts
</ActionSectionWrapper>
);
}
if (
checkInHasStarted &&
!teamHasEnoughMembers &&
minutesTillCheckInCloses > 0
) {
return (
<ActionSectionWrapper icon="warning" data-cy="not-enough-players-warning">
<AlertIcon /> You need at least 4 players in your roster to play.
Check-in is open for {minutesTillCheckInCloses} more{" "}
{minutesTillCheckInCloses > 1 ? "minutes" : "minute"}
</ActionSectionWrapper>
);
}
if (
checkInHasStarted &&
teamHasEnoughMembers &&
minutesTillCheckInCloses > 0
) {
return (
<ActionSectionWrapper
icon={minutesTillCheckInCloses <= 1 ? "warning" : "info"}
data-cy="check-in-alert"
>
{minutesTillCheckInCloses > 1 ? (
<>
<AlertIcon /> Check-in is open for {minutesTillCheckInCloses} more
minutes
</>
) : (
<>
<AlertIcon /> Check-in closes in less than a minute
</>
)}
<Form
method="post"
className="tournament__action-section__button-container"
>
<input
type="hidden"
name="_action"
value={TournamentAction.CHECK_IN}
/>
<input type="hidden" name="teamId" value={ownTeam.id} />
<Button
variant="outlined"
type="submit"
loading={transition.state !== "idle"}
icon={<CheckInIcon />}
data-cy="check-in-button"
>
Check-in
</Button>
</Form>
</ActionSectionWrapper>
);
}
return (
<ActionSectionWrapper icon="error">
<ErrorIcon /> Check-in has closed. Your team is not checked in
</ActionSectionWrapper>
);
}

View File

@ -1,104 +0,0 @@
import { Form, useMatches } from "@remix-run/react";
import invariant from "tiny-invariant";
import type { BracketModified } from "~/services/bracket";
import type { FindTournamentByNameForUrlI } from "~/services/tournament";
import { Unpacked } from "~/utils";
import { Chat } from "../Chat";
import { SubmitButton } from "../SubmitButton";
import { ActionSectionWrapper } from "./ActionSectionWrapper";
import { DuringMatchActionsRosters } from "./DuringMatchActionsRosters";
import { FancyStageBanner } from "./FancyStageBanner";
export function DuringMatchActions({
ownTeam,
currentMatch,
currentRound,
}: {
ownTeam: Unpacked<FindTournamentByNameForUrlI["teams"]>;
currentMatch: Unpacked<Unpacked<BracketModified["rounds"]>["matches"]>;
currentRound: Unpacked<BracketModified["rounds"]>;
}) {
const [, parentRoute] = useMatches();
const { teams } = parentRoute.data as FindTournamentByNameForUrlI;
const opponentTeam = teams.find(
(team) =>
[currentMatch.participants?.[0], currentMatch.participants?.[1]].includes(
team.name
) && team.id !== ownTeam.id
);
invariant(opponentTeam, "opponentTeam is undefined");
const currentPosition =
currentMatch.score?.reduce((acc, cur) => acc + cur, 1) ?? 1;
const currentStage = currentRound.stages.find(
(s) => s.position === currentPosition
);
invariant(currentStage, "currentStage is undefined");
const { stage } = currentStage;
const roundInfos = [
<>
<b>{currentMatch.score?.join("-")}</b> (Best of{" "}
{currentRound.stages.length})
</>,
];
return (
<>
<Chat
key={currentMatch.id}
id={currentMatch.id}
users={Object.fromEntries(
[...ownTeam.members, ...opponentTeam.members].map((m) => [
m.member.id,
{
name: m.member.discordName,
info: m.member.friendCode,
},
])
)}
/>
<div className="tournament-bracket__during-match-actions">
<FancyStageBanner
stage={stage}
roundNumber={currentPosition}
infos={roundInfos}
>
{currentPosition > 1 && (
<Form method="post">
<input type="hidden" name="_action" value="UNDO_REPORT_SCORE" />
<input
type="hidden"
name="position"
value={currentPosition - 1}
/>
<input type="hidden" name="matchId" value={currentMatch.id} />
<div className="tournament-bracket__stage-banner__bottom-bar">
<SubmitButton
actionType="UNDO_REPORT_SCORE"
className="tournament-bracket__stage-banner__undo-button"
loadingText="Undoing..."
>
Undo last score
</SubmitButton>
</div>
</Form>
)}
</FancyStageBanner>
<ActionSectionWrapper>
<DuringMatchActionsRosters
// Without the key prop when switching to another match the winnerId is remembered
// which causes "No winning team matching the id" error.
// Switching the key props forces the component to remount.
key={currentMatch.id}
ownTeam={ownTeam}
opponentTeam={opponentTeam}
matchId={currentMatch.id}
position={currentPosition}
/>
</ActionSectionWrapper>
</div>
</>
);
}

View File

@ -1,123 +0,0 @@
import * as React from "react";
import { Form } from "@remix-run/react";
import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "~/constants";
import type { FindTournamentByNameForUrlI } from "~/services/tournament";
import { Unpacked } from "~/utils";
import { SubmitButton } from "../SubmitButton";
import { TeamRosterInputs } from "./TeamRosterInputs";
export function DuringMatchActionsRosters({
ownTeam,
opponentTeam,
matchId,
position,
}: {
ownTeam: Unpacked<FindTournamentByNameForUrlI["teams"]>;
opponentTeam: Unpacked<FindTournamentByNameForUrlI["teams"]>;
matchId: string;
position: number;
}) {
const [checkedPlayers, setCheckedPlayers] = React.useState<
[string[], string[]]
>(checkedPlayersInitialState([ownTeam, opponentTeam]));
const [winnerId, setWinnerId] = React.useState<string | undefined>();
return (
<Form method="post" className="width-full">
<div>
<TeamRosterInputs
teamUpper={ownTeam}
teamLower={opponentTeam}
winnerId={winnerId}
setWinnerId={setWinnerId}
checkedPlayers={checkedPlayers}
setCheckedPlayers={setCheckedPlayers}
/>
<div className="tournament-bracket__during-match-actions__actions">
<input type="hidden" name="_action" value="REPORT_SCORE" />
<input type="hidden" name="matchId" value={matchId} />
<input type="hidden" name="winnerTeamId" value={winnerId ?? ""} />
<input
type="hidden"
name="playerIds"
value={JSON.stringify(checkedPlayers.flat())}
/>
<input type="hidden" name="position" value={position} />
<ReportScoreButtons
checkedPlayers={checkedPlayers}
winnerName={winningTeam()}
clearWinner={() => setWinnerId(undefined)}
/>
</div>
</div>
</Form>
);
function winningTeam() {
if (!winnerId) return;
if (ownTeam.id === winnerId) return ownTeam.name;
if (opponentTeam.id === winnerId) return opponentTeam.name;
throw new Error("No winning team matching the id");
}
}
// TODO: remember what previously selected for our team
function checkedPlayersInitialState([teamOne, teamTwo]: [
Unpacked<FindTournamentByNameForUrlI["teams"]>,
Unpacked<FindTournamentByNameForUrlI["teams"]>
]): [string[], string[]] {
const result: [string[], string[]] = [[], []];
if (teamOne.members.length === TOURNAMENT_TEAM_ROSTER_MIN_SIZE) {
result[0].push(...teamOne.members.map(({ member }) => member.id));
}
if (teamTwo.members.length === TOURNAMENT_TEAM_ROSTER_MIN_SIZE) {
result[1].push(...teamTwo.members.map(({ member }) => member.id));
}
return result;
}
function ReportScoreButtons({
checkedPlayers,
winnerName,
clearWinner,
}: {
checkedPlayers: string[][];
winnerName?: string;
clearWinner: () => void;
}) {
if (
!checkedPlayers.every(
(team) => team.length === TOURNAMENT_TEAM_ROSTER_MIN_SIZE
)
) {
return (
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
Please choose exactly {TOURNAMENT_TEAM_ROSTER_MIN_SIZE}+
{TOURNAMENT_TEAM_ROSTER_MIN_SIZE} players to report score
</p>
);
}
if (!winnerName) {
return (
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
Please select the winning team
</p>
);
}
return (
<SubmitButton
variant="minimal"
actionType="REPORT_SCORE"
loadingText={`Reporting ${winnerName} win...`}
onSuccess={clearWinner}
>
Report {winnerName} win
</SubmitButton>
);
}

View File

@ -1,152 +0,0 @@
import clsx from "clsx";
import invariant from "tiny-invariant";
import { matchIsOver } from "~/core/tournament/utils";
import type { BracketModified } from "~/services/bracket";
import { MyCSSProperties, Unpacked } from "~/utils";
import { EliminationBracketMatch } from "./EliminationBracketMatch";
export function EliminationBracket({
rounds,
ownTeamName,
}: {
rounds: BracketModified["rounds"];
ownTeamName?: string;
}) {
const style: MyCSSProperties = {
"--brackets-columns": rounds.length,
"--brackets-max-matches": rounds[0].matches.length,
};
return (
<div className="tournament-bracket__elim__container" style={style}>
<div className="tournament-bracket__elim__bracket">
{rounds.map((round, i) => (
<RoundInfo
key={round.id}
title={round.name}
isLast={i === rounds.length - 1}
bestOf={round.stages.length}
status="UPCOMING"
/>
))}
{rounds.map((round, roundI) => {
const nextRound: Unpacked<BracketModified["rounds"]> | undefined =
rounds[roundI + 1];
const amountOfMatchesBetweenRoundsEqual =
round.matches.length === nextRound?.matches.length;
const drawStraightLines =
round.matches.length === 1 || amountOfMatchesBetweenRoundsEqual;
const style: MyCSSProperties = {
"--brackets-bottom-border-length": drawStraightLines
? 0
: undefined,
"--brackets-column-matches": round.matches.length,
"--height-override": drawStraightLines ? "1px" : undefined,
};
return (
<div
key={round.id}
className="tournament-bracket__elim__column"
style={style}
>
<div className="tournament-bracket__elim__matches">
{round.matches.map((match) => {
return (
<EliminationBracketMatch
hidden={match.number === 0}
key={match.id}
match={match}
ownTeamName={ownTeamName}
isOver={matchIsOver({
bestOf: round.stages.length,
score: match.score,
})}
/>
);
})}
</div>
<div className="tournament-bracket__elim__lines">
{roundI !== rounds.length - 1 &&
theKindOfLinesToDraw({
amountOfMatchesBetweenRoundsEqual,
round,
roundI,
}).map((className, i) => (
<div className={className} key={i} />
))}
</div>
</div>
);
})}
</div>
</div>
);
}
function theKindOfLinesToDraw({
round,
roundI,
amountOfMatchesBetweenRoundsEqual,
}: {
round: Unpacked<BracketModified["rounds"]>;
roundI: number;
amountOfMatchesBetweenRoundsEqual: boolean;
}): (undefined | "no-line" | "bottom-only" | "top-only")[] {
return new Array(
amountOfMatchesBetweenRoundsEqual
? round.matches.length
: Math.ceil(round.matches.length / 2)
)
.fill(null)
.map((_, lineI) => {
// lines 0 1 2 3
// rounds 0 1 2 3 4 5 6 7
if (roundI !== 0) {
return undefined;
}
// TODO: better identifier for losers
if (round.name.includes("Losers")) {
return round.matches[lineI]?.number === 0 ? "no-line" : undefined;
}
const matchOne = round.matches[lineI * 2];
const matchTwo = round.matches[lineI * 2 + 1];
invariant(matchOne, "matchOne is undefined");
invariant(matchTwo, "matchTwo is undefined");
if (
matchOne.participants?.includes(null) &&
matchTwo.participants?.includes(null)
) {
return "no-line";
}
if (matchOne.participants?.includes(null)) return "bottom-only";
if (matchTwo.participants?.includes(null)) return "top-only";
return undefined;
});
}
function RoundInfo({
title,
bestOf,
status,
isLast,
}: {
title: string;
bestOf: number;
status: "DONE" | "INPROGRESS" | "UPCOMING";
isLast?: boolean;
}) {
return (
<div
className={clsx("tournament-bracket__elim__roundInfo", {
highlighted: status === "INPROGRESS",
last: isLast,
})}
>
<div className="tournament-bracket__elim__roundTitle">{title}</div>
{status !== "DONE" && (
<div className="tournament-bracket__elim__bestOf">Bo{bestOf}</div>
)}
</div>
);
}

View File

@ -1,103 +0,0 @@
import clsx from "clsx";
import * as React from "react";
import { Link } from "@remix-run/react";
import type { BracketModified } from "~/services/bracket";
import { Unpacked } from "~/utils";
export function EliminationBracketMatch({
match,
hidden,
ownTeamName,
isOver,
}: {
match: Unpacked<Unpacked<BracketModified["rounds"]>["matches"]>;
hidden?: boolean;
ownTeamName?: string;
isOver: boolean;
}) {
const cellText = (index: number) => {
if (match.participants?.[index]) return match.participants?.[index];
const matchNumber = match.participantSourceMatches?.[index];
if (typeof matchNumber === "number") {
return (
<i className="tournament-bracket__elim__loser-info">{`Loser of match ${matchNumber}`}</i>
);
}
return null;
};
const Container = ({ children }: { children: React.ReactNode }) => {
const hasBothParticipants =
(match.participants?.filter(Boolean).length ?? 0) > 1;
const atLeastOneStageReported = match.score?.some((s) => s > 0);
if (hasBothParticipants && atLeastOneStageReported)
return (
<Link
className="tournament-bracket__match__link"
to={`match/${match.number}`}
>
{children}
</Link>
);
return <>{children}</>;
};
return (
<Container>
<div className={clsx("tournament-bracket__elim__match", { hidden })}>
<div className="tournament-bracket__elim__roundNumber">
{match.number}
</div>
<div
className={clsx(
"tournament-bracket__elim__team",
"tournament-bracket__elim__teamOne",
{
own:
!isOver &&
ownTeamName &&
ownTeamName === match.participants?.[0],
defeated:
isOver && (match.score?.[0] ?? 0) < (match.score?.[1] ?? 0),
}
)}
>
{cellText(0)}
<span
className={clsx("tournament-bracket__elim__score", {
invisible: typeof match.score?.[0] !== "number",
})}
>
{match.score?.[0] ?? 0}
</span>
</div>
<div
className={clsx(
"tournament-bracket__elim__team",
"tournament-bracket__elim__teamTwo",
{
own:
!isOver &&
ownTeamName &&
ownTeamName === match.participants?.[1],
defeated:
isOver && (match.score?.[1] ?? 0) < (match.score?.[0] ?? 0),
}
)}
>
{cellText(1)}{" "}
<span
className={clsx("tournament-bracket__elim__score", {
invisible: typeof match.score?.[0] !== "number",
})}
>
{match.score?.[1] ?? 0}
</span>
</div>
</div>
</Container>
);
}

View File

@ -1,55 +0,0 @@
import * as React from "react";
import type { Mode } from "@prisma/client";
import {
modeToImageUrl,
MyCSSProperties,
stageNameToBannerImageUrl,
} from "~/utils";
import clsx from "clsx";
import { modesShortToLong } from "~/core/stages/stages";
export function FancyStageBanner({
stage,
roundNumber,
infos,
children,
}: {
stage: { mode: Mode; name: string };
roundNumber: number;
infos?: JSX.Element[];
children?: React.ReactNode;
}) {
const style: MyCSSProperties = {
"--_tournament-bg-url": `url("${stageNameToBannerImageUrl(stage.name)}")`,
};
return (
<>
<div
className={clsx("tournament-bracket__stage-banner", {
rounded: !infos,
})}
style={style}
>
<div className="tournament-bracket__stage-banner__top-bar">
<h4 className="tournament-bracket__stage-banner__top-bar__header">
<img
className="tournament-bracket__stage-banner__top-bar__mode-image"
src={modeToImageUrl(stage.mode)}
/>
{modesShortToLong[stage.mode]} on {stage.name}
</h4>
<h4>Stage {roundNumber}</h4>
</div>
{children}
</div>
{infos && (
<div className="tournament-bracket__infos">
{infos.map((info, i) => (
<div key={i}>{info}</div>
))}
</div>
)}
</>
);
}

View File

@ -1,103 +0,0 @@
import { Link, useLocation, useMatches } from "@remix-run/react";
import { DiscordIcon } from "~/components/icons/Discord";
import { TwitterIcon } from "~/components/icons/Twitter";
import { resolveTournamentFormatString } from "~/core/tournament/bracket";
import { FindTournamentByNameForUrlI } from "~/services/tournament";
export function InfoBanner() {
const [, parentRoute] = useMatches();
const data = parentRoute.data as FindTournamentByNameForUrlI;
const location = useLocation();
const urlToTournamentFrontPage = location.pathname
.split("/")
.slice(0, 4)
.join("/");
return (
<>
<div className="info-banner">
<div className="info-banner__top-row">
<div className="info-banner__top-row__date-name">
<time
dateTime={dateYYYYMMDD(data.startTime)}
className="info-banner__top-row__month-date"
>
<div className="info-banner__top-row__month-date__month">
{shortMonthName(data.startTime)}
</div>
<div className="info-banner__top-row__month-date__date">
{dayNumber(data.startTime)}
</div>
</time>
<Link
to={urlToTournamentFrontPage}
className="info-banner__top-row__tournament-name"
>
{data.name}
</Link>
</div>
<div className="info-banner__icon-buttons-container">
{data.organizer.twitter && (
// TODO: broken on Safari
<a
className="info-banner__icon-button"
href={data.organizer.twitter}
>
<TwitterIcon />
</a>
)}
<a
className="info-banner__icon-button"
href={data.organizer.discordInvite}
>
<DiscordIcon />
</a>
</div>
</div>
<div className="info-banner__bottom-row">
<div className="info-banner__bottom-row__infos">
<div className="info-banner__bottom-row__info-container">
<div className="info-banner__bottom-row__info-label">
Starting time
</div>
<time dateTime={data.startTime}>
{weekdayAndStartTime(data.startTime)}
</time>
</div>
<div className="info-banner__bottom-row__info-container">
<div className="info-banner__bottom-row__info-label">Format</div>
<div>{resolveTournamentFormatString(data.brackets)}</div>
</div>
<div className="info-banner__bottom-row__info-container">
<div className="info-banner__bottom-row__info-label">
Organizer
</div>
<div>{data.organizer.name}</div>
</div>
</div>
</div>
</div>
</>
);
}
// TODO: https://github.com/remix-run/remix/issues/656
function weekdayAndStartTime(date: string) {
return new Date(date).toLocaleString("en-US", {
weekday: "long",
hour: "numeric",
});
}
function shortMonthName(date: string) {
return new Date(date).toLocaleString("en-US", { month: "short" });
}
function dayNumber(date: string) {
return new Date(date).toLocaleString("en-US", { day: "numeric" });
}
function dateYYYYMMDD(date: string) {
return new Date(date).toISOString().split("T")[0];
}

View File

@ -1,123 +0,0 @@
import { Form, useMatches, useTransition } from "@remix-run/react";
import { tournamentHasNotStarted } from "~/core/tournament/validators";
import { useUser } from "~/hooks/common";
import { FindTournamentByNameForUrlI } from "~/services/tournament";
import { Avatar } from "../Avatar";
import { Button } from "../Button";
import { SubmitButton } from "../SubmitButton";
export function TeamRoster({
team,
showUnregister = false,
deleteMode = false,
}: {
team: {
id: string;
name: string;
members: {
captain: boolean;
member: {
id: string;
discordAvatar: string | null;
discordId: string;
discordName: string;
};
}[];
};
deleteMode?: boolean;
showUnregister?: boolean;
}) {
const [, parentRoute] = useMatches();
const tournament = parentRoute.data as FindTournamentByNameForUrlI;
const user = useUser();
const showDeleteButtons = (userToDeleteId: string) => {
return (
tournamentHasNotStarted(tournament) &&
deleteMode &&
userToDeleteId !== user?.id
);
};
const showUnregisterButton = () => {
return tournamentHasNotStarted(tournament) && showUnregister;
};
return (
<div className="teams-tab__team-container">
<div className="teams-tab__team-name">
{team.name}
{showUnregisterButton() ? (
<Form method="post" className="flex justify-center">
<input type="hidden" name="_action" value="UNREGISTER" />
<input type="hidden" name="teamId" value={team.id} />
<SubmitButton
actionType="UNREGISTER"
tiny
variant="minimal-destructive"
loadingText="Unregistering..."
onClick={(e) => {
if (
!confirm(`Unregister ${team.name} from ${tournament.name}?`)
) {
e.preventDefault();
}
}}
data-cy="unregister-button"
>
Unregister
</SubmitButton>
</Form>
) : null}
</div>
<div className="teams-tab__members-container">
{team.members
.sort((a, b) => Number(b.captain) - Number(a.captain))
.map(({ member, captain }, i) => (
<div key={member.id} className="teams-tab__member">
<div className="teams-tab__member__order-number">
{captain ? "C" : i + 1}
</div>
<div className="teams-tab__member__container">
<Avatar user={member} />
<div className="teams-tab__member__container__name-button">
<div>{member.discordName}</div>
{showDeleteButtons(member.id) && (
<DeleteFromRosterButton
playerId={member.id}
teamId={team.id}
/>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
}
function DeleteFromRosterButton({
playerId,
teamId,
}: {
playerId: string;
teamId: string;
}) {
const transition = useTransition();
return (
<Form method="post">
<input type="hidden" name="_action" value="DELETE_PLAYER" />
<input type="hidden" name="teamId" value={teamId} />
<input type="hidden" name="userId" value={playerId} />
<Button
variant="destructive"
tiny
loading={transition.state !== "idle"}
data-cy="remove-player-button"
>
Remove
</Button>
</Form>
);
}

View File

@ -1,131 +0,0 @@
import clsx from "clsx";
import clone from "just-clone";
import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "~/constants";
import { Label } from "../Label";
import { TeamRosterInputsCheckboxes } from "./TeamRosterInputsCheckboxes";
import * as React from "react";
/** Fields of a tournament team required to render `<TeamRosterInputs />` */
export interface TeamRosterInputTeam {
name: string;
id: string;
members: {
member: {
id: string;
discordName: string;
played?: boolean;
};
}[];
}
export type TeamRosterInputsType = "DEFAULT" | "DISABLED" | "PRESENTATIONAL";
/** Inputs to select who played for teams in a match as well as the winner. Can also be used in a presentational way. */
export function TeamRosterInputs({
teamUpper,
teamLower,
winnerId,
setWinnerId,
checkedPlayers,
setCheckedPlayers,
presentational = false,
}: {
teamUpper: TeamRosterInputTeam;
teamLower: TeamRosterInputTeam;
winnerId?: string | null;
setWinnerId?: (newId: string) => void;
checkedPlayers: [string[], string[]];
setCheckedPlayers?: (newPlayerIds: [string[], string[]]) => void;
presentational?: boolean;
}) {
const inputMode = (team: TeamRosterInputTeam): TeamRosterInputsType => {
if (presentational) return "PRESENTATIONAL";
// Disabled in this case because we expect a result to have exactly
// TOURNAMENT_TEAM_ROSTER_MIN_SIZE members per team when reporting it
// so there is no point to let user to change them around
if (team.members.length <= TOURNAMENT_TEAM_ROSTER_MIN_SIZE) {
return "DISABLED";
}
return "DEFAULT";
};
return (
<div className="tournament-bracket__during-match-actions__rosters">
{[teamUpper, teamLower].map((team, teamI) => (
<div key={team.id}>
<h4>{team.name}</h4>
<WinnerRadio
presentational={presentational}
checked={winnerId === team.id}
teamId={team.id}
onChange={() => setWinnerId?.(team.id)}
/>
<TeamRosterInputsCheckboxes
team={team}
checkedPlayers={checkedPlayers[teamI]}
mode={inputMode(team)}
handlePlayerClick={(playerId: string) => {
const newCheckedPlayers = () => {
const newPlayers = clone(checkedPlayers);
if (checkedPlayers.flat().includes(playerId)) {
newPlayers[teamI] = newPlayers[teamI].filter(
(id) => id !== playerId
);
} else {
newPlayers[teamI].push(playerId);
}
return newPlayers;
};
setCheckedPlayers?.(newCheckedPlayers());
}}
/>
</div>
))}
</div>
);
}
/** Renders radio button to select winner, or in presentational mode just display the text "Winner" */
function WinnerRadio({
presentational,
teamId,
checked,
onChange,
}: {
presentational: boolean;
teamId: string;
checked: boolean;
onChange: () => void;
}) {
const id = React.useId();
if (presentational) {
return (
<div
className={clsx(
"tournament-bracket__during-match-actions__winner-text",
{ invisible: !checked }
)}
>
Winner
</div>
);
}
return (
<div className="tournament-bracket__during-match-actions__radio-container">
<input
type="radio"
id={`${teamId}-${id}`}
onChange={onChange}
checked={checked}
/>
<Label className="mb-0 ml-2" htmlFor={`${teamId}-${id}`}>
Winner
</Label>
</div>
);
}

View File

@ -1,51 +0,0 @@
import clsx from "clsx";
import { Label } from "../Label";
import { TeamRosterInputsType, TeamRosterInputTeam } from "./TeamRosterInputs";
import * as React from "react";
export function TeamRosterInputsCheckboxes({
team,
checkedPlayers,
handlePlayerClick,
mode,
}: {
team: TeamRosterInputTeam;
checkedPlayers: string[];
handlePlayerClick: (playerId: string) => void;
/** DEFAULT = inputs work, DISABLED = inputs disabled and look disabled, PRESENTATION = inputs disabled but look like in DEFAULT (without hover styles) */
mode: TeamRosterInputsType;
}) {
const id = React.useId();
return (
<div className="tournament-bracket__during-match-actions__team-players">
{team.members.map(({ member }) => (
<div
key={member.id}
className={clsx(
"tournament-bracket__during-match-actions__checkbox-name",
{ "disabled-opaque": mode === "DISABLED" },
{ presentational: mode === "PRESENTATIONAL" }
)}
>
<input
className="plain tournament-bracket__during-match-actions__checkbox"
type="checkbox"
id={`${member.id}-${id}`}
name="playerName"
disabled={mode === "DISABLED" || mode === "PRESENTATIONAL"}
value={member.id}
checked={checkedPlayers.flat().includes(member.id)}
onChange={() => handlePlayerClick(member.id)}
/>{" "}
<Label
className="tournament-bracket__during-match-actions__player-name"
htmlFor={`${member.id}-${id}`}
>
{member.discordName}
</Label>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,56 @@
import clsx from "clsx";
import { assertUnreachable } from "~/utils/types";
import { TwitchIcon } from "../icons/Twitch";
import { TwitterIcon } from "../icons/Twitter";
import { YouTubeIcon } from "../icons/YouTube";
interface SocialLinkProps {
type: "youtube" | "twitter" | "twitch";
identifier: string;
}
export function SocialLink({
type,
identifier,
}: {
type: "youtube" | "twitter" | "twitch";
identifier: string;
}) {
const href = () => {
switch (type) {
case "twitch":
return `https://www.twitch.tv/${identifier}`;
case "twitter":
return `https://www.twitter.com/${identifier}`;
case "youtube":
return `https://www.youtube.com/channel/${identifier}`;
default:
assertUnreachable(type);
}
};
return (
<a
className={clsx("u__social-link", {
youtube: type === "youtube",
twitter: type === "twitter",
twitch: type === "twitch",
})}
href={href()}
>
<SocialLinkIcon type={type} />
</a>
);
}
function SocialLinkIcon({ type }: Pick<SocialLinkProps, "type">) {
switch (type) {
case "twitch":
return <TwitchIcon />;
case "twitter":
return <TwitterIcon />;
case "youtube":
return <YouTubeIcon />;
default:
assertUnreachable(type);
}
}

View File

@ -1,44 +1,3 @@
import { Ability } from "@prisma/client";
export const ADMIN_UUID = "ee2d82dd-624f-4b07-9d8d-ddee1f8fb36f";
export const ADMIN_TEST_DISCORD_ID = "79237403620945920";
export const ADMIN_TEST_AVATAR = "fcfd65a3bea598905abb9ca25296816b";
export const NZAP_UUID = "6cd9d01d-b724-498a-b706-eb70edd8a773";
export const NZAP_TEST_DISCORD_ID = "455039198672453645";
export const NZAP_TEST_AVATAR = "f809176af93132c3db5f0a5019e96339";
export const PAGE_TITLE_KEY = "pageTitle";
export const ROOM_PASS_LENGTH = 4;
export const LFG_GROUP_FULL_SIZE = 4;
export const TOURNAMENT_TEAM_ROSTER_MIN_SIZE = 4;
export const TOURNAMENT_TEAM_ROSTER_MAX_SIZE = 6;
/** How many minutes before the start of the tournament check-in closes */
export const TOURNAMENT_CHECK_IN_CLOSING_MINUTES_FROM_START = 10;
export const BEST_OF_OPTIONS = [3, 5, 7, 9] as const;
/** How many minutes a group has to be inactive before being hidden from the looking page */
export const LFG_GROUP_INACTIVE_MINUTES = 30;
export const MMR_TOPX_VISIBILITY_CUTOFF = 50;
export const AMOUNT_OF_ENTRIES_REQUIRED_FOR_LEADERBOARD = 7;
export const LFG_AMOUNT_OF_STAGES_TO_GENERATE = 7;
export const MINI_BIO_MAX_LENGTH = 280;
export const LFG_WEAPON_POOL_MAX_LENGTH = 3;
export const CLOSE_MMR_LIMIT = 250;
export const BIT_HIGHER_MMR_LIMIT = 500;
export const HIGHER_MMR_LIMIT = 750;
export const MAX_CHAT_MESSAGE_LENGTH = 280;
export const checkInClosesDate = (startTime: string): Date => {
return new Date(new Date(startTime).getTime() - 1000 * 10);
};
export const navItemsGrouped: {
title: string;
items: {
@ -80,192 +39,3 @@ export const navItemsGrouped: {
],
},
];
export const weapons = [
"Sploosh-o-matic",
"Neo Sploosh-o-matic",
"Sploosh-o-matic 7",
"Splattershot Jr.",
"Custom Splattershot Jr.",
"Kensa Splattershot Jr.",
"Splash-o-matic",
"Neo Splash-o-matic",
"Aerospray MG",
"Aerospray RG",
"Aerospray PG",
"Splattershot",
"Tentatek Splattershot",
"Kensa Splattershot",
".52 Gal",
".52 Gal Deco",
"Kensa .52 Gal",
"N-ZAP '85",
"N-ZAP '89",
"N-ZAP '83",
"Splattershot Pro",
"Forge Splattershot Pro",
"Kensa Splattershot Pro",
".96 Gal",
".96 Gal Deco",
"Jet Squelcher",
"Custom Jet Squelcher",
"L-3 Nozzlenose",
"L-3 Nozzlenose D",
"Kensa L-3 Nozzlenose",
"H-3 Nozzlenose",
"H-3 Nozzlenose D",
"Cherry H-3 Nozzlenose",
"Squeezer",
"Foil Squeezer",
"Luna Blaster",
"Luna Blaster Neo",
"Kensa Luna Blaster",
"Blaster",
"Custom Blaster",
"Range Blaster",
"Custom Range Blaster",
"Grim Range Blaster",
"Rapid Blaster",
"Rapid Blaster Deco",
"Kensa Rapid Blaster",
"Rapid Blaster Pro",
"Rapid Blaster Pro Deco",
"Clash Blaster",
"Clash Blaster Neo",
"Carbon Roller",
"Carbon Roller Deco",
"Splat Roller",
"Krak-On Splat Roller",
"Kensa Splat Roller",
"Dynamo Roller",
"Gold Dynamo Roller",
"Kensa Dynamo Roller",
"Flingza Roller",
"Foil Flingza Roller",
"Inkbrush",
"Inkbrush Nouveau",
"Permanent Inkbrush",
"Octobrush",
"Octobrush Nouveau",
"Kensa Octobrush",
"Classic Squiffer",
"New Squiffer",
"Fresh Squiffer",
"Splat Charger",
"Firefin Splat Charger",
"Kensa Charger",
"Splatterscope",
"Firefin Splatterscope",
"Kensa Splatterscope",
"E-liter 4K",
"Custom E-liter 4K",
"E-liter 4K Scope",
"Custom E-liter 4K Scope",
"Bamboozler 14 Mk I",
"Bamboozler 14 Mk II",
"Bamboozler 14 Mk III",
"Goo Tuber",
"Custom Goo Tuber",
"Slosher",
"Slosher Deco",
"Soda Slosher",
"Tri-Slosher",
"Tri-Slosher Nouveau",
"Sloshing Machine",
"Sloshing Machine Neo",
"Kensa Sloshing Machine",
"Bloblobber",
"Bloblobber Deco",
"Explosher",
"Custom Explosher",
"Mini Splatling",
"Zink Mini Splatling",
"Kensa Mini Splatling",
"Heavy Splatling",
"Heavy Splatling Deco",
"Heavy Splatling Remix",
"Hydra Splatling",
"Custom Hydra Splatling",
"Ballpoint Splatling",
"Ballpoint Splatling Nouveau",
"Nautilus 47",
"Nautilus 79",
"Dapple Dualies",
"Dapple Dualies Nouveau",
"Clear Dapple Dualies",
"Splat Dualies",
"Enperry Splat Dualies",
"Kensa Splat Dualies",
"Glooga Dualies",
"Glooga Dualies Deco",
"Kensa Glooga Dualies",
"Dualie Squelchers",
"Custom Dualie Squelchers",
"Dark Tetra Dualies",
"Light Tetra Dualies",
"Splat Brella",
"Sorella Brella",
"Tenta Brella",
"Tenta Sorella Brella",
"Tenta Camo Brella",
"Undercover Brella",
"Undercover Sorella Brella",
"Kensa Undercover Brella",
// reskins
"Hero Shot Replica",
"Hero Blaster Replica",
"Hero Roller Replica",
"Herobrush Replica",
"Hero Charger Replica",
"Hero Slosher Replica",
"Hero Splatling Replica",
"Hero Dualie Replicas",
"Hero Brella Replica",
"Octo Shot Replica",
] as const;
export const abilities: Ability[] = [
"ISM",
"ISS",
"REC",
"RSU",
"SSU",
"SCU",
"SS",
"SPU",
"QR",
"QSJ",
"BRU",
"RES",
"BDU",
"MPU",
"OG",
"LDE",
"T",
"CB",
"NS",
"H",
"TI",
"RP",
"AD",
"SJ",
"OS",
"DR",
"EMPTY",
];
export const monthNames = [
null,
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
] as const;

View File

@ -0,0 +1,136 @@
import { DISCORD_AUTH_KEY } from "./authenticator.server";
import { db } from "~/db";
import type { User } from "~/db/types";
import type { OAuth2Profile } from "remix-auth-oauth2";
import { OAuth2Strategy } from "remix-auth-oauth2";
import invariant from "tiny-invariant";
import { z } from "zod";
interface DiscordExtraParams extends Record<string, string | number> {
scope: string;
}
export type LoggedInUser = Pick<User, "id" | "discordId" | "discordAvatar">;
const partialDiscordUserSchema = z.object({
avatar: z.string().nullish(),
discriminator: z.string(),
id: z.string(),
username: z.string(),
});
const partialDiscordConnectionsSchema = z.array(
z.object({
visibility: z.number(),
verified: z.boolean(),
name: z.string(),
id: z.string(),
type: z.string(),
})
);
const discordUserDetailsSchema = z.tuple([
partialDiscordUserSchema,
partialDiscordConnectionsSchema,
]);
export class DiscordStrategy extends OAuth2Strategy<
LoggedInUser,
OAuth2Profile,
DiscordExtraParams
> {
name = DISCORD_AUTH_KEY;
scope: string;
constructor() {
invariant(process.env.DISCORD_CLIENT_ID);
invariant(process.env.DISCORD_CLIENT_SECRET);
invariant(process.env.BASE_URL);
super(
{
authorizationURL: "https://discord.com/api/oauth2/authorize",
tokenURL: "https://discord.com/api/oauth2/token",
clientID: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
callbackURL: new URL("/auth/callback", process.env.BASE_URL).toString(),
},
async ({ accessToken }) => {
const authHeader = ["Authorization", `Bearer ${accessToken}`];
const discordResponses = await Promise.all([
fetch("https://discord.com/api/users/@me", {
headers: [authHeader],
}),
fetch("https://discord.com/api/users/@me/connections", {
headers: [authHeader],
}),
]);
const [user, connections] = discordUserDetailsSchema.parse(
await Promise.all(
discordResponses.map((res) => {
if (!res.ok) throw new Error("Call to Discord API failed");
return res.json();
})
)
);
const userFromDb = db.users.upsert({
discordAvatar: user.avatar ?? null,
discordDiscriminator: user.discriminator,
discordId: user.id,
discordName: user.username,
...this.parseConnections(connections),
});
return {
id: userFromDb.id,
discordId: userFromDb.discordId,
discordAvatar: userFromDb.discordAvatar,
};
}
);
this.scope = "identify connections";
}
private parseConnections(
connections: z.infer<typeof partialDiscordConnectionsSchema>
) {
if (!connections) throw new Error("No connections");
const result: {
twitch: string | null;
twitter: string | null;
youtubeId: string | null;
} = {
twitch: null,
twitter: null,
youtubeId: null,
};
for (const connection of connections) {
if (connection.visibility !== 1 || !connection.verified) continue;
switch (connection.type) {
case "twitch":
result.twitch = connection.name;
break;
case "twitter":
result.twitter = connection.name;
break;
case "youtube":
result.youtubeId = connection.id;
}
}
return result;
}
protected authorizationParams() {
const urlSearchParams: Record<string, string> = {
scope: this.scope,
};
return new URLSearchParams(urlSearchParams);
}
}

View File

@ -0,0 +1,10 @@
import { Authenticator } from "remix-auth";
import { DiscordStrategy } from "./DiscordStrategy.server";
import type { LoggedInUser } from "./DiscordStrategy.server";
import { sessionStorage } from "./session.server";
export const DISCORD_AUTH_KEY = "discord";
export const authenticator = new Authenticator<LoggedInUser>(sessionStorage);
authenticator.use(new DiscordStrategy());

View File

@ -1,5 +0,0 @@
import { ADMIN_UUID } from "~/constants";
export function isAdmin(userId?: string) {
return userId === ADMIN_UUID;
}

View File

@ -1,53 +0,0 @@
import { Mode } from "@prisma/client";
import { fetchTimeout } from "~/utils";
const LANISTA_REQUEST_TIMEOUT = 7000;
/** Request Lanista to send match details to match-details endpoint. */
export async function requestMatchDetails({
matchId,
startTime,
endTime,
playerDiscordIds,
playedStages,
}: {
matchId: string;
startTime: Date;
endTime?: Date;
playerDiscordIds: string[];
playedStages: { stage: string; mode: Mode }[];
}) {
try {
if (process.env.NODE_ENV === "development") {
return;
}
if (!process.env.LANISTA_URL) {
throw new Error("process.env.LANISTA_URL not set");
}
const response = await fetchTimeout(
process.env.LANISTA_URL,
LANISTA_REQUEST_TIMEOUT,
{
body: JSON.stringify({
maplist: playedStages,
requesterId: playerDiscordIds,
startTime: startTime.toISOString(),
endTime: (endTime ?? new Date()).toISOString(),
matchId,
token: process.env.LANISTA_URL_TOKEN,
}),
method: "post",
headers: [["Content-Type", "application/json"]],
}
);
if (!response.ok) {
throw new Error(`error code: ${response.status}`);
}
} catch (e) {
if (e instanceof Error) {
console.error("Sending match to Lanista failed: ", e.message);
}
}
}

View File

@ -1,133 +0,0 @@
import { suite } from "uvu";
import * as assert from "uvu/assert";
import { UserLean } from "~/utils";
import { skillsToLeaderboard } from "./leaderboards";
import { muSigmaToSP } from "./utils";
const SkillsToLeaderboard = suite("skillsToLeaderboard()");
const USER: UserLean = {
discordAvatar: "",
discordDiscriminator: "1234",
discordId: "12341234",
discordName: "Sendou",
id: "1",
};
const USER_2: UserLean = {
discordAvatar: "",
discordDiscriminator: "12342",
discordId: "123412342",
discordName: "Sendou2",
id: "2",
};
SkillsToLeaderboard("Works with empty array", () => {
const players = skillsToLeaderboard([]);
assert.not.ok(players.length);
});
SkillsToLeaderboard("Ignores skills if below amount required", () => {
const players = skillsToLeaderboard([
{
createdAt: new Date(),
mu: 10,
sigma: 2,
userId: "1",
user: USER,
amountOfSets: null,
},
]);
assert.not.ok(players.length);
});
SkillsToLeaderboard("Gets peak", () => {
const players = skillsToLeaderboard(
new Array(10).fill(null).map((_) => ({
createdAt: new Date(),
mu: 10,
sigma: 2,
userId: "1",
user: USER,
amountOfSets: null,
}))
);
assert.equal(players[0].MMR, muSigmaToSP({ sigma: 2, mu: 10 }));
});
SkillsToLeaderboard("Ignores peaks at the start", () => {
const players = skillsToLeaderboard(
new Array(10).fill(null).map((_, i) => ({
createdAt: new Date(),
mu: i === 0 ? 30 : 10,
sigma: 2,
userId: "1",
user: USER,
amountOfSets: null,
}))
);
assert.equal(players[0].MMR, muSigmaToSP({ sigma: 2, mu: 10 }));
});
SkillsToLeaderboard("Gets peak from in between", () => {
const players = skillsToLeaderboard(
new Array(10).fill(null).map((_, i) => ({
createdAt: new Date(),
mu: i === 8 ? 30 : 10,
sigma: 2,
userId: "1",
user: USER,
amountOfSets: null,
}))
);
assert.equal(players[0].MMR, muSigmaToSP({ sigma: 2, mu: 30 }));
});
SkillsToLeaderboard("Calculates entries", () => {
const players = skillsToLeaderboard(
new Array(10).fill(null).map((_, i) => ({
createdAt: new Date(),
mu: i === 8 ? 30 : 10,
sigma: 2,
userId: "1",
user: USER,
amountOfSets: null,
}))
);
assert.equal(players[0].entries, 10);
});
SkillsToLeaderboard("Orders by MMR", () => {
const players = skillsToLeaderboard(
new Array(10)
.fill(null)
.map((_, i) => ({
createdAt: new Date(),
mu: i === 8 ? 30 : 10,
sigma: 2,
userId: "1",
user: USER,
amountOfSets: null,
}))
.concat(
new Array(10).fill(null).map((_) => ({
createdAt: new Date(),
mu: 40,
sigma: 2,
userId: "2",
user: USER_2,
amountOfSets: null,
}))
)
);
assert.equal(players[0].user.id, USER_2.id);
});
SkillsToLeaderboard.run();

View File

@ -1,91 +0,0 @@
import { Skill } from "@prisma/client";
import { AMOUNT_OF_ENTRIES_REQUIRED_FOR_LEADERBOARD } from "~/constants";
import { muSigmaToSP } from "./utils";
export interface LeaderboardEntry {
MMR: number;
user: { id: string; discordName: string };
entries: number;
}
type SkillInput = Pick<
Skill,
"mu" | "sigma" | "userId" | "amountOfSets" | "createdAt"
> & {
user: { id: string; discordName: string };
};
type UserId = string;
export function skillsToLeaderboard(skills: SkillInput[]): LeaderboardEntry[] {
const counts: Record<UserId, number> = {};
const peakMMR: Record<UserId, LeaderboardEntry> = {};
for (const skill of skills.sort(sortSkillsByCreatedAt)) {
if (!counts[skill.userId]) {
counts[skill.userId] = skill.amountOfSets ?? 1;
} else {
counts[skill.userId] = counts[skill.userId] + (skill.amountOfSets ?? 1);
}
if (counts[skill.userId] < AMOUNT_OF_ENTRIES_REQUIRED_FOR_LEADERBOARD) {
continue;
}
const MMR = muSigmaToSP(skill);
if (!peakMMR[skill.userId] || peakMMR[skill.userId].MMR < MMR) {
peakMMR[skill.userId] = {
MMR,
user: {
discordName: skill.user.discordName,
id: skill.user.id,
},
// we set this below
entries: 0,
};
}
}
for (const [userId, count] of Object.entries(counts)) {
if (!peakMMR[userId]) continue;
peakMMR[userId].entries = count;
}
return Object.values(peakMMR).sort((a, b) => b.MMR - a.MMR);
}
function sortSkillsByCreatedAt(a: SkillInput, b: SkillInput) {
return a.createdAt.getTime() - b.createdAt.getTime();
}
export function monthYearOptions() {
const FIRST_MONTH = 3;
const FIRST_YEAR = 2022;
const result: { month: number; year: number }[] = [];
let month = new Date().getMonth() + 1;
let year = new Date().getFullYear();
do {
result.push({ month, year });
month--;
if (month === 0) {
month = 12;
year--;
}
} while (month >= FIRST_MONTH && year >= FIRST_YEAR);
return result;
}
export function monthYearIsValid({
month,
year,
}: {
month: number;
year: number;
}) {
return monthYearOptions().some(
(option) => option.month === month && option.year === year
);
}

View File

@ -1,174 +0,0 @@
import { suite } from "uvu";
import {
adjustSkills,
averageTeamMMRs,
muSigmaToSP,
resolveOwnMMR,
teamSkillToExactMMR,
} from "./utils";
import * as assert from "uvu/assert";
const AdjustSkills = suite("adjustSkills()");
const ResolveOwnMMR = suite("resolveOwnMMR()");
const TeamSkillToExactMMR = suite("teamSkillToExactMMR()");
const AverageTeamMMRs = suite("averageTeamMMRs()");
const MU_AT_START = 20;
const SIGMA_AT_START = 4;
AdjustSkills("Adjust skills to correct direction", () => {
const adjusted = adjustSkills({
skills: ["w1", "w2", "l1", "l2"].map((userId) => ({
mu: MU_AT_START,
sigma: SIGMA_AT_START,
userId,
})),
playerIds: {
losing: ["l1", "l2"],
winning: ["w1", "w2"],
},
});
for (const skill of adjusted) {
if (skill.userId.startsWith("w")) {
if (skill.mu <= MU_AT_START || skill.sigma >= SIGMA_AT_START) {
throw new Error("Mu got worse or sigma more inaccurate after winning");
}
} else {
if (skill.mu >= MU_AT_START || skill.sigma >= SIGMA_AT_START) {
throw new Error("Mu got better or sigma more inaccurate after losing");
}
}
}
});
AdjustSkills("Handles missing skills", () => {
const adjusted = adjustSkills({
skills: ["w2", "l1"].map((userId) => ({
mu: MU_AT_START,
sigma: SIGMA_AT_START,
userId,
})),
playerIds: {
losing: ["l1", "l2"],
winning: ["w1", "w2"],
},
});
assert.equal(adjusted.length, 4);
});
ResolveOwnMMR("Doesn't show own MMR if missing", () => {
const own = resolveOwnMMR({
skills: [{ userId: "test2", mu: 20, sigma: 7 }],
user: { id: "test" },
});
const own2 = resolveOwnMMR({
skills: [],
user: { id: "test" },
});
assert.not.ok(own);
assert.not.ok(own2);
});
ResolveOwnMMR("Calculates own MMR stats correctly", () => {
const skills = new Array(9)
.fill(null)
.map((_, i) => ({ userId: `${i}`, mu: 20 + i, sigma: 7 }));
skills.push({ userId: "test", mu: 40, sigma: 7 });
const own = resolveOwnMMR({
skills,
user: { id: "test" },
});
const valueShouldBe = muSigmaToSP({ mu: 40, sigma: 7 });
assert.equal(own?.topX, 5);
assert.equal(own?.value, valueShouldBe);
});
ResolveOwnMMR("Hides topX if not good", () => {
const skills = new Array(9)
.fill(null)
.map((_, i) => ({ userId: `${i}`, mu: 20 + i, sigma: 7 }));
skills.push({ userId: "test", mu: 1, sigma: 7 });
const own = resolveOwnMMR({
skills,
user: { id: "test" },
});
assert.not.ok(own?.topX);
});
TeamSkillToExactMMR("Sums up MMR's", () => {
const skills = new Array(4)
.fill(null)
.map((_) => ({ mu: MU_AT_START, sigma: SIGMA_AT_START }));
const teamMMR = teamSkillToExactMMR(
skills.map((s) => ({ user: { skill: [{ mu: s.mu, sigma: s.sigma }] } }))
);
assert.equal(
teamMMR,
muSigmaToSP({ mu: MU_AT_START, sigma: SIGMA_AT_START }) * 4
);
});
TeamSkillToExactMMR("Pads team MMR", () => {
const skills = new Array(3)
.fill(null)
.map((_) => ({ mu: MU_AT_START, sigma: SIGMA_AT_START }));
const teamMMR = teamSkillToExactMMR(
skills.map((s) => ({ user: { skill: [{ mu: s.mu, sigma: s.sigma }] } }))
);
assert.equal(
teamMMR,
muSigmaToSP({ mu: MU_AT_START, sigma: SIGMA_AT_START }) * 3 + 1000
);
const teamMMRNoSkills = teamSkillToExactMMR([]);
assert.equal(teamMMRNoSkills, 4 * 1000);
});
AverageTeamMMRs("Gets average MMR", () => {
const MMRs = averageTeamMMRs({
skills: [
{ mu: MU_AT_START, sigma: SIGMA_AT_START, userId: "1" },
{ mu: MU_AT_START + 1, sigma: SIGMA_AT_START, userId: "2" },
],
teams: [
{ id: "a", members: [{ member: { id: "1" } }, { member: { id: "2" } }] },
],
});
const expected =
(muSigmaToSP({ mu: MU_AT_START, sigma: SIGMA_AT_START }) +
muSigmaToSP({ mu: MU_AT_START + 1, sigma: SIGMA_AT_START })) /
2;
assert.equal(MMRs["a"], Math.round(expected));
});
AverageTeamMMRs("Handles teams with no skill", () => {
const MMRs = averageTeamMMRs({
skills: [
{ mu: MU_AT_START, sigma: SIGMA_AT_START, userId: "1" },
{ mu: MU_AT_START + 1, sigma: SIGMA_AT_START, userId: "2" },
],
teams: [
{ id: "a", members: [{ member: { id: "1" } }, { member: { id: "2" } }] },
{ id: "b", members: [{ member: { id: "3" } }, { member: { id: "4" } }] },
],
});
assert.equal(Object.keys(MMRs).length, 1);
});
AdjustSkills.run();
ResolveOwnMMR.run();
TeamSkillToExactMMR.run();
AverageTeamMMRs.run();

View File

@ -1,266 +0,0 @@
import { Skill } from "@prisma/client";
import clone from "just-clone";
import { rating, ordinal, rate } from "openskill";
import { LFG_GROUP_FULL_SIZE, MMR_TOPX_VISIBILITY_CUTOFF } from "~/constants";
import { PlayFrontPageLoader } from "~/routes/play/index";
import { SeedsLoaderData } from "~/routes/to/$organization.$tournament/seeds";
import * as TournamentMatch from "~/models/TournamentMatch.server";
import invariant from "tiny-invariant";
import { Unpacked } from "~/utils";
const TAU = 0.3;
/** Get first skill object of the array (should be ordered so that most recent skill is first) and convert it into MMR. */
export function skillArrayToMMR(
skills: {
mu: number;
sigma: number;
}[]
) {
const skill: { mu: number; sigma: number } | undefined = skills[0];
if (!skill) return;
return muSigmaToSP(skill);
}
export function muSigmaToSP(skill: { mu: number; sigma: number }) {
return toTwoDecimals(ordinal(rating(skill)) * 10 + 1000);
}
interface TeamSkill {
user: {
skill: {
mu: number;
sigma: number;
}[];
};
}
export function teamSkillToExactMMR(teamSkills: TeamSkill[]) {
let sum = 0;
const teamSkillsClone = clone(teamSkills);
while (teamSkillsClone.length < LFG_GROUP_FULL_SIZE) {
teamSkillsClone.push({ user: { skill: [] } });
}
const defaultRating = rating();
const skillsWithDefaults = teamSkillsClone.reduce((acc: TeamSkill[], cur) => {
if (cur.user.skill.length === 0) {
return [
{
user: {
skill: [{ mu: defaultRating.mu, sigma: defaultRating.sigma }],
},
},
...acc,
];
}
return [cur, ...acc];
}, []);
for (const { user } of skillsWithDefaults) {
const MMR = skillArrayToMMR(user.skill);
if (!MMR) continue;
sum += MMR;
}
return toTwoDecimals(sum);
}
export function toTwoDecimals(value: number) {
return Number(value.toFixed(2));
}
interface AdjustSkill {
mu: number;
sigma: number;
userId: string;
}
export function adjustSkills({
skills,
playerIds,
}: {
skills: AdjustSkill[];
playerIds: {
winning: string[];
losing: string[];
};
}): AdjustSkill[] {
const mapToRatings = (id: string) => {
const skill = skills.find((s) => s.userId === id);
if (!skill) return rating();
return rating(skill);
};
const winningTeam = playerIds.winning.map(mapToRatings);
const losingTeam = playerIds.losing.map(mapToRatings);
const [ratedWinners, ratedLosers] = rate([winningTeam, losingTeam], {
tau: TAU,
preventSigmaIncrease: true,
});
const ratedToReturnable =
(side: "winning" | "losing") =>
(rating: Rating, i: number): AdjustSkill => ({
mu: rating.mu,
sigma: rating.sigma,
userId: playerIds[side][i],
});
return [
...ratedWinners.map(ratedToReturnable("winning")),
...ratedLosers.map(ratedToReturnable("losing")),
];
}
export function adjustSkillsWithCancel({
skills,
playerIds,
noUpdateUserIds,
}: {
skills: AdjustSkill[];
playerIds: {
winning: string[];
losing: string[];
};
noUpdateUserIds: string[];
}) {
const allAdjusted = adjustSkills({ skills, playerIds });
return allAdjusted.filter((skill) => !noUpdateUserIds.includes(skill.userId));
}
export function resolveOwnMMR({
skills,
user,
}: {
skills: { userId: string; mu: number; sigma: number }[];
user?: { id: string };
}): PlayFrontPageLoader["ownMMR"] {
if (!user) return;
const ownSkillObj = skills.find((s) => s.userId === user.id);
if (!ownSkillObj) return;
const ownSkill = muSigmaToSP(ownSkillObj);
const allSkills = skills.map((s) => muSigmaToSP(s));
const ownPercentile = percentile(allSkills, ownSkill);
// can't be top 0%
const topX = Math.max(1, Math.round(100 - ownPercentile));
return {
value: ownSkill,
// we show the top x data only for those who have it good
// since probably nobody wants to know they are the bottom
// 10% or something
topX: topX > MMR_TOPX_VISIBILITY_CUTOFF ? undefined : topX,
};
}
// https://stackoverflow.com/a/69730272
function percentile(arr: number[], val: number) {
let count = 0;
arr.forEach((v) => {
if (v < val) {
count++;
} else if (v == val) {
count += 0.5;
}
});
return (100 * count) / arr.length;
}
export function averageTeamMMRs({
skills,
teams,
}: {
skills: Pick<Skill, "userId" | "mu" | "sigma">[];
teams: { id: string; members: { member: { id: string } }[] }[];
}) {
const result: SeedsLoaderData["MMRs"] = {};
for (const team of teams) {
let MMRSum = 0;
let playersWithSkill = 0;
for (const { member } of team.members) {
const skill = skills.find((s) => s.userId === member.id);
if (!skill) continue;
MMRSum += muSigmaToSP(skill);
playersWithSkill++;
}
if (playersWithSkill === 0) continue;
result[team.id] = Math.round(MMRSum / playersWithSkill);
}
return result;
}
export function bracketToChangedMMRs({
matches,
skills,
}: {
matches: TournamentMatch.AllTournamentMatchesWithRosterInfo;
skills: Pick<Skill, "mu" | "sigma" | "userId">[];
}): Pick<Skill, "mu" | "sigma" | "userId" | "amountOfSets">[] {
const result = Object.fromEntries(
skills.map((s) => [s.userId, { mu: s.mu, sigma: s.sigma, amountOfSets: 0 }])
);
for (const match of matches) {
const currentSkills = Object.entries(result).map(([userId, skill]) => ({
...skill,
userId,
}));
const playerIds = winnersAndLosersOfTournamentMatch(match);
const newMMRs = adjustSkills({ skills: currentSkills, playerIds });
for (const newMMR of newMMRs) {
const newAmountOfSets = (result[newMMR.userId]?.amountOfSets ?? 0) + 1;
result[newMMR.userId] = { ...newMMR, amountOfSets: newAmountOfSets };
}
}
return Object.entries(result)
.map(([userId, skill]) => ({ ...skill, userId }))
.filter((skill) => skill.amountOfSets > 0);
}
function winnersAndLosersOfTournamentMatch(
match: Unpacked<TournamentMatch.AllTournamentMatchesWithRosterInfo>
) {
const scores = match.results.reduce(
(acc, result) => {
acc[result.winner]++;
return acc;
},
{ UPPER: 0, LOWER: 0 }
);
invariant(scores.LOWER !== scores.UPPER, "scores.LOWER === scores.UPPER");
const winner = scores.LOWER > scores.UPPER ? "LOWER" : "UPPER";
const playersWhoPlayedInSet = match.results.reduce((acc, result) => {
result.players.forEach((player) => acc.add(player.id));
return acc;
}, new Set<string>());
return match.participants.reduce(
(acc: { winning: string[]; losing: string[] }, participant) => {
acc[winner === participant.order ? "winning" : "losing"].push(
...participant.team.members
.map((m) => m.memberId)
.filter((id) => playersWhoPlayedInSet.has(id))
);
return acc;
},
{ winning: [], losing: [] }
);
}

View File

@ -1,50 +0,0 @@
import { Mode } from "@prisma/client";
import { suite } from "uvu";
import * as assert from "uvu/assert";
import { LFG_AMOUNT_OF_STAGES_TO_GENERATE } from "~/constants";
import { idToStage } from "../stages/stages";
import { generateMapListForLfgMatch } from "./mapList";
const GenerateMapListForLfgMatch = suite("generateMapListForLfgMatch()");
GenerateMapListForLfgMatch("Right amount of SZ", () => {
const mapList = generateMapListForLfgMatch();
let amountOfSz = 0;
for (const stage of mapList) {
const stageObj = idToStage(stage.stageId);
if (stageObj.mode === "SZ") amountOfSz++;
}
assert.equal(amountOfSz, Math.floor(LFG_AMOUNT_OF_STAGES_TO_GENERATE / 2));
});
GenerateMapListForLfgMatch("Contains all modes", () => {
const mapList = generateMapListForLfgMatch();
const modes = new Set<Mode>();
for (const stage of mapList) {
const stageObj = idToStage(stage.stageId);
modes.add(stageObj.mode);
}
assert.equal(modes.size, 4);
});
GenerateMapListForLfgMatch("No duplicate maps", () => {
const mapList = generateMapListForLfgMatch();
const maps = new Set<string>();
for (const stage of mapList) {
const stageObj = idToStage(stage.stageId);
if (maps.has(stageObj.name)) {
throw new Error(`Duplicate map: ${stageObj.name}`);
}
maps.add(stageObj.name);
}
assert.equal(maps.size, LFG_AMOUNT_OF_STAGES_TO_GENERATE);
});
GenerateMapListForLfgMatch.run();

View File

@ -1,116 +0,0 @@
import { Mode } from "@prisma/client";
import clone from "just-clone";
import shuffle from "just-shuffle";
import invariant from "tiny-invariant";
import { LFG_AMOUNT_OF_STAGES_TO_GENERATE } from "~/constants";
import { StageName, stageToId } from "../stages/stages";
const LEGAL_MODES: Mode[] = ["TC", "RM", "CB"];
// ⚠️ Every used mode needs to have at least AMOUNT_OF_STAGES_TO_GENERATE maps
const LEGAL_STAGES: Record<Mode, StageName[]> = {
TW: [],
SZ: [
"Ancho-V Games",
"Blackbelly Skatepark",
"Humpback Pump Track",
"Inkblot Art Academy",
"MakoMart",
"Manta Maria",
"Musselforge Fitness",
"New Albacore Hotel",
"Piranha Pit",
"Shellendorf Institute",
"Skipper Pavilion",
"Snapper Canal",
"Starfish Mainstage",
"Sturgeon Shipyard",
"The Reef",
"Wahoo World",
],
TC: [
"Ancho-V Games",
"Humpback Pump Track",
"Inkblot Art Academy",
"MakoMart",
"Manta Maria",
"Musselforge Fitness",
"Piranha Pit",
"Shellendorf Institute",
"Starfish Mainstage",
"Sturgeon Shipyard",
"The Reef",
],
RM: [
"Ancho-V Games",
"Blackbelly Skatepark",
"Humpback Pump Track",
"MakoMart",
"Manta Maria",
"Musselforge Fitness",
"Snapper Canal",
"Starfish Mainstage",
"Sturgeon Shipyard",
"The Reef",
],
CB: [
"Humpback Pump Track",
"Inkblot Art Academy",
"MakoMart",
"Manta Maria",
"Musselforge Fitness",
"New Albacore Hotel",
"Piranha Pit",
"Snapper Canal",
"Starfish Mainstage",
"Sturgeon Shipyard",
"The Reef",
],
};
export function generateMapListForLfgMatch(): {
order: number;
stageId: number;
}[] {
const modesShuffled = shuffle(LEGAL_MODES);
const stagesShuffled = Object.fromEntries(
Object.entries(clone(LEGAL_STAGES)).map(([key, stages]) => [
key,
shuffle(stages),
])
) as Record<Mode, StageName[]>;
const usedMaps = new Set<StageName>();
const stageList: { name: string; mode: Mode }[] = [];
for (let i = 0; i < LFG_AMOUNT_OF_STAGES_TO_GENERATE; i++) {
const mode = (() => {
if (i !== 0 && i % 2 !== 0) {
return "SZ";
}
const result = modesShuffled.shift();
invariant(result);
modesShuffled.push(result);
return result;
})();
const name = (() => {
const stages = stagesShuffled[mode];
// guaranteed to never run out of unused maps since every mode has at least AMOUNT_OF_STAGES_TO_GENERATE maps
while (usedMaps.has(stages[0])) {
stages.shift();
}
usedMaps.add(stages[0]);
return stages[0];
})();
stageList.push({ name, mode });
}
return stageList.map((stage, i) => ({
order: i + 1,
stageId: stageToId(stage),
}));
}

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +0,0 @@
import type {
LookingLoaderData,
LookingLoaderDataGroup,
} from "~/routes/play/looking";
import rawInfos from "./data.json";
const infos = rawInfos as Partial<
Record<string, { weapons?: string[]; peakXP?: number; peakLP?: number }>
>;
export function addInfoFromOldSendouInk(
type: "LEAGUE" | "SOLO",
data: LookingLoaderData
): LookingLoaderData {
return {
...data,
ownGroup: mapGroup(data.ownGroup),
likedGroups: data.likedGroups.map(mapGroup),
neutralGroups: data.neutralGroups.map(mapGroup),
likerGroups: data.likerGroups.map(mapGroup),
};
function mapGroup(group: LookingLoaderDataGroup): LookingLoaderDataGroup {
return {
...group,
members: group.members?.map((member) => {
const playerInfos = infos[member.discordId];
return {
...member,
peakXP: type === "SOLO" ? playerInfos?.peakXP : undefined,
peakLP: type === "LEAGUE" ? playerInfos?.peakLP : undefined,
};
}),
};
}
}
export function userHasTop500Result({ discordId }: { discordId?: string }) {
if (!discordId) return false;
return Boolean(infos[discordId]?.peakXP);
}

View File

@ -1,195 +0,0 @@
import { suite } from "uvu";
import * as assert from "uvu/assert";
import { BIT_HIGHER_MMR_LIMIT } from "~/constants";
import {
calculateDifference,
groupsToWinningAndLosingPlayerIds,
isMatchReplay,
scoresAreIdentical,
uniteGroupInfo,
UniteGroupInfoArg,
} from "./utils";
const UniteGroupInfo = suite("uniteGroupInfo()");
const ScoresAreIdentical = suite("scoresAreIdentical()");
const GroupsToWinningAndLosingPlayerIds = suite(
"groupsToWinningAndLosingPlayerIds()"
);
const CalculateDifference = suite("calculateDifference()");
const IsMatchReplay = suite("isMatchReplay()");
const SMALL_GROUP: UniteGroupInfoArg = { id: "small", memberCount: 1 };
const BIG_GROUP: UniteGroupInfoArg = { id: "big", memberCount: 3 };
UniteGroupInfo("Removes captain if other group is smaller", () => {
const { removeCaptainsFromOther } = uniteGroupInfo(SMALL_GROUP, BIG_GROUP);
assert.ok(removeCaptainsFromOther);
});
UniteGroupInfo("Doesn't remove captain if groups are same size", () => {
const { removeCaptainsFromOther } = uniteGroupInfo(SMALL_GROUP, SMALL_GROUP);
assert.not.ok(removeCaptainsFromOther);
});
UniteGroupInfo("Bigger group survives", () => {
const { otherGroupId, survivingGroupId } = uniteGroupInfo(
BIG_GROUP,
SMALL_GROUP
);
assert.equal(survivingGroupId, "big");
assert.equal(otherGroupId, "small");
});
ScoresAreIdentical("Detects identical score", () => {
const result = scoresAreIdentical({
stages: [
{ winnerGroupId: "a" },
{ winnerGroupId: "a" },
{ winnerGroupId: "a" },
],
winnerIds: ["a", "a", "a"],
});
assert.ok(result);
});
ScoresAreIdentical("Detects not identical score", () => {
const result = scoresAreIdentical({
stages: [
{ winnerGroupId: "a" },
{ winnerGroupId: "a" },
{ winnerGroupId: "b" },
],
winnerIds: ["a", "b", "a"],
});
const result2 = scoresAreIdentical({
stages: [
{ winnerGroupId: "a" },
{ winnerGroupId: "a" },
{ winnerGroupId: "b" },
],
winnerIds: ["b", "b", "a"],
});
const result3 = scoresAreIdentical({
stages: [{ winnerGroupId: "a" }, { winnerGroupId: "a" }],
winnerIds: ["a", "a", "a"],
});
assert.not.ok(result);
assert.not.ok(result2);
assert.not.ok(result3);
});
GroupsToWinningAndLosingPlayerIds(
"Splits players to winning and losing",
() => {
const { winning, losing } = groupsToWinningAndLosingPlayerIds({
winnerGroupIds: ["a", "b", "b"],
groups: [
{ id: "b", members: [{ user: { id: "m3" } }, { user: { id: "m4" } }] },
{ id: "a", members: [{ user: { id: "m1" } }, { user: { id: "m2" } }] },
],
});
assert.ok(winning.includes("m3"));
assert.ok(winning.includes("m4"));
assert.ok(losing.includes("m1"));
assert.ok(losing.includes("m2"));
}
);
CalculateDifference("Close", () => {
assert.equal(calculateDifference({ ourMMR: 0, theirMMR: 0 }), "CLOSE");
assert.equal(calculateDifference({ ourMMR: 0, theirMMR: 1 }), "CLOSE");
assert.equal(calculateDifference({ ourMMR: 1, theirMMR: 0 }), "CLOSE");
});
CalculateDifference("Higher/lower", () => {
assert.equal(
calculateDifference({ ourMMR: 0, theirMMR: 10_000 }),
"LOT_HIGHER"
);
assert.equal(
calculateDifference({ ourMMR: 10_000, theirMMR: 0 }),
"LOT_LOWER"
);
});
CalculateDifference("A bit higher/lower", () => {
assert.equal(
calculateDifference({ ourMMR: 0, theirMMR: BIT_HIGHER_MMR_LIMIT }),
"BIT_HIGHER"
);
assert.equal(
calculateDifference({ ourMMR: 0, theirMMR: -BIT_HIGHER_MMR_LIMIT }),
"BIT_LOWER"
);
});
const user = { id: "a" };
const createMembers = (input: string[]) => input.map((v) => ({ memberId: v }));
IsMatchReplay("Detects replays", () => {
assert.ok(
isMatchReplay({
user,
recentMatch: {
groups: [
{ members: createMembers(["a", "b", "c", "d"]) },
{ members: createMembers(["e", "f", "g", "h"]) },
],
},
group: { members: createMembers(["e", "f", "g", "h"]) },
})
);
assert.ok(
isMatchReplay({
user,
recentMatch: {
groups: [
{ members: createMembers(["e", "f", "g", "h"]) },
{ members: createMembers(["a", "b", "c", "d"]) },
],
},
group: { members: createMembers(["e", "f", "g", "1"]) },
})
);
});
IsMatchReplay("Detects not replays", () => {
assert.not.ok(
isMatchReplay({
user,
recentMatch: {
groups: [
{ members: createMembers(["a", "b", "c", "d"]) },
{ members: createMembers(["e", "f", "g", "h"]) },
],
},
group: { members: createMembers(["1", "2", "3", "4"]) },
})
);
assert.not.ok(
isMatchReplay({
user,
recentMatch: {
groups: [
{ members: createMembers(["a", "b", "c", "d"]) },
{ members: createMembers(["e", "f", "g", "h"]) },
],
},
group: { members: createMembers(["e", "f", "3", "4"]) },
})
);
});
UniteGroupInfo.run();
ScoresAreIdentical.run();
GroupsToWinningAndLosingPlayerIds.run();
CalculateDifference.run();
IsMatchReplay.run();

View File

@ -1,352 +0,0 @@
import { LfgGroupStatus } from "@prisma/client";
import { redirect } from "@remix-run/node";
import invariant from "tiny-invariant";
import {
BIT_HIGHER_MMR_LIMIT,
CLOSE_MMR_LIMIT,
HIGHER_MMR_LIMIT,
LFG_GROUP_FULL_SIZE,
LFG_GROUP_INACTIVE_MINUTES,
} from "~/constants";
import * as LFGGroup from "~/models/LFGGroup.server";
import * as LFGMatch from "~/models/LFGMatch.server";
import { PlayFrontPageLoader } from "~/routes/play/index";
import {
LookingLoaderData,
LookingLoaderDataGroup,
} from "~/routes/play/looking";
import { Unpacked } from "~/utils";
import {
sendouQAddPlayersPage,
sendouQFrontPage,
sendouQLookingPage,
sendouQMatchPage,
} from "~/utils/urls";
import { skillArrayToMMR, teamSkillToExactMMR } from "../mmr/utils";
import { canUniteWithGroup } from "./validators";
export interface UniteGroupInfoArg {
id: string;
memberCount: number;
}
export function uniteGroupInfo(
groupA: UniteGroupInfoArg,
groupB: UniteGroupInfoArg
): LFGGroup.UniteGroupsArgs {
const survivingGroupId =
groupA.memberCount > groupB.memberCount ? groupA.id : groupB.id;
const otherGroupId = survivingGroupId === groupA.id ? groupB.id : groupA.id;
return {
survivingGroupId,
otherGroupId,
removeCaptainsFromOther: groupA.memberCount !== groupB.memberCount,
};
}
/** Checks if the reported score is the same as score from the database */
export function scoresAreIdentical({
stages,
winnerIds,
}: {
stages: { winnerGroupId: string | null }[];
winnerIds: string[];
}): boolean {
const stagesWithWinner = stages.filter((stage) => stage.winnerGroupId);
if (stagesWithWinner.length !== winnerIds.length) return false;
for (const [i, stage] of stagesWithWinner.entries()) {
if (!stage.winnerGroupId) break;
if (stage.winnerGroupId !== winnerIds[i]) return false;
}
return true;
}
export function groupsToWinningAndLosingPlayerIds({
winnerGroupIds,
groups,
}: {
winnerGroupIds: string[];
groups: { id: string; members: { user: { id: string } }[] }[];
}): {
winning: string[];
losing: string[];
} {
const occurences: Record<string, number> = {};
for (const groupId of winnerGroupIds) {
if (occurences[groupId]) occurences[groupId]++;
else occurences[groupId] = 1;
}
const winnerGroupId = Object.entries(occurences)
.sort((a, b) => a[1] - b[1])
.pop()?.[0];
invariant(winnerGroupId, "winnerGroupId is undefined");
return groups.reduce(
(acc, group) => {
const ids = group.members.map((m) => m.user.id);
if (group.id === winnerGroupId) acc.winning = ids;
else acc.losing = ids;
return acc;
},
{ winning: [] as string[], losing: [] as string[] }
);
}
/**
* Group dates to compare against for expired status. E.g. if the group
* lastActionAt.getTime() is smaller than that of EXPIRED Date's then
* that group is expired
*/
export function groupExpiredDates(): Record<
"ALMOST_EXPIRED" | "EXPIRED",
Date
> {
const now = new Date();
const thirtyMinutesAgo = new Date(
now.getTime() - 60_000 * LFG_GROUP_INACTIVE_MINUTES
);
const now2 = new Date();
const twentyMinutesAgo = new Date(
now2.getTime() - 60_000 * (LFG_GROUP_INACTIVE_MINUTES - 10)
);
return { EXPIRED: thirtyMinutesAgo, ALMOST_EXPIRED: twentyMinutesAgo };
}
export function groupWillBeInactiveAt(timestamp: number) {
return new Date(timestamp + 60_000 * LFG_GROUP_INACTIVE_MINUTES);
}
export function groupExpirationStatus(lastActionAtTimestamp: number) {
const { EXPIRED: expiredDate, ALMOST_EXPIRED: almostExpiredDate } =
groupExpiredDates();
if (expiredDate.getTime() > lastActionAtTimestamp) return "EXPIRED";
if (almostExpiredDate.getTime() > lastActionAtTimestamp) {
return "ALMOST_EXPIRED";
}
}
export function otherGroupsForResponse({
groups,
likes,
lookingForMatch,
ownGroup,
recentMatch,
user,
}: {
groups: LFGGroup.FindLookingAndOwnActive;
likes: {
given: Set<string>;
received: Set<string>;
};
lookingForMatch: boolean;
ownGroup: Unpacked<LFGGroup.FindLookingAndOwnActive>;
recentMatch: LFGMatch.RecentOfUser;
user: { id: string };
}) {
return (
groups
.filter(
(group) =>
(lookingForMatch && group.members.length === LFG_GROUP_FULL_SIZE) ||
canUniteWithGroup({
ownGroupType: ownGroup.type,
ownGroupSize: ownGroup.members.length,
otherGroupSize: group.members.length,
})
)
.filter((group) => group.id !== ownGroup.id)
.filter(filterExpiredGroups)
// this should not happen.... but sometimes it does :)
.filter((g) => g.members.length > 0)
.map((group): LookingLoaderDataGroup => {
const ranked = () => {
if (lookingForMatch && !ownGroup.ranked) return false;
return group.ranked ?? undefined;
};
return {
id: group.id,
// When looking for a match ranked groups are censored
// and instead we only reveal their approximate skill level
members:
ownGroup.ranked && group.ranked && lookingForMatch
? undefined
: group.members.map((m) => {
return {
miniBio: m.user.miniBio ?? undefined,
discordAvatar: m.user.discordAvatar,
discordId: m.user.discordId,
discordName: m.user.discordName,
discordDiscriminator: m.user.discordDiscriminator,
id: m.user.id,
captain: m.captain,
weapons: m.user.weapons,
MMR: skillArrayToMMR(m.user.skill),
};
}),
ranked: ranked(),
replay: isMatchReplay({ user, group, recentMatch }),
MMRRelation:
ownGroup.ranked &&
group.ranked &&
group.members.length === LFG_GROUP_FULL_SIZE
? resolveMMRRelation({ group, ownGroup })
: undefined,
};
})
.reduce(
(
acc: Omit<
LookingLoaderData,
"ownGroup" | "type" | "isCaptain" | "lastActionAtTimestamp"
>,
group
) => {
// likesReceived first so that if both received like and
// given like then handle this edge case by just displaying the
// group as waiting like back
if (likes.received.has(group.id)) {
acc.likerGroups.push(group);
} else if (likes.given.has(group.id)) {
acc.likedGroups.push(group);
} else {
acc.neutralGroups.push(group);
}
return acc;
},
{ likedGroups: [], neutralGroups: [], likerGroups: [] }
)
);
}
export function isMatchReplay({
recentMatch,
user,
group,
}: {
recentMatch: { groups: { members: { memberId: string }[] }[] } | null;
user: { id: string };
group: { members: { memberId: string }[] };
}): boolean {
if (!recentMatch) return false;
const opponentGroupOfRecent = recentMatch.groups.find((g) =>
g.members.every((m) => m.memberId !== user.id)
);
invariant(
opponentGroupOfRecent,
"Unexpected opponentGroupOfRecent undefined"
);
const memberIdsOfGroup = new Set(group.members.map((m) => m.memberId));
let sameCount = 0;
for (const { memberId } of opponentGroupOfRecent.members) {
if (memberIdsOfGroup.has(memberId)) sameCount++;
}
return sameCount > 2;
}
export function filterExpiredGroups(group: { lastActionAt: Date }) {
const { EXPIRED: expiredDate } = groupExpiredDates();
return group.lastActionAt.getTime() > expiredDate.getTime();
}
export function countGroups(
groups: LFGGroup.FindLookingAndOwnActive
): PlayFrontPageLoader["counts"] {
return groups.filter(filterExpiredGroups).reduce(
(acc: PlayFrontPageLoader["counts"], group) => {
const memberCount = group.members.length;
if (group.type === "QUAD" && memberCount !== 4) {
acc.QUAD += memberCount;
} else if (group.type === "TWIN" && memberCount !== 2) {
acc.TWIN += memberCount;
} else if (group.type === "VERSUS") {
acc["VERSUS"] += memberCount;
}
return acc;
},
{ TWIN: 0, QUAD: 0, VERSUS: 0 }
);
}
function resolveMMRRelation({
group,
ownGroup,
}: {
group: Unpacked<LFGGroup.FindLookingAndOwnActive>;
ownGroup: Unpacked<LFGGroup.FindLookingAndOwnActive>;
}): NonNullable<LookingLoaderDataGroup["MMRRelation"]> {
return calculateDifference({
ourMMR: teamSkillToExactMMR(ownGroup.members),
theirMMR: teamSkillToExactMMR(group.members),
});
}
export function calculateDifference({
ourMMR,
theirMMR,
}: {
ourMMR: number;
theirMMR: number;
}): NonNullable<LookingLoaderDataGroup["MMRRelation"]> {
const difference = Math.abs(ourMMR - theirMMR);
const ownIsBigger = ourMMR > theirMMR;
if (difference <= CLOSE_MMR_LIMIT) return "CLOSE";
if (difference <= BIT_HIGHER_MMR_LIMIT && ownIsBigger) return "BIT_LOWER";
if (difference <= BIT_HIGHER_MMR_LIMIT && !ownIsBigger) return "BIT_HIGHER";
if (difference <= HIGHER_MMR_LIMIT && ownIsBigger) return "LOWER";
if (difference <= HIGHER_MMR_LIMIT && !ownIsBigger) return "HIGHER";
if (ownIsBigger) return "LOT_LOWER";
if (!ownIsBigger) return "LOT_HIGHER";
throw new Error("Unexpected calculateMMRRelation scenario");
}
export function resolveRedirect({
currentStatus = "INACTIVE",
currentPage,
matchId,
}: {
currentStatus?: LfgGroupStatus;
currentPage: LfgGroupStatus;
matchId?: string | null;
}) {
if (currentStatus === currentPage) return;
switch (currentStatus) {
case "INACTIVE": {
return redirect(sendouQFrontPage());
}
case "LOOKING": {
return redirect(sendouQLookingPage());
}
case "MATCH": {
invariant(matchId, "Unexpected no match id for redirect");
return redirect(sendouQMatchPage(matchId));
}
case "PRE_ADD": {
return redirect(sendouQAddPlayersPage());
}
default: {
const exhaustive: never = currentStatus;
throw new Response(`Unknown status: ${JSON.stringify(exhaustive)}`, {
status: 500,
});
}
}
}

View File

@ -1,31 +0,0 @@
import { suite } from "uvu";
import * as assert from "uvu/assert";
import { scoreValid } from "./validators";
const ScoreValidator = suite("scoreValid()");
ScoreValidator("Accepts valid scores", () => {
const winners = ["a", "b", "a", "a", "a", "a"];
const winners2 = ["a", "b", "b", "b", "b", "a", "a", "a", "a"];
const winners3 = ["a", "a", "a", "a", "a"];
const winners4 = ["a", "a"];
assert.ok(scoreValid(winners, 9));
assert.ok(scoreValid(winners2, 9));
assert.ok(scoreValid(winners3, 9));
assert.ok(scoreValid(winners4, 3));
});
ScoreValidator("Rejects invalid scores", () => {
const winners = ["a", "b", "a", "a", "a", "a", "a"];
const winners2 = ["a", "b", "b", "b", "b", "a", "a", "a", "a", "b"];
const winners3 = ["a", "a", "a", "a", "a", "b"];
const winners4 = ["a", "a", "a"];
assert.not.ok(scoreValid(winners, 9));
assert.not.ok(scoreValid(winners2, 9));
assert.not.ok(scoreValid(winners3, 9));
assert.not.ok(scoreValid(winners4, 3));
});
ScoreValidator.run();

View File

@ -1,102 +0,0 @@
import { LfgGroupType } from "@prisma/client";
import { LFG_GROUP_FULL_SIZE } from "~/constants";
import * as LFGGroup from "~/models/LFGGroup.server";
import { isAdmin } from "../common/permissions";
export function isGroupAdmin({
group,
user,
}: {
group?: { members: { captain: boolean; memberId: string }[] };
user: { id: string };
}): boolean {
return Boolean(
isAdmin(user.id) ||
group?.members.some(
(member) => member.captain && member.memberId === user.id
)
);
}
/**
* Checks that group size is suitable to be united with. E.g. if type of group
* is QUAD and your group has 2 members then a legal group to unite with would have
* 1 or 2 members.
*/
export function canUniteWithGroup({
ownGroupType,
ownGroupSize,
otherGroupSize,
}: {
ownGroupType: LfgGroupType;
ownGroupSize: number;
otherGroupSize: number;
}): boolean {
const maxGroupSizeToConsider =
ownGroupType === "TWIN"
? 2 - ownGroupSize
: LFG_GROUP_FULL_SIZE - ownGroupSize;
return maxGroupSizeToConsider >= otherGroupSize;
}
/**
* Is score valid? In a best of 9 examples of valid scores:
* 5-0, 5-1, 5-4;
* invalid scores:
* 6-0, 5-5, 4-3
* */
export function scoreValid(winners: string[], bestOf: number) {
const requiredWinsToTakeTheSet = Math.ceil(bestOf / 2);
const ids = Array.from(new Set(winners));
if (ids.length > 2) return false;
const scores = [0, 0];
for (const [i, winnerId] of winners.entries()) {
if (winnerId === ids[0]) scores[0]++;
else scores[1]++;
// it's not possible to report more maps once set has concluded
if (
scores.some((score) => score === requiredWinsToTakeTheSet) &&
i !== winners.length - 1
) {
return false;
}
}
return (
scores.some((score) => score === requiredWinsToTakeTheSet) &&
scores.some((score) => score < requiredWinsToTakeTheSet)
);
}
export function matchIsUnranked(match: { stages: unknown[] }) {
return match.stages.length === 0;
}
export function canPreAddToGroup(group: {
type: LfgGroupType;
members: unknown[];
}) {
if (group.type === "VERSUS" && group.members.length < LFG_GROUP_FULL_SIZE) {
return true;
}
// it doesn't make sense to fill a quad completely as it defeats the purpose
if (group.type === "QUAD" && group.members.length < LFG_GROUP_FULL_SIZE - 1) {
return true;
}
return false;
}
export function userIsNotInGroup({
groups,
userId,
}: {
groups: LFGGroup.FindLookingAndOwnActive;
userId: string;
}) {
return groups.every((g) => g.members.every((m) => m.memberId !== userId));
}

View File

@ -0,0 +1,14 @@
import { createCookieSessionStorage } from "@remix-run/node";
import invariant from "tiny-invariant";
invariant(process.env.SESSION_SECRET);
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: "_session",
sameSite: "lax",
path: "/",
httpOnly: true,
secrets: [process.env.SESSION_SECRET],
secure: process.env.NODE_ENV === "production",
},
});

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