Tier list maker feature (#2634)
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

This commit is contained in:
Kalle 2025-11-16 16:30:24 +02:00 committed by GitHub
parent 40c5f96cfe
commit 187e1aa105
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 1847 additions and 11 deletions

View File

@ -4,14 +4,18 @@ import type { TierName } from "~/features/mmr/mmr-constants";
import type {
MainWeaponId,
ModeShortWithSpecial,
SpecialWeaponId,
StageId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import {
mainWeaponImageUrl,
modeImageUrl,
outlinedFiveStarMainWeaponImageUrl,
outlinedMainWeaponImageUrl,
specialWeaponImageUrl,
stageImageUrl,
subWeaponImageUrl,
TIER_PLUS_URL,
tierImageUrl,
} from "~/utils/urls";
@ -145,6 +149,50 @@ export function StageImage({ stageId, testId, ...rest }: StageImageProps) {
);
}
type SubWeaponImageProps = {
subWeaponId: SubWeaponId;
} & Omit<ImageProps, "path" | "alt" | "title">;
export function SubWeaponImage({
subWeaponId,
testId,
...rest
}: SubWeaponImageProps) {
const { t } = useTranslation(["weapons"]);
return (
<Image
{...rest}
alt={t(`weapons:SUB_${subWeaponId}`)}
title={t(`weapons:SUB_${subWeaponId}`)}
testId={testId}
path={subWeaponImageUrl(subWeaponId)}
/>
);
}
type SpecialWeaponImageProps = {
specialWeaponId: SpecialWeaponId;
} & Omit<ImageProps, "path" | "alt" | "title">;
export function SpecialWeaponImage({
specialWeaponId,
testId,
...rest
}: SpecialWeaponImageProps) {
const { t } = useTranslation(["weapons"]);
return (
<Image
{...rest}
alt={t(`weapons:SPECIAL_${specialWeaponId}`)}
title={t(`weapons:SPECIAL_${specialWeaponId}`)}
testId={testId}
path={specialWeaponImageUrl(specialWeaponId)}
/>
);
}
type TierImageProps = {
tier: { name: TierName; isPlus: boolean };
} & Omit<ImageProps, "path" | "alt" | "title" | "size" | "height">;

View File

@ -0,0 +1,17 @@
export function ChevronDownIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
);
}

View File

@ -0,0 +1,17 @@
export function ChevronUpIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 15.75l7.5-7.5 7.5 7.5"
/>
</svg>
);
}

View File

