mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
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:
parent
1e1f02fb2a
commit
185295d54e
18
.env.example
18
.env.example
|
|
@ -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=
|
||||
|
|
@ -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: "^_" }],
|
||||
|
|
|
|||
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
|
|
@ -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
8
.gitignore
vendored
|
|
@ -1,8 +1,8 @@
|
|||
node_modules
|
||||
|
||||
.env
|
||||
/.cache
|
||||
/server/build
|
||||
/public/build
|
||||
/build
|
||||
backup.sql
|
||||
/public/build
|
||||
.env
|
||||
|
||||
db.sqlite3*
|
||||
|
|
@ -1 +1 @@
|
|||
build
|
||||
build
|
||||
2
LICENSE
2
LICENSE
|
|
@ -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>.
|
||||
41
README.md
41
README.md
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" })}
|
||||
|
|
|
|||
|
|
@ -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()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export function FormErrorMessage({ errorMsg }: { errorMsg?: string }) {
|
||||
if (!errorMsg) return null;
|
||||
|
||||
return (
|
||||
<p className="form-validation-error" role="alert">
|
||||
{errorMsg}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import * as React from "react";
|
||||
|
||||
export function FormInfoText({ children }: { children: React.ReactNode }) {
|
||||
return <p className="form-info-text">{children}</p>;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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
5
app/components/Main.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type * as React from "react";
|
||||
|
||||
export const Main = ({ children }: { children: React.ReactNode }) => (
|
||||
<main className="layout__main">{children}</main>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { CSSProperties } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export function ArrowUpIcon({
|
||||
className,
|
||||
|
|
|
|||
14
app/components/icons/Twitch.tsx
Normal file
14
app/components/icons/Twitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
app/components/icons/User.tsx
Normal file
16
app/components/icons/User.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
app/components/icons/YouTube.tsx
Normal file
14
app/components/icons/YouTube.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -12,6 +12,7 @@ export function HamburgerButton({
|
|||
className="layout__burger"
|
||||
onClick={onClick}
|
||||
data-cy="hamburger-button"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
width="32"
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
49
app/components/layout/UserItem.tsx
Normal file
49
app/components/layout/UserItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
56
app/components/u/SocialLink.tsx
Normal file
56
app/components/u/SocialLink.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
230
app/constants.ts
230
app/constants.ts
|
|
@ -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;
|
||||
|
|
|
|||
136
app/core/DiscordStrategy.server.ts
Normal file
136
app/core/DiscordStrategy.server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
app/core/authenticator.server.ts
Normal file
10
app/core/authenticator.server.ts
Normal 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());
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { ADMIN_UUID } from "~/constants";
|
||||
|
||||
export function isAdmin(userId?: string) {
|
||||
return userId === ADMIN_UUID;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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: [] }
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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));
|
||||
}
|
||||
14
app/core/session.server.ts
Normal file
14
app/core/session.server.ts
Normal 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
Loading…
Reference in New Issue
Block a user