From edd3a1d91b35fc7768c9a7e1e03c77b30c1bc8c4 Mon Sep 17 00:00:00 2001 From: Remmy Cat Stock <3317423+remmycat@users.noreply.github.com> Date: Tue, 25 Oct 2022 22:54:58 +0200 Subject: [PATCH] Improve MapPoolSelector UI --- app/components/MapPoolSelector.tsx | 160 ++++++++++++++++++-- app/components/icons/ArrowLongLeft.tsx | 18 +++ app/hooks/useOnce.ts | 10 ++ app/modules/in-game-lists/modes.ts | 25 +-- app/modules/map-pool-serializer/map-pool.ts | 22 ++- app/routes/calendar/$id/index.tsx | 8 +- app/routes/calendar/new.tsx | 70 +++------ app/routes/maps.tsx | 119 ++++++++------- app/styles/calendar-new.css | 2 +- app/styles/maps.css | 40 +++-- app/styles/utils.css | 4 + app/styles/vars.css | 5 + app/utils/urls.ts | 10 +- public/locales/de/common.json | 2 + public/locales/de/game-misc.json | 7 +- public/locales/en/common.json | 2 + public/locales/en/game-misc.json | 7 +- 17 files changed, 355 insertions(+), 156 deletions(-) create mode 100644 app/components/icons/ArrowLongLeft.tsx create mode 100644 app/hooks/useOnce.ts diff --git a/app/components/MapPoolSelector.tsx b/app/components/MapPoolSelector.tsx index 6896fd97f..a4a495691 100644 --- a/app/components/MapPoolSelector.tsx +++ b/app/components/MapPoolSelector.tsx @@ -3,23 +3,73 @@ import { useTranslation } from "react-i18next"; import { Image } from "~/components/Image"; import type { ModeShort, StageId } from "~/modules/in-game-lists"; import { modes, stageIds } from "~/modules/in-game-lists"; -import { type MapPool } from "~/modules/map-pool-serializer"; +import { MapPool } from "~/modules/map-pool-serializer"; import { modeImageUrl, stageImageUrl } from "~/utils/urls"; +import { Button } from "~/components/Button"; +import { CrossIcon } from "./icons/Cross"; +import { ArrowLongLeftIcon } from "./icons/ArrowLongLeft"; +import * as React from "react"; + +export type MapPoolSelectorProps = { + mapPool: MapPool; + handleRemoval?: () => void; + handleMapPoolChange: (mapPool: MapPool) => void; + className?: string; +}; export function MapPoolSelector({ mapPool, handleMapPoolChange, -}: { + handleRemoval, + className, +}: MapPoolSelectorProps) { + const { t } = useTranslation(); + + const handleStageModesChange = (newMapPool: MapPool) => { + handleMapPoolChange(newMapPool); + }; + + const handleClear = () => { + handleMapPoolChange(MapPool.EMPTY); + }; + + return ( +
+ {t("maps.mapPool")} +
+
+ {handleRemoval && ( + + )} + +
+ +
+
+ ); +} + +export type MapPoolStagesProps = { mapPool: MapPool; - handleMapPoolChange?: ({ - mode, - stageId, - }: { - mode: ModeShort; - stageId: StageId; - }) => void; -}) { - const { t } = useTranslation(["game-misc"]); + handleMapPoolChange?: (newMapPool: MapPool) => void; +}; + +export function MapPoolStages({ + mapPool, + handleMapPoolChange, +}: MapPoolStagesProps) { + const { t } = useTranslation(["game-misc", "common"]); const isPresentational = !handleMapPoolChange; @@ -29,6 +79,52 @@ export function MapPoolSelector({ return mapPool.hasStage(stageId); }; + const handleModeChange = ({ + mode, + stageId, + }: { + mode: ModeShort; + stageId: StageId; + }) => { + const newMapPool = mapPool.parsed[mode].includes(stageId) + ? new MapPool({ + ...mapPool.parsed, + [mode]: mapPool.parsed[mode].filter((id) => id !== stageId), + }) + : new MapPool({ + ...mapPool.parsed, + [mode]: [...mapPool.parsed[mode], stageId], + }); + + handleMapPoolChange?.(newMapPool); + }; + + const handleStageClear = (stageId: StageId) => { + const newMapPool = new MapPool({ + TW: mapPool.parsed.TW.filter((id) => id !== stageId), + SZ: mapPool.parsed.SZ.filter((id) => id !== stageId), + TC: mapPool.parsed.TC.filter((id) => id !== stageId), + RM: mapPool.parsed.RM.filter((id) => id !== stageId), + CB: mapPool.parsed.CB.filter((id) => id !== stageId), + }); + + handleMapPoolChange?.(newMapPool); + }; + + const handleStageAdd = (stageId: StageId) => { + const newMapPool = new MapPool({ + TW: [...mapPool.parsed.TW, stageId], + SZ: [...mapPool.parsed.SZ, stageId], + TC: [...mapPool.parsed.TC, stageId], + RM: [...mapPool.parsed.RM, stageId], + CB: [...mapPool.parsed.CB, stageId], + }); + + handleMapPoolChange?.(newMapPool); + }; + + const id = React.useId(); + return (
{stageIds.filter(stageRowIsVisible).map((stageId) => ( @@ -40,8 +136,14 @@ export function MapPoolSelector({ width={80} height={45} /> -
-
{t(`game-misc:STAGE_${stageId}`)}
+
+
+ {t(`game-misc:STAGE_${stageId}`)} +
{modes.map((mode) => { const selected = mapPool.parsed[mode.short].includes(stageId); @@ -54,7 +156,8 @@ export function MapPoolSelector({ className={clsx("maps__mode", { selected, })} - alt={mode.long} + title={t(`game-misc:MODE_LONG_${mode.short}`)} + alt={t(`game-misc:MODE_LONG_${mode.short}`)} path={modeImageUrl(mode.short)} width={33} height={33} @@ -69,15 +172,18 @@ export function MapPoolSelector({ selected, })} onClick={() => - handleMapPoolChange?.({ mode: mode.short, stageId }) + handleModeChange?.({ mode: mode.short, stageId }) } type="button" + title={t(`game-misc:MODE_LONG_${mode.short}`)} + aria-describedby={`${id}-stage-name-${stageId}`} + aria-pressed={selected} > {mode.long} ); })} + {!isPresentational && + (mapPool.hasStage(stageId) ? ( +
diff --git a/app/components/icons/ArrowLongLeft.tsx b/app/components/icons/ArrowLongLeft.tsx new file mode 100644 index 000000000..b419e99ea --- /dev/null +++ b/app/components/icons/ArrowLongLeft.tsx @@ -0,0 +1,18 @@ +export function ArrowLongLeftIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/hooks/useOnce.ts b/app/hooks/useOnce.ts new file mode 100644 index 000000000..4296bf40a --- /dev/null +++ b/app/hooks/useOnce.ts @@ -0,0 +1,10 @@ +import { useMemo } from "react"; + +/** + * Utility hook for calling `useMemo(f, [])`, when you're sure it needs no + * revalidation but feel bad for getting shamed by eslint everytime :D + */ +export function useOnce(factory: () => T) { + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(factory, []); +} diff --git a/app/modules/in-game-lists/modes.ts b/app/modules/in-game-lists/modes.ts index 62736c388..d44939f0f 100644 --- a/app/modules/in-game-lists/modes.ts +++ b/app/modules/in-game-lists/modes.ts @@ -1,24 +1,9 @@ export const modes = [ - { - short: "TW", - long: "Turf War", - }, - { - short: "SZ", - long: "Splat Zones", - }, - { - short: "TC", - long: "Tower Control", - }, - { - short: "RM", - long: "Rainmaker", - }, - { - short: "CB", - long: "Clam Blitz", - }, + { short: "TW" }, + { short: "SZ" }, + { short: "TC" }, + { short: "RM" }, + { short: "CB" }, ] as const; export const modesShort = modes.map((mode) => mode.short); diff --git a/app/modules/map-pool-serializer/map-pool.ts b/app/modules/map-pool-serializer/map-pool.ts index 8c26c654b..8639d5416 100644 --- a/app/modules/map-pool-serializer/map-pool.ts +++ b/app/modules/map-pool-serializer/map-pool.ts @@ -6,7 +6,11 @@ import type { ReadonlyMapPoolObject, MapPoolObject } from "./types"; import clone from "just-clone"; import type { MapPoolMap } from "~/db/types"; import { mapPoolListToMapPoolObject } from "~/modules/map-list-generator"; -import type { ModeShort, StageId } from "~/modules/in-game-lists"; +import { + type ModeShort, + type StageId, + stageIds, +} from "~/modules/in-game-lists"; type DbMapPoolList = Array>; @@ -84,4 +88,20 @@ export class MapPool { toJSON() { return this.parsed; } + + static EMPTY = new MapPool({ + SZ: [], + TC: [], + CB: [], + RM: [], + TW: [], + }); + + static ANARCHY = new MapPool({ + SZ: [...stageIds], + TC: [...stageIds], + CB: [...stageIds], + RM: [...stageIds], + TW: [], + }); } diff --git a/app/routes/calendar/$id/index.tsx b/app/routes/calendar/$id/index.tsx index bc9db4ffe..28b2b8c69 100644 --- a/app/routes/calendar/$id/index.tsx +++ b/app/routes/calendar/$id/index.tsx @@ -33,13 +33,13 @@ import { discordFullName, makeTitle } from "~/utils/strings"; import { calendarEditPage, calendarReportWinnersPage, - mapsPage, navIconUrl, + readonlyMapsPage, resolveBaseUrl, userPage, } from "~/utils/urls"; import { actualNumber, id } from "~/utils/zod"; -import { MapPoolSelector } from "~/components/MapPoolSelector"; +import { MapPoolStages } from "~/components/MapPoolSelector"; import { Tags } from "../components/Tags"; import { MapPool } from "~/modules/map-pool-serializer"; @@ -245,10 +245,10 @@ function MapPoolInfo() { return (
- + diff --git a/app/routes/calendar/new.tsx b/app/routes/calendar/new.tsx index b19e1076d..5357ffadb 100644 --- a/app/routes/calendar/new.tsx +++ b/app/routes/calendar/new.tsx @@ -20,14 +20,12 @@ import { TrashIcon } from "~/components/icons/Trash"; import { Input } from "~/components/Input"; import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; -import { Toggle } from "~/components/Toggle"; import { CALENDAR_EVENT } from "~/constants"; import { db } from "~/db"; import type { Badge as BadgeType, CalendarEventTag } from "~/db/types"; import { useIsMounted } from "~/hooks/useIsMounted"; import { requireUser } from "~/modules/auth"; import { i18next } from "~/modules/i18n"; -import type { ModeShort, StageId } from "~/modules/in-game-lists"; import { MapPool } from "~/modules/map-pool-serializer"; import { canEditCalendarEvent } from "~/permissions"; import calendarNewStyles from "~/styles/calendar-new.css"; @@ -519,61 +517,41 @@ function BadgesAdder() { ); } -const DEFAULT_MAP_POOL = new MapPool({ - SZ: [], - TC: [], - CB: [], - RM: [], - TW: [], -}); function MapPoolSection() { - const { t } = useTranslation(["game-misc", "calendar"]); + const { t } = useTranslation(["game-misc", "common"]); const { eventToEdit } = useLoaderData(); const [mapPool, setMapPool] = React.useState( - eventToEdit?.mapPool ? new MapPool(eventToEdit.mapPool) : DEFAULT_MAP_POOL + eventToEdit?.mapPool ? new MapPool(eventToEdit.mapPool) : MapPool.EMPTY ); const [includeMapPool, setIncludeMapPool] = React.useState( Boolean(eventToEdit?.mapPool) ); - const handleMapPoolChange = ({ - mode, - stageId, - }: { - mode: ModeShort; - stageId: StageId; - }) => { - const newMapPool = new MapPool( - mapPool.parsed[mode].includes(stageId) - ? { - ...mapPool.parsed, - [mode]: mapPool.parsed[mode].filter((id) => id !== stageId), - } - : { - ...mapPool.parsed, - [mode]: [...mapPool.parsed[mode], stageId], - } - ); + const id = React.useId(); - setMapPool(newMapPool); - }; + return includeMapPool ? ( + <> + - return ( -
- {includeMapPool && ( - - )} - -
- - {includeMapPool && ( - - )} -
+ setIncludeMapPool(false)} + handleMapPoolChange={setMapPool} + /> + + ) : ( +
+ +
); } diff --git a/app/routes/maps.tsx b/app/routes/maps.tsx index 61eb662cc..1c63249ea 100644 --- a/app/routes/maps.tsx +++ b/app/routes/maps.tsx @@ -17,12 +17,7 @@ import { Main } from "~/components/Main"; import { Toggle } from "~/components/Toggle"; import { db } from "~/db"; import { i18next } from "~/modules/i18n"; -import { - stageIds, - type ModeShort, - type ModeWithStage, - type StageId, -} from "~/modules/in-game-lists"; +import { stageIds, type ModeWithStage } from "~/modules/in-game-lists"; import { generateMapList, mapPoolToNonEmptyModes, @@ -33,11 +28,18 @@ import styles from "~/styles/maps.css"; import { makeTitle } from "~/utils/strings"; import { calendarEventPage, ipLabsMaps } from "~/utils/urls"; import { type SendouRouteHandle } from "~/utils/remix"; -import { MapPoolSelector } from "~/components/MapPoolSelector"; +import { MapPoolSelector, MapPoolStages } from "~/components/MapPoolSelector"; +import { EditIcon } from "~/components/icons/Edit"; +import { useOnce } from "~/hooks/useOnce"; const AMOUNT_OF_MAPS_IN_MAP_LIST = stageIds.length * 2; -export const unstable_shouldReload: ShouldReloadFunction = () => false; +export const unstable_shouldReload: ShouldReloadFunction = ({ url }) => { + const searchParams = new URL(url).searchParams; + // Only let loader reload data if we're not currently editing the map pool + // and persisting it in the search params. + return searchParams.has("readonly"); +}; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; @@ -81,34 +83,43 @@ export const loader = async ({ request }: LoaderArgs) => { }; }; -const DEFAULT_MAP_POOL = new MapPool({ - SZ: [...stageIds], - TC: [...stageIds], - CB: [...stageIds], - RM: [...stageIds], - TW: [], -}); - export default function MapListPage() { const { t } = useTranslation(["common"]); const data = useLoaderData(); const [searchParams] = useSearchParams(); - const { mapPool, handleMapPoolChange } = useSearchParamMapPool(); + const { mapPool, handleMapPoolChange, readonly, switchToEditMode } = + useSearchParamPersistedMapPool(); return (
- {data.calendarEvent && !searchParams.has("pool") && ( -
- {t("common:maps.mapPool")}:{" "} - - {data.calendarEvent.name} - + {searchParams.has("readonly") && data.calendarEvent && ( +
+
+ {t("common:maps.mapPool")}:{" "} + { + + {data.calendarEvent.name} + + } +
+
)} - + {readonly ? ( + + ) : ( + + )} (); const [searchParams, setSearchParams] = useSearchParams(); - const mapPool = (() => { + const initialMapPool = useOnce(() => { if (searchParams.has("pool")) { return new MapPool(searchParams.get("pool")!); } - if (data?.mapPool) { + if (data.mapPool) { return new MapPool(data.mapPool); } - return DEFAULT_MAP_POOL; - })(); + return MapPool.ANARCHY; + }); - const handleMapPoolChange = ({ - mode, - stageId, - }: { - mode: ModeShort; - stageId: StageId; - }) => { - const newMapPool = new MapPool( - mapPool.parsed[mode].includes(stageId) - ? { - ...mapPool.parsed, - [mode]: mapPool.parsed[mode].filter((id) => id !== stageId), - } - : { - ...mapPool.parsed, - [mode]: [...mapPool.parsed[mode], stageId], - } - ); + const [mapPool, setMapPool] = React.useState(initialMapPool); + const handleMapPoolChange = (newMapPool: MapPool) => { + setMapPool(newMapPool); setSearchParams( { pool: newMapPool.serialized, @@ -165,9 +161,20 @@ function useSearchParamMapPool() { ); }; + const switchToEditMode = () => { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.delete("readonly"); + setSearchParams(newSearchParams, { + replace: false, + state: { scroll: false }, + }); + }; + return { mapPool, + readonly: searchParams.has("readonly"), handleMapPoolChange, + switchToEditMode, }; } @@ -192,13 +199,16 @@ function MapListCreator({ mapPool }: { mapPool: MapPool }) { setMapList(list); }; + const disabled = + mapPool.isEmpty() || (szEveryOther && !mapPool.hasMode("SZ")); + return (
- {mapList && ( @@ -206,7 +216,12 @@ function MapListCreator({ mapPool }: { mapPool: MapPool }) {
    {mapList.map(({ mode, stageId }, i) => (
  1. - {t(`game-misc:MODE_SHORT_${mode}`)}{" "} + + {t(`game-misc:MODE_SHORT_${mode}`)} + {" "} {t(`game-misc:STAGE_${stageId}`)}
  2. ))} diff --git a/app/styles/calendar-new.css b/app/styles/calendar-new.css index 291f6adc1..fdf61d77f 100644 --- a/app/styles/calendar-new.css +++ b/app/styles/calendar-new.css @@ -1,5 +1,5 @@ .calendar-new__container { - max-width: 32rem; + max-width: 38rem; } .calendar-new__select { diff --git a/app/styles/maps.css b/app/styles/maps.css index cc680c1d4..5d064d305 100644 --- a/app/styles/maps.css +++ b/app/styles/maps.css @@ -1,9 +1,19 @@ .maps__container { - max-width: 32rem; + max-width: 38rem; +} + +.maps__pool-meta { + display: flex; + align-items: center; + justify-content: space-between; } .maps__pool-info { - font-size: var(--fonts-xxs); + font-size: var(--fonts-xs); + font-weight: var(--bold); +} + +.maps__pool-info a { font-weight: var(--semi-bold); } @@ -49,29 +59,27 @@ .maps__mode-button { padding: 0; - padding: var(--s-1-5); - border: none; - border-radius: var(--rounded); - background-color: var(--bg-darker); + padding: var(--s-1); + border: 2px solid var(--bg-darker); + border-radius: var(--rounded-full); + background-color: transparent; color: var(--theme); opacity: 1 !important; outline: initial; } .maps__mode-button.selected { - background-color: var(--theme-very-transparent); + border: 2px solid transparent; + background-color: var(--bg-mode-active); } .maps__stage-image { border-radius: var(--rounded); } -.maps__mode { - filter: grayscale(100%); -} - -.maps__mode.selected { - filter: unset; +.maps__mode:not(.selected) { + filter: var(--inactive-image-filter); + opacity: 0.6; } .maps__map-list-creator { @@ -96,3 +104,9 @@ font-weight: var(--semi-bold); margin-block-start: var(--s-4); } + +.maps__mode-abbr { + color: var(--text-lighter); + font-weight: var(--bold); + text-decoration: none; +} diff --git a/app/styles/utils.css b/app/styles/utils.css index fef6c85f5..f9b542e43 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -126,6 +126,10 @@ justify-content: center; } +.justify-end { + justify-content: flex-end; +} + .flex-wrap { flex-wrap: wrap; } diff --git a/app/styles/vars.css b/app/styles/vars.css index 71e346065..c626fc365 100644 --- a/app/styles/vars.css +++ b/app/styles/vars.css @@ -7,6 +7,7 @@ html { --bg-darker-transparent: hsla(202deg 90% 90% / 65%); --bg-ability: rgb(3 6 7); --bg-badge: #000; + --bg-mode-active: hsl(255deg 66.7% 50% / 40%); --abilities-button-bg: hsl(237deg 32% 30%); --badge-text: rgb(255 255 255 / 95%); --border: hsl(237deg 100% 86%); @@ -31,6 +32,7 @@ html { --theme-semi-transparent-vibrant: hsl(255deg 100% 81% / 75%); --theme-secondary: hsl(85deg 66.7% 55.3%); --rounded: 16px; + --rounded-full: 200px; --rounded-sm: 10px; --fonts-xl: 1.5rem; --fonts-lg: 1.2rem; @@ -73,6 +75,7 @@ html { --s-96: 2rem; --sparse: 0.4px; --label-margin: var(--s-1); + --inactive-image-filter: grayscale(100%) brightness(30%); } html.dark { @@ -84,6 +87,7 @@ html.dark { --bg-darker-transparent: hsla(237.3deg 42.3% 26.6% / 90%); --bg-ability: rgb(17 19 43); --bg-badge: #000; + --bg-mode-active: var(--theme-transparent); --abilities-button-bg: hsl(237.3deg 42.3% 26.6%); --border: hsl(237.3deg 42.3% 45.6%); --button-text: rgb(0 0 0 / 85%); @@ -104,6 +108,7 @@ html.dark { --theme-transparent-vibrant: hsl(255deg 78% 65% / 54%); --theme-semi-transparent-vibrant: hsl(255deg 78% 65% / 75%); --theme-secondary: hsl(85deg 66.7% 55.3%); + --inactive-image-filter: grayscale(100%) brightness(130%); } html.dark .light-mode-only { diff --git a/app/utils/urls.ts b/app/utils/urls.ts index bef1ce4dd..bb77c5894 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -1,5 +1,11 @@ import slugify from "slugify"; -import type { Badge, GearType, MapPoolMap, User } from "~/db/types"; +import type { + Badge, + CalendarEvent, + GearType, + MapPoolMap, + User, +} from "~/db/types"; import type { ModeShort, weaponCategories } from "~/modules/in-game-lists"; import type { Ability, @@ -74,6 +80,8 @@ export const calendarReportWinnersPage = (eventId: number) => `/calendar/${eventId}/report-winners`; export const mapsPage = (eventId?: MapPoolMap["calendarEventId"]) => `/maps${eventId ? `?eventId=${eventId}` : ""}`; +export const readonlyMapsPage = (eventId: CalendarEvent["id"]) => + `/maps?readonly&eventId=${eventId}`; export const articlePage = (slug: string) => `/a/${slug}`; export const analyzerPage = (args?: { weaponId: MainWeaponId; diff --git a/public/locales/de/common.json b/public/locales/de/common.json index af292c7b5..7820cc344 100644 --- a/public/locales/de/common.json +++ b/public/locales/de/common.json @@ -34,6 +34,8 @@ "actions.delete": "Löschen", "actions.loadMore": "Mehr laden", "actions.close": "Schließen", + "actions.clear": "Leeren", + "actions.selectAll": "Alle auswählen", "maps.createMapList": "Arenen-Liste erstellen", "maps.halfSz": "50% Herrschaft", diff --git a/public/locales/de/game-misc.json b/public/locales/de/game-misc.json index 4e3c81ce7..d9e393f1c 100644 --- a/public/locales/de/game-misc.json +++ b/public/locales/de/game-misc.json @@ -15,5 +15,10 @@ "MODE_SHORT_SZ": "HS", "MODE_SHORT_TC": "TK", "MODE_SHORT_RM": "OG", - "MODE_SHORT_CB": "MC" + "MODE_SHORT_CB": "MC", + "MODE_LONG_TW": "Revierkampf", + "MODE_LONG_SZ": "Herrschaft", + "MODE_LONG_TC": "Turmkommando", + "MODE_LONG_RM": "Operation Goldfisch", + "MODE_LONG_CB": "Muschelchaos" } diff --git a/public/locales/en/common.json b/public/locales/en/common.json index db52a437d..c9f7a376e 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -36,6 +36,8 @@ "actions.loadMore": "Load more", "actions.copyToClipboard": "Copy to clipboard", "actions.close": "Close", + "actions.clear": "Clear", + "actions.selectAll": "Select All", "maps.createMapList": "Create map list", "maps.halfSz": "50% SZ", diff --git a/public/locales/en/game-misc.json b/public/locales/en/game-misc.json index be8229456..780d33e38 100644 --- a/public/locales/en/game-misc.json +++ b/public/locales/en/game-misc.json @@ -15,5 +15,10 @@ "MODE_SHORT_SZ": "SZ", "MODE_SHORT_TC": "TC", "MODE_SHORT_RM": "RM", - "MODE_SHORT_CB": "CB" + "MODE_SHORT_CB": "CB", + "MODE_LONG_TW": "Turf War", + "MODE_LONG_SZ": "Splat Zones", + "MODE_LONG_TC": "Tower Control", + "MODE_LONG_RM": "Rainmaker", + "MODE_LONG_CB": "Clam Blitz" }