@ -96,6 +96,11 @@ export const navItems = [
url: "t",
prefetch: false,
},
{
name: "tier-list-maker",
url: "tier-list-maker",
prefetch: false,
},
{
name: "links",
url: "links",

View File

@ -1,7 +1,7 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { getUser } from "~/features/auth/core/user.server";
import { i18next } from "~/modules/i18n/i18next.server";
import { weaponIdIsNotAlt } from "~/modules/in-game-lists/weapon-ids";
import { weaponIdToType } from "~/modules/in-game-lists/weapon-ids";
import { logger } from "~/utils/logger";
import { weaponNameSlugToId } from "~/utils/unslugify.server";
import { mySlugify } from "~/utils/urls";
@ -21,7 +21,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
const weaponId = weaponNameSlugToId(params.slug);
if (typeof weaponId !== "number" || !weaponIdIsNotAlt(weaponId)) {
if (typeof weaponId !== "number" || weaponIdToType(weaponId) === "ALT_SKIN") {
throw new Response(null, { status: 404 });
}

View File

@ -9,7 +9,7 @@ import { useUser } from "~/features/auth/core/user";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
weaponCategories,
weaponIdIsNotAlt,
weaponIdToType,
} from "~/modules/in-game-lists/weapon-ids";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
@ -72,7 +72,7 @@ export default function BuildsPage() {
</div>
<div className={styles.categoryWeapons}>
{(category.weaponIds as readonly MainWeaponId[])
.filter(weaponIdIsNotAlt)
.filter((weaponId) => weaponIdToType(weaponId) !== "ALT_SKIN")
.map((weaponId, i) => (
<React.Fragment key={weaponId}>
{i !== 0 && weaponId % 10 === 0 ? (

View File

@ -0,0 +1,4 @@
.item {
cursor: move;
touch-action: none;
}

View File

@ -0,0 +1,39 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { TierListItem } from "../tier-list-maker-schemas";
import { tierListItemId } from "../tier-list-maker-utils";
import styles from "./DraggableItem.module.css";
import { TierListItemImage } from "./TierListItemImage";
interface DraggableItemProps {
item: TierListItem;
}
export function DraggableItem({ item }: DraggableItemProps) {
const uniqueId = tierListItemId(item);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: uniqueId,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.3 : 1,
};
return (
<div ref={setNodeRef} className={styles.item} style={style}>
<div data-item-id={uniqueId} {...listeners} {...attributes}>
<TierListItemImage item={item} />
</div>
</div>
);
}

View File

@ -0,0 +1,4 @@
.preview {
opacity: 0.8;
cursor: grabbing;
}

View File

@ -0,0 +1,15 @@
import type { TierListItem } from "../tier-list-maker-schemas";
import styles from "./ItemDragPreview.module.css";
import { TierListItemImage } from "./TierListItemImage";
interface ItemDragPreviewProps {
item: TierListItem;
}
export function ItemDragPreview({ item }: ItemDragPreviewProps) {
return (
<div className={styles.preview}>
<TierListItemImage item={item} />
</div>
);
}

View File

@ -0,0 +1,5 @@
.pool {
display: flex;
flex-wrap: wrap;
gap: var(--s-2-5);
}

View File

@ -0,0 +1,19 @@
import { useDroppable } from "@dnd-kit/core";
import { useTierListState } from "../contexts/TierListContext";
import { DraggableItem } from "./DraggableItem";
import styles from "./ItemPool.module.css";
export function ItemPool() {
const { availableItems } = useTierListState();
const { setNodeRef } = useDroppable({
id: "item-pool",
});
return (
<div ref={setNodeRef} className={styles.pool}>
{availableItems.map((item) => (
<DraggableItem key={`${item.type}:${item.id}`} item={item} />
))}
</div>
);
}

View File

@ -0,0 +1,15 @@
.imageWrapper img {
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.modeOverlay {
position: absolute;
bottom: 2px;
right: 2px;
background-color: var(--bg-lighter-solid);
border-radius: 100%;
padding: var(--s-1);
}

View File

@ -0,0 +1,66 @@
import {
ModeImage,
SpecialWeaponImage,
StageImage,
SubWeaponImage,
WeaponImage,
} from "~/components/Image";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { TierListItem } from "../tier-list-maker-schemas";
import styles from "./TierListItemImage.module.css";
interface TierListItemImageProps {
item: TierListItem;
}
export function TierListItemImage({ item }: TierListItemImageProps) {
switch (item.type) {
case "main-weapon":
return (
<div className={styles.imageWrapper}>
<WeaponImage weaponSplId={item.id} variant="badge" size={48} />
</div>
);
case "sub-weapon":
return (
<div className={styles.imageWrapper}>
<SubWeaponImage subWeaponId={item.id} size={48} />
</div>
);
case "special-weapon":
return (
<div className={styles.imageWrapper}>
<SpecialWeaponImage specialWeaponId={item.id} size={48} />
</div>
);
case "stage":
return (
<div className={styles.imageWrapper}>
<StageImage stageId={item.id} width={80} className="rounded-sm" />
</div>
);
case "mode":
return (
<div className={styles.imageWrapper}>
<ModeImage mode={item.id} width={48} height={48} />
</div>
);
case "stage-mode": {
const [stageIdStr, mode] = item.id.split("-");
const stageId = Number(stageIdStr) as StageId;
return (
<div className={styles.imageWrapper}>
<div className="relative">
<StageImage stageId={stageId} width={80} className="rounded-sm" />
<ModeImage
mode={mode as ModeShort}
width={24}
height={24}
className={styles.modeOverlay}
/>
</div>
</div>
);
}
}
}

View File

@ -0,0 +1,121 @@
.container {
display: flex;
gap: var(--s-1);
min-height: 68px;
}
.tierLabel {
display: flex;
align-items: center;
justify-content: center;
min-width: 68px;
padding: var(--s-2);
font-size: var(--fonts-lg);
font-weight: var(--bold);
color: var(--text);
border-radius: var(--rounded-sm);
cursor: pointer;
transition: opacity 0.2s;
border: none;
}
.tierLabel:hover {
opacity: 0.9;
}
.tierName {
font-size: var(--fonts-xl);
color: var(--button-text);
}
.popupContent {
display: flex;
flex-direction: column;
gap: var(--s-6);
padding: var(--s-2);
}
.nameInput {
max-width: 125px;
text-align: center;
}
.targetZone {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: var(--s-1-5);
padding: var(--s-2);
background: var(--bg-lighter);
border: 2px dashed transparent;
border-radius: var(--rounded-sm) 0 0 var(--rounded-sm);
align-content: flex-start;
transition: all 0.2s;
}
.targetZoneOver {
border-color: var(--theme-transparent);
background: var(--bg-lightest);
}
.targetZoneFullRadius {
border-radius: var(--rounded-sm);
}
.emptyMessage {
width: 100%;
height: 100%;
text-align: center;
margin: auto;
color: var(--text-lighter);
font-size: var(--fonts-md);
font-weight: var(--semi-bold);
font-style: italic;
display: flex;
align-items: center;
justify-content: center;
}
.arrowControls {
display: flex;
flex-direction: column;
gap: var(--s-1);
margin: auto;
}
.arrowButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: var(--s-1);
background: var(--bg-lighter);
border: none;
cursor: pointer;
transition: opacity 0.2s;
}
.arrowButtonUpper {
border-radius: 0 var(--rounded-sm) 0 0;
}
.arrowButtonLower {
border-radius: 0 0 var(--rounded-sm) 0;
}
.arrowButton:hover:not(:disabled) {
opacity: 0.8;
}
.arrowButton:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.arrowIcon {
width: 20px;
height: 20px;
stroke-width: 3;
color: var(--text);
}

View File

@ -0,0 +1,143 @@
import { useDroppable } from "@dnd-kit/core";
import {
horizontalListSortingStrategy,
SortableContext,
} from "@dnd-kit/sortable";
import clsx from "clsx";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { ChevronDownIcon } from "~/components/icons/ChevronDown";
import { ChevronUpIcon } from "~/components/icons/ChevronUp";
import { TrashIcon } from "~/components/icons/Trash";
import { useTierListState } from "../contexts/TierListContext";
import type { TierListMakerTier } from "../tier-list-maker-schemas";
import { tierListItemId } from "../tier-list-maker-utils";
import { DraggableItem } from "./DraggableItem";
import styles from "./TierRow.module.css";
interface TierRowProps {
tier: TierListMakerTier;
}
export function TierRow({ tier }: TierRowProps) {
const {
state,
getItemsInTier,
handleRemoveTier,
handleRenameTier,
handleChangeTierColor,
handleMoveTierUp,
handleMoveTierDown,
showTierHeaders,
showArrowControls,
} = useTierListState();
const items = getItemsInTier(tier.id);
const { t } = useTranslation(["tier-list-maker", "common"]);
const { setNodeRef, isOver } = useDroppable({
id: tier.id,
});
const tierIndex = state.tiers.findIndex((t) => t.id === tier.id);
const isFirstTier = tierIndex === 0;
const isLastTier = tierIndex === state.tiers.length - 1;
const handleDelete = () => {
handleRemoveTier(tier.id);
};
return (
<div className={styles.container}>
{showTierHeaders ? (
<SendouPopover
trigger={
<Button
className={styles.tierLabel}
style={{ backgroundColor: tier.color }}
>
<span className={styles.tierName}>{tier.name}</span>
</Button>
}
>
<div className={styles.popupContent}>
<div className="stack horizontal justify-between">
<span className="font-bold text-md">
{t("tier-list-maker:editingTier")}
</span>
</div>
<div className="stack md">
<input
type="text"
value={tier.name}
onChange={(e) => handleRenameTier(tier.id, e.target.value)}
className={styles.nameInput}
maxLength={10}
/>
<input
type="color"
value={tier.color}
onChange={(e) => handleChangeTierColor(tier.id, e.target.value)}
className="plain"
/>
</div>
<div className="stack horizontal justify-end">
<SendouButton
onPress={handleDelete}
variant="minimal-destructive"
icon={<TrashIcon />}
/>
</div>
</div>
</SendouPopover>
) : null}
<div
ref={setNodeRef}
className={clsx(styles.targetZone, {
[styles.targetZoneOver]: isOver,
[styles.targetZoneFullRadius]: !showArrowControls,
})}
>
{items.length === 0 ? (
<div className={styles.emptyMessage}>
{t("tier-list-maker:dropItems")}
</div>
) : (
<SortableContext
items={items.map(tierListItemId)}
strategy={horizontalListSortingStrategy}
>
{items.map((item) => (
<DraggableItem key={tierListItemId(item)} item={item} />
))}
</SortableContext>
)}
</div>
{showArrowControls ? (
<div className={styles.arrowControls}>
<button
className={clsx(styles.arrowButton, styles.arrowButtonUpper)}
onClick={() => handleMoveTierUp(tier.id)}
disabled={isFirstTier}
type="button"
aria-label="Move tier up"
>
<ChevronUpIcon className={styles.arrowIcon} />
</button>
<button
className={clsx(styles.arrowButton, styles.arrowButtonLower)}
onClick={() => handleMoveTierDown(tier.id)}
disabled={isLastTier}
type="button"
aria-label="Move tier down"
>
<ChevronDownIcon className={styles.arrowIcon} />
</button>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
import { useTierList } from "../hooks/useTierListState";
type TierListContextType = ReturnType<typeof useTierList>;
const TierListContext = createContext<TierListContextType | null>(null);
export function TierListProvider({ children }: { children: ReactNode }) {
const state = useTierList();
return (
<TierListContext.Provider value={state}>
{children}
</TierListContext.Provider>
);
}
export function useTierListState() {
const context = useContext(TierListContext);
if (!context) {
throw new Error("useTierListState must be used within TierListProvider");
}
return context;
}

View File

@ -0,0 +1,480 @@
import type {
DragEndEvent,
DragOverEvent,
DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
import { useSearchParams } from "@remix-run/react";
import JSONCrush from "jsoncrush";
import * as React from "react";
import { flushSync } from "react-dom";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { modesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import {
mainWeaponIds,
specialWeaponIds,
subWeaponIds,
weaponIdToType,
} from "~/modules/in-game-lists/weapon-ids";
import { assertUnreachable } from "~/utils/types";
import { DEFAULT_TIERS } from "../tier-list-maker-constants";
import {
type TierListItem,
type TierListMakerTier,
type TierListState,
tierListItemTypeSchema,
tierListStateSerializedSchema,
} from "../tier-list-maker-schemas";
import { getNextNthForItem } from "../tier-list-maker-utils";
export function useTierList() {
const [itemType, setItemType] = useSearchParamState<TierListItem["type"]>({
name: "type",
defaultValue: "main-weapon",
revive: (value) => {
const parsed = tierListItemTypeSchema.safeParse(value);
return parsed.success ? parsed.data : "main-weapon";
},
});
const { tiers, setTiers, persistTiersStateToParams } =
useSearchParamTiersState();
const [activeItem, setActiveItem] = React.useState<TierListItem | null>(null);
const [hideAltKits, setHideAltKits] = useSearchParamState({
name: "hideAltKits",
defaultValue: false,
revive: (value) => value === "true",
});
const [hideAltSkins, setHideAltSkins] = useSearchParamState({
name: "hideAltSkins",
defaultValue: false,
revive: (value) => value === "true",
});
const [canAddDuplicates, setCanAddDuplicates] = useSearchParamState({
name: "canAddDuplicates",
defaultValue: true,
revive: (value) => value === "true",
});
const [showTierHeaders, setShowTierHeaders] = useSearchParamState({
name: "showTierHeaders",
defaultValue: true,
revive: (value) => value === "true",
});
const [showArrowControls, setShowArrowControls] = useSearchParamState({
name: "showArrowControls",
defaultValue: true,
revive: (value) => value === "true",
});
const parseItemFromId = (id: string): TierListItem | null => {
const [type, idStr, nth] = String(id).split(":");
if (!type || !idStr) return null;
if (type === "mode" || type === "stage-mode") {
return {
type: type as TierListItem["type"],
id: idStr,
nth: nth ? Number(nth) : undefined,
} as TierListItem;
}
return {
type: type as TierListItem["type"],
id: Number(idStr),
nth: nth ? Number(nth) : undefined,
} as TierListItem;
};
const findContainer = (item: TierListItem): string | null => {
for (const [tierId, items] of tiers.tierItems.entries()) {
if (
items.some(
(i) => i.id === item.id && i.type === item.type && i.nth === item.nth,
)
) {
return tierId;
}
}
return null;
};
const handleDragStart = (event: DragStartEvent) => {
const item = parseItemFromId(String(event.active.id));
if (item) {
setActiveItem(item);
}
};
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
if (!over) {
return;
}
const activeItem = parseItemFromId(String(active.id));
if (!activeItem) return;
const overId = over.id;
const activeContainer = findContainer(activeItem);
const overItem = parseItemFromId(String(overId));
const overContainer = String(overId).startsWith("tier-")
? String(overId)
: overItem
? findContainer(overItem)
: null;
if (!overContainer || activeContainer === overContainer) {
if (activeContainer && overContainer === activeContainer) {
const newTierItems = new Map(tiers.tierItems);
const containerItems = newTierItems.get(activeContainer) || [];
const oldIndex = containerItems.findIndex(
(item) =>
item.id === activeItem.id &&
item.type === activeItem.type &&
item.nth === activeItem.nth,
);
const newIndex = overItem
? containerItems.findIndex(
(item) =>
item.id === overItem.id &&
item.type === overItem.type &&
item.nth === overItem.nth,
)
: -1;
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
newTierItems.set(
activeContainer,
arrayMove(containerItems, oldIndex, newIndex),
);
}
setTiers({
...tiers,
tierItems: newTierItems,
});
}
return;
}
const newTierItems = new Map(tiers.tierItems);
const activeItems = activeContainer
? newTierItems.get(activeContainer) || []
: [];
const overItems = newTierItems.get(overContainer) || [];
const overIndex = overItem
? overItems.findIndex(
(item) =>
item.id === overItem.id &&
item.type === overItem.type &&
item.nth === overItem.nth,
)
: overItems.length;
if (activeContainer) {
newTierItems.set(
activeContainer,
activeItems.filter(
(item) =>
!(
item.id === activeItem.id &&
item.type === activeItem.type &&
item.nth === activeItem.nth
),
),
);
}
const newOverItems = [...overItems];
newOverItems.splice(
overIndex === -1 ? newOverItems.length : overIndex,
0,
activeItem,
);
newTierItems.set(overContainer, newOverItems);
setTiers({
...tiers,
tierItems: newTierItems,
});
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveItem(null);
if (!over) {
return;
}
const item = parseItemFromId(String(active.id));
if (!item) return;
const overId = over.id;
const overItem = parseItemFromId(String(overId));
const isDroppedInPool =
overId === "item-pool" || (overItem && !findContainer(overItem));
if (isDroppedInPool) {
const newTierItems = new Map(tiers.tierItems);
const currentContainer = findContainer(item);
if (currentContainer) {
const containerItems = newTierItems.get(currentContainer) || [];
newTierItems.set(
currentContainer,
containerItems.filter(
(i) =>
!(i.id === item.id && i.type === item.type && i.nth === item.nth),
),
);
}
flushSync(() => {
setTiers({
...tiers,
tierItems: newTierItems,
});
});
}
persistTiersStateToParams();
};
const handleAddTier = () => {
const newTier: TierListMakerTier = {
id: `tier-${Date.now()}`,
name: "New",
color: "#888888",
};
setTiers({
...tiers,
tiers: [...tiers.tiers, newTier],
});
persistTiersStateToParams();
};
const handleRemoveTier = (tierId: string) => {
const newTierItems = new Map(tiers.tierItems);
newTierItems.delete(tierId);
setTiers({
tiers: tiers.tiers.filter((tier) => tier.id !== tierId),
tierItems: newTierItems,
});
persistTiersStateToParams();
};
const handleRenameTier = (tierId: string, newName: string) => {
setTiers({
...tiers,
tiers: tiers.tiers.map((tier) =>
tier.id === tierId ? { ...tier, name: newName } : tier,
),
});
persistTiersStateToParams();
};
const handleChangeTierColor = (tierId: string, newColor: string) => {
setTiers({
...tiers,
tiers: tiers.tiers.map((tier) =>
tier.id === tierId ? { ...tier, color: newColor } : tier,
),
});
persistTiersStateToParams();
};
const getItemsInTier = (tierId: string): TierListItem[] => {
return tiers.tierItems.get(tierId) || [];
};
const getAllItemIdsForType = (type: TierListItem["type"]) => {
switch (type) {
case "main-weapon":
return [...mainWeaponIds];
case "sub-weapon":
return [...subWeaponIds];
case "special-weapon":
return [...specialWeaponIds];
case "stage":
return [...stageIds];
case "mode":
return [...modesShort];
case "stage-mode": {
const combinations: string[] = [];
for (const stageId of stageIds) {
for (const mode of modesShort) {
combinations.push(`${stageId}-${mode}`);
}
}
return combinations;
}
default: {
assertUnreachable(type);
}
}
};
const getAvailableItems = (): TierListItem[] => {
const placedItems = new Set<string>();
for (const items of tiers.tierItems.values()) {
for (const item of items) {
placedItems.add(`${item.type}:${item.id}`);
}
}
const allItemIds = getAllItemIdsForType(itemType);
return allItemIds
.map((id) => ({ id, type: itemType }) as TierListItem)
.flatMap((item) => {
if (placedItems.has(`${item.type}:${item.id}`)) {
if (!canAddDuplicates) return [];
return {
...item,
nth: getNextNthForItem(item, tiers),
};
}
if (item.type === "main-weapon" && typeof item.id === "number") {
const weaponType = weaponIdToType(item.id);
if (hideAltKits && weaponType === "ALT_KIT") return [];
if (hideAltSkins && weaponType === "ALT_SKIN") return [];
}
return item;
});
};
const handleMoveTierUp = (tierId: string) => {
const currentIndex = tiers.tiers.findIndex((tier) => tier.id === tierId);
if (currentIndex <= 0) return;
const newTiers = [...tiers.tiers];
[newTiers[currentIndex - 1], newTiers[currentIndex]] = [
newTiers[currentIndex],
newTiers[currentIndex - 1],
];
setTiers({
...tiers,
tiers: newTiers,
});
};
const handleMoveTierDown = (tierId: string) => {
const currentIndex = tiers.tiers.findIndex((tier) => tier.id === tierId);
if (currentIndex === -1 || currentIndex >= tiers.tiers.length - 1) {
return;
}
const newTiers = [...tiers.tiers];
[newTiers[currentIndex], newTiers[currentIndex + 1]] = [
newTiers[currentIndex + 1],
newTiers[currentIndex],
];
setTiers({
...tiers,
tiers: newTiers,
});
};
const handleReset = () => {
setTiers({
tiers: DEFAULT_TIERS,
tierItems: new Map(),
});
};
return {
itemType,
setItemType,
state: tiers,
activeItem,
handleDragStart,
handleDragOver,
handleDragEnd,
handleAddTier,
handleRemoveTier,
handleRenameTier,
handleChangeTierColor,
handleMoveTierUp,
handleMoveTierDown,
handleReset,
getItemsInTier,
availableItems: getAvailableItems(),
hideAltKits,
setHideAltKits,
hideAltSkins,
setHideAltSkins,
canAddDuplicates,
setCanAddDuplicates,
showTierHeaders,
setShowTierHeaders,
showArrowControls,
setShowArrowControls,
};
}
const TIER_SEARCH_PARAM_NAME = "state";
export function useSearchParamTiersState() {
const [initialSearchParams] = useSearchParams();
const [tiers, setTiers] = React.useState<TierListState>(() => {
const param = initialSearchParams.get(TIER_SEARCH_PARAM_NAME);
try {
if (param) {
const uncrushed = JSONCrush.uncrush(param);
const parsed = tierListStateSerializedSchema.parse(
JSON.parse(uncrushed),
);
return {
tiers: parsed.tiers,
tierItems: new Map(parsed.tierItems),
};
}
} catch {} // ignored on purpose
return {
tiers: DEFAULT_TIERS,
tierItems: new Map(),
};
});
const persistTiersStateToParams = () => {
const searchParams = new URLSearchParams(window.location.search);
const serializedState = JSON.stringify({
tiers: tiers.tiers,
tierItems: Array.from(tiers.tierItems.entries()),
});
searchParams.set(TIER_SEARCH_PARAM_NAME, JSONCrush.crush(serializedState));
window.history.replaceState(
{},
"",
`${window.location.pathname}?${String(searchParams)}`,
);
};
return {
tiers,
setTiers,
persistTiersStateToParams,
};
}

View File

@ -0,0 +1,20 @@
.container {
user-select: none;
}
.header {
display: flex;
justify-content: space-between;
}
.tierList {
display: flex;
flex-direction: column;
gap: var(--s-2);
margin-bottom: var(--s-6);
}
.filters {
display: flex;
gap: var(--s-4);
}

View File

@ -0,0 +1,257 @@
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
pointerWithin,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import type { MetaFunction } from "@remix-run/react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { SendouSwitch } from "~/components/elements/Switch";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { PlusIcon } from "~/components/icons/Plus";
import { RefreshIcon } from "~/components/icons/Refresh";
import { Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
import { useIsMounted } from "~/hooks/useIsMounted";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { navIconUrl, TIER_LIST_MAKER_URL } from "~/utils/urls";
import { ItemDragPreview } from "../components/ItemDragPreview";
import { ItemPool } from "../components/ItemPool";
import { TierRow } from "../components/TierRow";
import {
TierListProvider,
useTierListState,
} from "../contexts/TierListContext";
import type { TierListItem } from "../tier-list-maker-schemas";
import styles from "./tier-list-maker.module.css";
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Tier List Maker",
ogTitle: "Splatoon 3 tier list maker",
description:
"Generate Splatoon tier lists featuring main weapons, sub weapons, special weapons or stages.",
location: args.location,
});
};
export const handle: SendouRouteHandle = {
i18n: "tier-list-maker",
breadcrumb: () => ({
imgPath: navIconUrl("tier-list-maker"),
href: TIER_LIST_MAKER_URL,
type: "IMAGE",
}),
};
export default function TierListMakerPage() {
const isMounted = useIsMounted();
if (!isMounted)
return (
<Main bigger>
<Placeholder />
</Main>
);
return (
<TierListProvider>
<TierListMakerContent />
</TierListProvider>
);
}
function TierListMakerContent() {
const { t } = useTranslation(["tier-list-maker"]);
const {
itemType,
setItemType,
state,
activeItem,
handleDragStart,
handleDragOver,
handleDragEnd,
handleAddTier,
handleReset,
hideAltKits,
setHideAltKits,
hideAltSkins,
setHideAltSkins,
canAddDuplicates,
setCanAddDuplicates,
showTierHeaders,
setShowTierHeaders,
showArrowControls,
setShowArrowControls,
} = useTierListState();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
return (
<Main bigger className={clsx(styles.container, "stack lg")}>
<div className={styles.header}>
<SendouButton onPress={handleAddTier} size="small" icon={<PlusIcon />}>
{t("tier-list-maker:addTier")}
</SendouButton>
<ResetPopover key={state.tierItems.size} handleReset={handleReset} />
</div>
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="stack">
<div className={styles.tierList}>
{state.tiers.map((tier) => (
<TierRow key={tier.id} tier={tier} />
))}
</div>
<div className="stack horizontal md flex-wrap">
<SendouSwitch
isSelected={canAddDuplicates}
onChange={setCanAddDuplicates}
size="small"
>
{t("tier-list-maker:allowDuplicates")}
</SendouSwitch>
<SendouSwitch
isSelected={showTierHeaders}
onChange={setShowTierHeaders}
size="small"
>
{t("tier-list-maker:showTierHeaders")}
</SendouSwitch>
<SendouSwitch
isSelected={showArrowControls}
onChange={setShowArrowControls}
size="small"
>
{t("tier-list-maker:showArrowControls")}
</SendouSwitch>
</div>
</div>
<SendouTabs
selectedKey={itemType}
onSelectionChange={(key) => setItemType(key as TierListItem["type"])}
>
<SendouTabList>
<SendouTab id="main-weapon">
{t("tier-list-maker:mainWeapons")}
</SendouTab>
<SendouTab id="sub-weapon">
{t("tier-list-maker:subWeapons")}
</SendouTab>
<SendouTab id="special-weapon">
{t("tier-list-maker:specialWeapons")}
</SendouTab>
<SendouTab id="stage">{t("tier-list-maker:stages")}</SendouTab>
<SendouTab id="mode">{t("tier-list-maker:modes")}</SendouTab>
<SendouTab id="stage-mode">
{t("tier-list-maker:stageModes")}
</SendouTab>
</SendouTabList>
<SendouTabPanel id="main-weapon">
<div className="stack md">
<ItemPool />
<div className={styles.filters}>
<SendouSwitch
isSelected={hideAltKits}
onChange={setHideAltKits}
>
{t("tier-list-maker:hideAltKits")}
</SendouSwitch>
<SendouSwitch
isSelected={hideAltSkins}
onChange={setHideAltSkins}
>
{t("tier-list-maker:hideAltSkins")}
</SendouSwitch>
</div>
</div>
</SendouTabPanel>
<SendouTabPanel id="sub-weapon">
<ItemPool />
</SendouTabPanel>
<SendouTabPanel id="special-weapon">
<ItemPool />
</SendouTabPanel>
<SendouTabPanel id="stage">
<ItemPool />
</SendouTabPanel>
<SendouTabPanel id="mode">
<ItemPool />
</SendouTabPanel>
<SendouTabPanel id="stage-mode">
<ItemPool />
</SendouTabPanel>
</SendouTabs>
<DragOverlay>
{activeItem ? <ItemDragPreview item={activeItem} /> : null}
</DragOverlay>
</DndContext>
</Main>
);
}
function ResetPopover({ handleReset }: { handleReset: () => void }) {
const { t } = useTranslation(["tier-list-maker", "common"]);
return (
<SendouPopover
trigger={
<SendouButton
size="small"
icon={<RefreshIcon />}
variant="minimal-destructive"
>
{t("common:actions.reset")}
</SendouButton>
}
>
<div className="stack sm items-center">
<div>{t("tier-list-maker:resetConfirmation")}</div>
<div className="stack horizontal sm">
<SendouButton
size="miniscule"
variant="destructive"
onPress={() => {
handleReset();
}}
>
{t("common:actions.reset")}
</SendouButton>
</div>
</div>
</SendouPopover>
);
}

View File

@ -0,0 +1,9 @@
import type { TierListMakerTier } from "./tier-list-maker-schemas";
export const DEFAULT_TIERS: TierListMakerTier[] = [
{ id: "tier-x", name: "X", color: "#ff4655" },
{ id: "tier-s", name: "S", color: "#ff8c42" },
{ id: "tier-a", name: "A", color: "#ffd23f" },
{ id: "tier-b", name: "B", color: "#bfe84d" },
{ id: "tier-c", name: "C", color: "#5dbb63" },
];

View File

@ -0,0 +1,75 @@
import { z } from "zod/v4";
import { assertType } from "~/utils/types";
import {
hexCode,
modeShort,
specialWeaponId,
stageId,
subWeaponId,
weaponSplId,
} from "~/utils/zod";
export const tierListItemTypeSchema = z.enum([
"main-weapon",
"sub-weapon",
"special-weapon",
"stage",
"mode",
"stage-mode",
]);
assertType<z.infer<typeof tierListItemTypeSchema>, TierListItem["type"]>();
const tierListItemSchema = z.union([
z.object({
id: weaponSplId,
nth: z.number().optional(),
type: z.literal("main-weapon"),
}),
z.object({
id: subWeaponId,
nth: z.number().optional(),
type: z.literal("sub-weapon"),
}),
z.object({
id: specialWeaponId,
nth: z.number().optional(),
type: z.literal("special-weapon"),
}),
z.object({
id: stageId,
nth: z.number().optional(),
type: z.literal("stage"),
}),
z.object({
id: modeShort,
nth: z.number().optional(),
type: z.literal("mode"),
}),
z.object({
id: z.string(),
nth: z.number().optional(),
type: z.literal("stage-mode"),
}),
]);
export type TierListItem = z.infer<typeof tierListItemSchema>;
const tierSchema = z.object({
id: z.string(),
name: z.string(),
color: hexCode,
});
export type TierListMakerTier = z.infer<typeof tierSchema>;
type TierListItemSchemaType = z.infer<typeof tierListItemSchema>;
export const tierListStateSerializedSchema = z.object({
tiers: z.array(tierSchema),
tierItems: z.array(z.tuple([z.string(), z.array(tierListItemSchema)])),
});
export type TierListState = {
tiers: Array<TierListMakerTier>;
tierItems: Map<string, TierListItemSchemaType[]>;
};

View File

@ -0,0 +1,28 @@
import type { TierListItem, TierListState } from "./tier-list-maker-schemas";
export function tierListItemId(item: TierListItem) {
return `${item.type}:${item.id}${item.nth ? `:${item.nth}` : ""}`;
}
/**
* Finds the next nth value for a duplicate item in the tier list.
* Searches through all tiers to find the maximum nth value for items
* with the same id and type, then returns max + 1.
*/
export function getNextNthForItem(
item: TierListItem,
tiers: TierListState,
): number {
return (
Array.from(tiers.tierItems.values()).reduce((maxNth, items) => {
const matchingItems = items.filter(
(i) => i.id === item.id && i.type === item.type,
);
const currentMax = Math.max(
...matchingItems.map((i) => i.nth ?? 0),
maxNth,
);
return currentMax;
}, 0) + 1
);
}

View File

@ -14,6 +14,7 @@ import orgDa from "../../../locales/da/org.json";
import qDa from "../../../locales/da/q.json";
import scrimsDa from "../../../locales/da/scrims.json";
import teamDa from "../../../locales/da/team.json";
import tierListMakerDa from "../../../locales/da/tier-list-maker.json";
import tournamentDa from "../../../locales/da/tournament.json";
import userDa from "../../../locales/da/user.json";
import vodsDa from "../../../locales/da/vods.json";
@ -34,6 +35,7 @@ import orgDe from "../../../locales/de/org.json";
import qDe from "../../../locales/de/q.json";
import scrimsDe from "../../../locales/de/scrims.json";
import teamDe from "../../../locales/de/team.json";
import tierListMakerDe from "../../../locales/de/tier-list-maker.json";
import tournamentDe from "../../../locales/de/tournament.json";
import userDe from "../../../locales/de/user.json";
import vodsDe from "../../../locales/de/vods.json";
@ -54,6 +56,7 @@ import org from "../../../locales/en/org.json";
import q from "../../../locales/en/q.json";
import scrimsEn from "../../../locales/en/scrims.json";
import team from "../../../locales/en/team.json";
import tierListMaker from "../../../locales/en/tier-list-maker.json";
import tournament from "../../../locales/en/tournament.json";
import user from "../../../locales/en/user.json";
import vods from "../../../locales/en/vods.json";
@ -74,6 +77,7 @@ import orgEsEs from "../../../locales/es-ES/org.json";
import qEsEs from "../../../locales/es-ES/q.json";
import scrimsEsEs from "../../../locales/es-ES/scrims.json";
import teamEsEs from "../../../locales/es-ES/team.json";
import tierListMakerEsEs from "../../../locales/es-ES/tier-list-maker.json";
import tournamentEsEs from "../../../locales/es-ES/tournament.json";
import userEsEs from "../../../locales/es-ES/user.json";
import vodsEsEs from "../../../locales/es-ES/vods.json";
@ -94,6 +98,7 @@ import orgEsUs from "../../../locales/es-US/org.json";
import qEsUs from "../../../locales/es-US/q.json";
import scrimsEsUs from "../../../locales/es-US/scrims.json";
import teamEsUs from "../../../locales/es-US/team.json";
import tierListMakerEsUs from "../../../locales/es-US/tier-list-maker.json";
import tournamentEsUs from "../../../locales/es-US/tournament.json";
import userEsUs from "../../../locales/es-US/user.json";
import vodsEsUs from "../../../locales/es-US/vods.json";
@ -114,6 +119,7 @@ import orgFrCa from "../../../locales/fr-CA/org.json";
import qFrCa from "../../../locales/fr-CA/q.json";
import scrimsFrCa from "../../../locales/fr-CA/scrims.json";
import teamFrCa from "../../../locales/fr-CA/team.json";
import tierListMakerFrCa from "../../../locales/fr-CA/tier-list-maker.json";
import tournamentFrCa from "../../../locales/fr-CA/tournament.json";
import userFrCa from "../../../locales/fr-CA/user.json";
import vodsFrCa from "../../../locales/fr-CA/vods.json";
@ -134,6 +140,7 @@ import orgFrEu from "../../../locales/fr-EU/org.json";
import qFrEu from "../../../locales/fr-EU/q.json";
import scrimsFrEu from "../../../locales/fr-EU/scrims.json";
import teamFrEu from "../../../locales/fr-EU/team.json";
import tierListMakerFrEu from "../../../locales/fr-EU/tier-list-maker.json";
import tournamentFrEu from "../../../locales/fr-EU/tournament.json";
import userFrEu from "../../../locales/fr-EU/user.json";
import vodsFrEu from "../../../locales/fr-EU/vods.json";
@ -154,6 +161,7 @@ import orgHe from "../../../locales/he/org.json";
import qHe from "../../../locales/he/q.json";
import scrimsHe from "../../../locales/he/scrims.json";
import teamHe from "../../../locales/he/team.json";
import tierListMakerHe from "../../../locales/he/tier-list-maker.json";
import tournamentHe from "../../../locales/he/tournament.json";
import userHe from "../../../locales/he/user.json";
import vodsHe from "../../../locales/he/vods.json";
@ -174,6 +182,7 @@ import orgIt from "../../../locales/it/org.json";
import qIt from "../../../locales/it/q.json";
import scrimsIt from "../../../locales/it/scrims.json";
import teamIt from "../../../locales/it/team.json";
import tierListMakerIt from "../../../locales/it/tier-list-maker.json";
import tournamentIt from "../../../locales/it/tournament.json";
import userIt from "../../../locales/it/user.json";
import vodsIt from "../../../locales/it/vods.json";
@ -194,6 +203,7 @@ import orgJa from "../../../locales/ja/org.json";
import qJa from "../../../locales/ja/q.json";
import scrimsJa from "../../../locales/ja/scrims.json";
import teamJa from "../../../locales/ja/team.json";
import tierListMakerJa from "../../../locales/ja/tier-list-maker.json";
import tournamentJa from "../../../locales/ja/tournament.json";
import userJa from "../../../locales/ja/user.json";
import vodsJa from "../../../locales/ja/vods.json";
@ -214,6 +224,7 @@ import orgKo from "../../../locales/ko/org.json";
import qKo from "../../../locales/ko/q.json";
import scrimsKo from "../../../locales/ko/scrims.json";
import teamKo from "../../../locales/ko/team.json";
import tierListMakerKo from "../../../locales/ko/tier-list-maker.json";
import tournamentKo from "../../../locales/ko/tournament.json";
import userKo from "../../../locales/ko/user.json";
import vodsKo from "../../../locales/ko/vods.json";
@ -234,6 +245,7 @@ import orgNl from "../../../locales/nl/org.json";
import qNl from "../../../locales/nl/q.json";
import scrimsNl from "../../../locales/nl/scrims.json";
import teamNl from "../../../locales/nl/team.json";
import tierListMakerNl from "../../../locales/nl/tier-list-maker.json";
import tournamentNl from "../../../locales/nl/tournament.json";
import userNl from "../../../locales/nl/user.json";
import vodsNl from "../../../locales/nl/vods.json";
@ -254,6 +266,7 @@ import orgPl from "../../../locales/pl/org.json";
import qPl from "../../../locales/pl/q.json";
import scrimsPl from "../../../locales/pl/scrims.json";
import teamPl from "../../../locales/pl/team.json";
import tierListMakerPl from "../../../locales/pl/tier-list-maker.json";
import tournamentPl from "../../../locales/pl/tournament.json";
import userPl from "../../../locales/pl/user.json";
import vodsPl from "../../../locales/pl/vods.json";
@ -274,6 +287,7 @@ import orgPtBr from "../../../locales/pt-BR/org.json";
import qPtBr from "../../../locales/pt-BR/q.json";
import scrimsPtBr from "../../../locales/pt-BR/scrims.json";
import teamPtBr from "../../../locales/pt-BR/team.json";
import tierListMakerPtBr from "../../../locales/pt-BR/tier-list-maker.json";
import tournamentPtBr from "../../../locales/pt-BR/tournament.json";
import userPtBr from "../../../locales/pt-BR/user.json";
import vodsPtBr from "../../../locales/pt-BR/vods.json";
@ -294,6 +308,7 @@ import orgRu from "../../../locales/ru/org.json";
import qRu from "../../../locales/ru/q.json";
import scrimsRu from "../../../locales/ru/scrims.json";
import teamRu from "../../../locales/ru/team.json";
import tierListMakerRu from "../../../locales/ru/tier-list-maker.json";
import tournamentRu from "../../../locales/ru/tournament.json";
import userRu from "../../../locales/ru/user.json";
import vodsRu from "../../../locales/ru/vods.json";
@ -314,6 +329,7 @@ import orgZh from "../../../locales/zh/org.json";
import qZh from "../../../locales/zh/q.json";
import scrimsZh from "../../../locales/zh/scrims.json";
import teamZh from "../../../locales/zh/team.json";
import tierListMakerZh from "../../../locales/zh/tier-list-maker.json";
import tournamentZh from "../../../locales/zh/tournament.json";
import userZh from "../../../locales/zh/user.json";
import vodsZh from "../../../locales/zh/vods.json";
@ -340,6 +356,7 @@ export const resources = {
badges: badgesEsUs,
contributions: contributionsEsUs,
team: teamEsUs,
"tier-list-maker": tierListMakerEsUs,
analyzer: analyzerEsUs,
},
en: {
@ -362,6 +379,7 @@ export const resources = {
badges: badges,
contributions: contributions,
team: team,
"tier-list-maker": tierListMaker,
analyzer: analyzer,
},
ko: {
@ -384,6 +402,7 @@ export const resources = {
badges: badgesKo,
contributions: contributionsKo,
team: teamKo,
"tier-list-maker": tierListMakerKo,
analyzer: analyzerKo,
},
de: {
@ -406,6 +425,7 @@ export const resources = {
badges: badgesDe,
contributions: contributionsDe,
team: teamDe,
"tier-list-maker": tierListMakerDe,
analyzer: analyzerDe,
},
nl: {
@ -428,6 +448,7 @@ export const resources = {
badges: badgesNl,
contributions: contributionsNl,
team: teamNl,
"tier-list-maker": tierListMakerNl,
analyzer: analyzerNl,
},
"pt-BR": {
@ -450,6 +471,7 @@ export const resources = {
badges: badgesPtBr,
contributions: contributionsPtBr,
team: teamPtBr,
"tier-list-maker": tierListMakerPtBr,
analyzer: analyzerPtBr,
},
zh: {
@ -472,6 +494,7 @@ export const resources = {
badges: badgesZh,
contributions: contributionsZh,
team: teamZh,
"tier-list-maker": tierListMakerZh,
analyzer: analyzerZh,
},
"fr-CA": {
@ -494,6 +517,7 @@ export const resources = {
badges: badgesFrCa,
contributions: contributionsFrCa,
team: teamFrCa,
"tier-list-maker": tierListMakerFrCa,
analyzer: analyzerFrCa,
},
ru: {
@ -516,6 +540,7 @@ export const resources = {
badges: badgesRu,
contributions: contributionsRu,
team: teamRu,
"tier-list-maker": tierListMakerRu,
analyzer: analyzerRu,
},
it: {
@ -538,6 +563,7 @@ export const resources = {
badges: badgesIt,
contributions: contributionsIt,
team: teamIt,
"tier-list-maker": tierListMakerIt,
analyzer: analyzerIt,
},
ja: {
@ -560,6 +586,7 @@ export const resources = {
badges: badgesJa,
contributions: contributionsJa,
team: teamJa,
"tier-list-maker": tierListMakerJa,
analyzer: analyzerJa,
},
da: {
@ -582,6 +609,7 @@ export const resources = {
badges: badgesDa,
contributions: contributionsDa,
team: teamDa,
"tier-list-maker": tierListMakerDa,
analyzer: analyzerDa,
},
"es-ES": {
@ -604,6 +632,7 @@ export const resources = {
badges: badgesEsEs,
contributions: contributionsEsEs,
team: teamEsEs,
"tier-list-maker": tierListMakerEsEs,
analyzer: analyzerEsEs,
},
he: {
@ -626,6 +655,7 @@ export const resources = {
badges: badgesHe,
contributions: contributionsHe,
team: teamHe,
"tier-list-maker": tierListMakerHe,
analyzer: analyzerHe,
},
"fr-EU": {
@ -648,6 +678,7 @@ export const resources = {
badges: badgesFrEu,
contributions: contributionsFrEu,
team: teamFrEu,
"tier-list-maker": tierListMakerFrEu,
analyzer: analyzerFrEu,
},
pl: {
@ -670,6 +701,7 @@ export const resources = {
badges: badgesPl,
contributions: contributionsPl,
team: teamPl,
"tier-list-maker": tierListMakerPl,
analyzer: analyzerPl,
},
};

View File

@ -129,9 +129,27 @@ export function weaponIdToArrayWithAlts(weaponId: MainWeaponId) {
return [weaponId];
}
const altWeaponIds = new Set(altWeaponIdToId.keys());
export const weaponIdIsNotAlt = (weaponId: MainWeaponId) =>
!altWeaponIds.has(weaponId);
/**
* Determines the type of weapon based on its ID
*
* @returns "ALT_SKIN" if the weapon is an alternate skin, "BASE" if it's a base weapon, or "ALT_KIT" if it's an alternate kit
*
* @example
* // Splattershot is a base weapon
* weaponIdToType(40); // -> "BASE"
*
* // Tentatek Splattershot is an alternate kit
* weaponIdToType(41); // -> "ALT_KIT"
*
* // Hero Shot Replica is an alternate skin
* weaponIdToType(45); // -> "ALT_SKIN"
*/
export const weaponIdToType = (weaponId: MainWeaponId) => {
if (altWeaponIdToId.has(weaponId)) return "ALT_SKIN";
if (weaponId === weaponIdToBaseWeaponId(weaponId)) return "BASE";
return "ALT_KIT";
};
/** Returns true if the weapon ID has alternate skins
*

View File

@ -265,6 +265,7 @@ export const namespaceJsonsToPreloadObj: Record<Namespace, boolean> = {
scrims: true,
tournament: true,
team: true,
"tier-list-maker": true,
vods: true,
art: true,
q: true,

View File

@ -188,6 +188,11 @@ export default [
route("/tiers", "features/sendouq/routes/tiers.tsx"),
route(
"/tier-list-maker",
"features/tier-list-maker/routes/tier-list-maker.tsx",
),
...prefix("/lfg", [
index("features/lfg/routes/lfg.tsx"),
route("new", "features/lfg/routes/lfg.new.tsx"),

View File

@ -101,6 +101,7 @@ export const STOP_IMPERSONATING_URL = "/auth/impersonate/stop";
export const SEED_URL = "/seed";
export const PLANNER_URL = "/plans";
export const MAPS_URL = "/maps";
export const TIER_LIST_MAKER_URL = "/tier-list-maker";
export const ANALYZER_URL = "/analyzer";
export const OBJECT_DAMAGE_CALCULATOR_URL = "/object-damage-calculator";
export const VODS_PAGE = "/vods";

View File

@ -6,7 +6,11 @@ import {
type abilitiesShort,
} from "~/modules/in-game-lists/abilities";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import {
mainWeaponIds,
specialWeaponIds,
subWeaponIds,
} from "~/modules/in-game-lists/weapon-ids";
import { FRIEND_CODE_REGEXP } from "../features/sendouq/q-constants";
import { SHORT_NANOID_LENGTH } from "./id";
import type { Unpacked } from "./types";
@ -108,6 +112,10 @@ export const weaponSplId = z.preprocess(
numericEnum(mainWeaponIds),
);
export const subWeaponId = numericEnum(subWeaponIds);
export const specialWeaponId = numericEnum(specialWeaponIds);
export const qWeapon = z.object({
weaponSplId,
isFavorite: z.union([z.literal(0), z.literal(1)]),

View File

@ -0,0 +1,63 @@
import { expect, type Locator, type Page, test } from "@playwright/test";
import { navigate } from "~/utils/playwright";
import { TIER_LIST_MAKER_URL } from "~/utils/urls";
test.describe("Tier List Maker", () => {
test("toggles work, items can be dragged, and state persists after reload", async ({
page,
}) => {
await navigate({ page, url: TIER_LIST_MAKER_URL });
const emptyTiers = page.getByText("Drop items here");
await expect(emptyTiers).toHaveCount(5);
// Test that toggles are clickable (just verify they work)
await page.getByText("Allow duplicates").click();
await page.getByText("Show tier headers").click();
await page.getByText("Show arrow controls").click();
await page.getByText("Hide alt kits").click();
await page.getByText("Hide alt skins").click();
// Drag first weapon to first tier
const firstWeapon = page.locator('[data-item-id^="main-weapon:"]').first();
await dragItemToTier(page, firstWeapon);
// Drag second weapon to second tier
const secondWeapon = page.locator('[data-item-id^="main-weapon:"]').first();
await dragItemToTier(page, secondWeapon);
// Switch to Stages tab and drag a stage
await page.getByText("Stages").click();
const firstStage = page.locator('[data-item-id^="stage:"]').first();
await expect(firstStage).toBeVisible();
await dragItemToTier(page, firstStage);
// Wait for state to settle
await page.waitForTimeout(1000);
// Reload the page
await page.reload();
// Verify items persisted in the tier list (not in the pool)
// Should have fewer than 5 "Drop items here" (meaning at least 1 tier is filled)
await expect(emptyTiers).toHaveCount(3);
});
});
async function dragItemToTier(page: Page, item: Locator) {
await item.hover();
await page.mouse.down();
const tier = page.getByText("Drop items here").last();
const tierBox = await tier.boundingBox();
if (tierBox) {
await page.mouse.move(
tierBox.x + tierBox.width / 2,
tierBox.y + tierBox.height / 2,
{ steps: 10 },
);
}
await page.mouse.up();
await page.waitForTimeout(200);
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "",
"pages.scrims": "",
"pages.settings": "",
"pages.tier-list-maker": "",
"pages.luti": "",
"header.profile": "Profil",
"header.logout": "Log ud",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "",
"pages.scrims": "",
"pages.settings": "",
"pages.tier-list-maker": "",
"pages.luti": "",
"header.profile": "Profil",
"header.logout": "Ausloggen",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "LFG",
"pages.scrims": "Scrims",
"pages.settings": "Settings",
"pages.tier-list-maker": "Tier List Maker",
"pages.luti": "LUTI",
"header.profile": "Profile",
"header.logout": "Log out",

View File

@ -0,0 +1,17 @@
{
"addTier": "Add tier",
"mainWeapons": "Main Weapons",
"subWeapons": "Sub Weapons",
"specialWeapons": "Special Weapons",
"stages": "Stages",
"modes": "Modes",
"stageModes": "Stage + Modes",
"dropItems": "Drop items here",
"editingTier": "Editing tier",
"hideAltKits": "Hide alt kits",
"hideAltSkins": "Hide alt skins",
"resetConfirmation": "Are you sure you want to reset the tier list? This will remove all items and restore default tiers.",
"allowDuplicates": "Allow duplicates",
"showTierHeaders": "Show tier headers",
"showArrowControls": "Show arrow controls"
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "",
"pages.scrims": "",
"pages.settings": "",
"pages.tier-list-maker": "",
"pages.luti": "",
"header.profile": "Perfil",
"header.logout": "Cerrar sesión",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "Buscando grupo",
"pages.scrims": "",
"pages.settings": "",
"pages.tier-list-maker": "",
"pages.luti": "",
"header.profile": "Perfil",
"header.logout": "Cerrar sesión",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "",
"pages.scrims": "",
"pages.settings": "",
"pages.tier-list-maker": "",
"pages.luti": "",
"header.profile": "Profil",
"header.logout": "Déconnexion",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "LFG",
"pages.scrims": "Scrims",
"pages.settings": "Paramètres",
"pages.tier-list-maker": "",
"pages.luti": "LUTI",
"header.profile": "Profil",
"header.logout": "Déconnexion",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "",
"pages.scrims": "",
"pages.settings": "",
"pages.tier-list-maker": "",
"pages.luti": "",
"header.profile": "פרופיל",
"header.logout": "התנתקות",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "LFG",
"pages.scrims": "",
"pages.settings": "Impostazioni",
"pages.tier-list-maker": "",
"pages.luti": "LUTI",
"header.profile": "Profilo",
"header.logout": "Esci",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "グループ探し",
"pages.scrims": "",
"pages.settings": "設定",
"pages.tier-list-maker": "",
"pages.luti": "",
"header.profile": "プロファイル",
"header.logout": "ログアウト",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "",
"pages.scrims": "",
"pages.settings": "",
"pages.tier-list-maker": "",
"pages.luti": "",
"header.profile": "프로필",
"header.logout": "로그아웃",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "",
"pages.scrims": "",
"pages.settings": "",
"pages.tier-list-maker": "",
"pages.luti": "",
"header.profile": "Profiel",
"header.logout": "Log uit",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "",
"pages.scrims": "",
"pages.settings": "",
"pages.tier-list-maker": "",
"pages.luti": "",
"header.profile": "Profil",
"header.logout": "Wyloguj się",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "Formação de Times",
"pages.scrims": "",
"pages.settings": "",
"pages.tier-list-maker": "",
"pages.luti": "",
"header.profile": "Perfil",
"header.logout": "Sair",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "LFG",
"pages.scrims": "Скримы",
"pages.settings": "Настройки",
"pages.tier-list-maker": "",
"pages.luti": "LUTI",
"header.profile": "Профиль",
"header.logout": "Выйти",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

View File

@ -26,6 +26,7 @@
"pages.lfg": "招募板",
"pages.scrims": "",
"pages.settings": "",
"pages.tier-list-maker": "",
"pages.luti": "",
"header.profile": "个人资料",
"header.logout": "登出",

View File

@ -0,0 +1,17 @@
{
"addTier": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
"stages": "",
"modes": "",
"stageModes": "",
"dropItems": "",
"editingTier": "",
"hideAltKits": "",
"hideAltSkins": "",
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
}

13
package-lock.json generated
View File

@ -33,6 +33,7 @@
"i18next-http-backend": "^2.6.2",
"ics": "^3.8.1",
"isbot": "^5.1.27",
"jsoncrush": "^1.1.8",
"kysely": "^0.28.2",
"lru-cache": "^11.1.0",
"markdown-to-jsx": "^7.7.6",
@ -11483,6 +11484,12 @@
"node": ">=6"
}
},
"node_modules/jsoncrush": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/jsoncrush/-/jsoncrush-1.1.8.tgz",
"integrity": "sha512-lvIMGzMUA0fjuqwNcxlTNRq2bibPZ9auqT/LyGdlR5hvydJtA/BasSgkx4qclqTKVeTidrJvsS/oVjlTCPQ4Nw==",
"license": "MIT"
},
"node_modules/jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
@ -18695,9 +18702,9 @@
}
},
"node_modules/zod": {
"version": "3.25.61",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.61.tgz",
"integrity": "sha512-fzfJgUw78LTNnHujj9re1Ov/JJQkRZZGDMcYqSx7Hp4rPOkKywaFHq0S6GoHeXs0wGNE/sIOutkXgnwzrVOGCQ==",
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@ -54,6 +54,7 @@
"i18next-http-backend": "^2.6.2",
"ics": "^3.8.1",
"isbot": "^5.1.27",
"jsoncrush": "^1.1.8",
"kysely": "^0.28.2",
"lru-cache": "^11.1.0",
"markdown-to-jsx": "^7.7.6",

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB