diff --git a/app/components/AbilitiesSelector.tsx b/app/components/AbilitiesSelector.tsx index 499978883..6c93ea3dc 100644 --- a/app/components/AbilitiesSelector.tsx +++ b/app/components/AbilitiesSelector.tsx @@ -104,7 +104,7 @@ export function AbilitiesSelector({ })} type="button" onClick={() => onButtonClick(ability)} - data-cy={`${ability.name}-ability-button`} + data-testid={`${ability.name}-ability-button`} draggable="true" onDragStart={onDragStart(ability)} onDragEnd={onDragEnd} diff --git a/app/components/Ability.tsx b/app/components/Ability.tsx index 1f382adba..2680d0646 100644 --- a/app/components/Ability.tsx +++ b/app/components/Ability.tsx @@ -63,7 +63,7 @@ export function Ability({ } as any } onClick={onClick} - data-cy={`${ability}-ability`} + data-testid={`${ability}-ability`} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={(event) => { diff --git a/app/features/build-analyzer/analyzer-constants.ts b/app/features/build-analyzer/analyzer-constants.ts index 8b0be70ae..0edf035e3 100644 --- a/app/features/build-analyzer/analyzer-constants.ts +++ b/app/features/build-analyzer/analyzer-constants.ts @@ -61,3 +61,5 @@ export const multiShot: Partial> = { }; export const RAINMAKER_SPEED_PENALTY_MODIFIER = 0.8; + +export const UNKNOWN_SHORT = "U"; diff --git a/app/features/build-analyzer/analyzer-hooks.ts b/app/features/build-analyzer/analyzer-hooks.ts index db4aea0c7..a012e403e 100644 --- a/app/features/build-analyzer/analyzer-hooks.ts +++ b/app/features/build-analyzer/analyzer-hooks.ts @@ -1,10 +1,8 @@ import { useSearchParams } from "@remix-run/react"; -import { EMPTY_BUILD } from "~/constants"; import { type BuildAbilitiesTupleWithUnknown, type MainWeaponId, type Ability, - type AbilityType, type AbilityWithUnknown, abilities, isAbility, @@ -15,11 +13,11 @@ import { buildStats } from "./core/stats"; import type { SpecialEffectType } from "./analyzer-types"; import { buildToAbilityPoints, + serializeBuild, + validatedBuildFromSearchParams, validatedWeaponIdFromSearchParams, } from "./core/utils"; -const UNKNOWN_SHORT = "U"; - export function useAnalyzeBuild() { const [searchParams, setSearchParams] = useSearchParams(); @@ -116,64 +114,6 @@ function filterMainOnlyAbilities( return Boolean(abilityObj && abilityObj.type !== "STACKABLE"); } -function serializeBuild(build: BuildAbilitiesTupleWithUnknown) { - return build - .flat() - .map((ability) => (ability === "UNKNOWN" ? UNKNOWN_SHORT : ability)) - .join(","); -} - -function validatedBuildFromSearchParams( - searchParams: URLSearchParams, - key = "build" -): BuildAbilitiesTupleWithUnknown { - const abilitiesArr = searchParams.get(key) - ? searchParams.get(key)?.split(",") - : null; - - if (!abilitiesArr) return EMPTY_BUILD; - - try { - return [ - [ - validateAbility(["STACKABLE", "HEAD_MAIN_ONLY"], abilitiesArr[0]), - validateAbility(["STACKABLE"], abilitiesArr[1]), - validateAbility(["STACKABLE"], abilitiesArr[2]), - validateAbility(["STACKABLE"], abilitiesArr[3]), - ], - [ - validateAbility(["STACKABLE", "CLOTHES_MAIN_ONLY"], abilitiesArr[4]), - validateAbility(["STACKABLE"], abilitiesArr[5]), - validateAbility(["STACKABLE"], abilitiesArr[6]), - validateAbility(["STACKABLE"], abilitiesArr[7]), - ], - [ - validateAbility(["STACKABLE", "SHOES_MAIN_ONLY"], abilitiesArr[8]), - validateAbility(["STACKABLE"], abilitiesArr[9]), - validateAbility(["STACKABLE"], abilitiesArr[10]), - validateAbility(["STACKABLE"], abilitiesArr[11]), - ], - ]; - } catch (err) { - return EMPTY_BUILD; - } -} - -function validateAbility( - legalTypes: Array, - ability?: string -): AbilityWithUnknown { - if (!ability) throw new Error("Ability missing"); - if (ability === UNKNOWN_SHORT) return "UNKNOWN"; - - const abilityObj = abilities.find( - (a) => a.name === ability && legalTypes.includes(a.type) - ); - if (abilityObj) return abilityObj.name; - - throw new Error("Invalid ability"); -} - function validatedLdeIntensityFromSearchParams(searchParams: URLSearchParams) { const ldeIntensity = searchParams.get("lde") ? Number(searchParams.get("lde")) diff --git a/app/features/build-analyzer/core/stats.ts b/app/features/build-analyzer/core/stats.ts index a2b2c5f83..2aa6dbdd1 100644 --- a/app/features/build-analyzer/core/stats.ts +++ b/app/features/build-analyzer/core/stats.ts @@ -626,7 +626,7 @@ function respawnTime( abilityPoints: args.abilityPoints, ability: QUICK_RESPAWN_TIME_ABILITY, }); - const abilityPoints = hasRespawnPunisher + const abilityPoints = splattedByRP ? qrApAfterRespawnPunish({ ap, hasTacticooler: args.hasTacticooler, diff --git a/app/features/build-analyzer/core/utils.ts b/app/features/build-analyzer/core/utils.ts index 2079c48e4..d9bb76850 100644 --- a/app/features/build-analyzer/core/utils.ts +++ b/app/features/build-analyzer/core/utils.ts @@ -1,3 +1,4 @@ +import type { AbilityType } from "~/modules/in-game-lists"; import { abilities, mainWeaponIds, @@ -17,6 +18,8 @@ import type { SubWeaponParams, } from "../analyzer-types"; import invariant from "tiny-invariant"; +import { EMPTY_BUILD } from "~/constants"; +import { UNKNOWN_SHORT } from "../analyzer-constants"; export function weaponParams(): ParamsJson { return weaponParamsJson as ParamsJson; @@ -171,6 +174,64 @@ export function validatedWeaponIdFromSearchParams( return weaponCategories[0].weaponIds[0]; } +function validateAbility( + legalTypes: Array, + ability?: string +): AbilityWithUnknown { + if (!ability) throw new Error("Ability missing"); + if (ability === UNKNOWN_SHORT) return "UNKNOWN"; + + const abilityObj = abilities.find( + (a) => a.name === ability && legalTypes.includes(a.type) + ); + if (abilityObj) return abilityObj.name; + + throw new Error("Invalid ability"); +} + +export function validatedBuildFromSearchParams( + searchParams: URLSearchParams, + key = "build" +): BuildAbilitiesTupleWithUnknown { + const abilitiesArr = searchParams.get(key) + ? searchParams.get(key)?.split(",") + : null; + + if (!abilitiesArr) return EMPTY_BUILD; + + try { + return [ + [ + validateAbility(["STACKABLE", "HEAD_MAIN_ONLY"], abilitiesArr[0]), + validateAbility(["STACKABLE"], abilitiesArr[1]), + validateAbility(["STACKABLE"], abilitiesArr[2]), + validateAbility(["STACKABLE"], abilitiesArr[3]), + ], + [ + validateAbility(["STACKABLE", "CLOTHES_MAIN_ONLY"], abilitiesArr[4]), + validateAbility(["STACKABLE"], abilitiesArr[5]), + validateAbility(["STACKABLE"], abilitiesArr[6]), + validateAbility(["STACKABLE"], abilitiesArr[7]), + ], + [ + validateAbility(["STACKABLE", "SHOES_MAIN_ONLY"], abilitiesArr[8]), + validateAbility(["STACKABLE"], abilitiesArr[9]), + validateAbility(["STACKABLE"], abilitiesArr[10]), + validateAbility(["STACKABLE"], abilitiesArr[11]), + ], + ]; + } catch (err) { + return EMPTY_BUILD; + } +} + +export function serializeBuild(build: BuildAbilitiesTupleWithUnknown) { + return build + .flat() + .map((ability) => (ability === "UNKNOWN" ? UNKNOWN_SHORT : ability)) + .join(","); +} + export const hpDivided = (hp: number) => hp / 10; export function possibleApValues() { @@ -184,3 +245,6 @@ export function possibleApValues() { return Array.from(uniqueValues).sort((a, b) => a - b); } + +export const buildIsEmpty = (build: BuildAbilitiesTupleWithUnknown) => + build.flat().every((ability) => ability === "UNKNOWN"); diff --git a/app/features/build-analyzer/index.ts b/app/features/build-analyzer/index.ts index dce956a62..566eefc67 100644 --- a/app/features/build-analyzer/index.ts +++ b/app/features/build-analyzer/index.ts @@ -1,6 +1,8 @@ export { possibleApValues, validatedWeaponIdFromSearchParams, + validatedBuildFromSearchParams, + serializeBuild, hpDivided, } from "./core/utils"; export type { diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx index c634dcbc1..f51cebe28 100644 --- a/app/features/build-analyzer/routes/analyzer.tsx +++ b/app/features/build-analyzer/routes/analyzer.tsx @@ -1,5 +1,5 @@ import { type LinksFunction, type MetaFunction } from "@remix-run/node"; -import type { ShouldReloadFunction } from "@remix-run/react"; +import type { ShouldRevalidateFunction } from "@remix-run/react"; import { Link } from "@remix-run/react"; import * as React from "react"; import { useTranslation } from "~/hooks/useTranslation"; @@ -37,6 +37,7 @@ import { objectDamageCalculatorPage, specialWeaponImageUrl, subWeaponImageUrl, + userNewBuildPage, } from "~/utils/urls"; import clsx from "clsx"; import { @@ -59,7 +60,8 @@ import { } from "../analyzer-constants"; import { useAnalyzeBuild } from "../analyzer-hooks"; import { Tabs, Tab } from "~/components/Tabs"; -import { isStackableAbility } from "../core/utils"; +import { buildIsEmpty, isStackableAbility } from "../core/utils"; +import { useUser } from "~/modules/auth"; export const CURRENT_PATCH = "2.1"; @@ -83,9 +85,10 @@ export const handle: SendouRouteHandle = { }; // Resolves this Github issue: https://github.com/Sendouc/sendou.ink/issues/1053 -export const unstable_shouldReload: ShouldReloadFunction = () => false; +export const shouldRevalidate: ShouldRevalidateFunction = () => false; export default function BuildAnalyzerPage() { + const user = useUser(); const { t } = useTranslation(["analyzer", "common", "weapons"]); useSetTitle(t("common:pages.analyzer")); const { @@ -107,11 +110,9 @@ export default function BuildAnalyzerPage() { return [analyzed.stats[key], analyzed2.stats[key]] as [Stat, Stat]; }; - const objectShredderSelected = build[2][0] === "OS"; + const objectShredderSelected = build[2][0] === "OS" || build2[2][0] === "OS"; - const isComparing = - build.flat().some((ability) => ability !== "UNKNOWN") && - build2.flat().some((ability) => ability !== "UNKNOWN"); + const isComparing = !buildIsEmpty(build) && !buildIsEmpty(build2); const mainWeaponCategoryItems = [ analyzed.stats.shotSpreadAir && ( @@ -736,7 +737,10 @@ export default function BuildAnalyzerPage() { )} - + )} + {user && focusedBuild && !buildIsEmpty(focusedBuild) ? ( + + + {t("analyzer:newBuildPrompt")} + + ) : null} @@ -1082,16 +1105,18 @@ function StatCategory({ containerClassName = "analyzer__stat-collection", textBelow, summaryRightContent, + testId, }: { title: string; children: React.ReactNode; containerClassName?: string; textBelow?: string; summaryRightContent?: React.ReactNode; + testId?: string; }) { return (
- + {title} {summaryRightContent} @@ -1110,6 +1135,7 @@ function StatCard({ popoverInfo, abilityPoints, isComparing, + testId, }: { title: string; stat: [Stat, Stat] | [Stat, Stat] | number | string; @@ -1117,6 +1143,7 @@ function StatCard({ popoverInfo?: string; abilityPoints: AbilityPoints; isComparing: boolean; + testId?: string; }) { const { t } = useTranslation("analyzer"); @@ -1177,14 +1204,20 @@ function StatCard({ ? t("build1") : t("base")} {" "} -
+
{showComparison ? (stat as [Stat, Stat])[0].value : baseValue} {suffix}
{showBuildValue() ? (
-

+

{showComparison ? t("build2") : t("build")}

{" "}
diff --git a/app/routes/u.$identifier/builds/new.tsx b/app/routes/u.$identifier/builds/new.tsx index aec5ac2b2..e1761af61 100644 --- a/app/routes/u.$identifier/builds/new.tsx +++ b/app/routes/u.$identifier/builds/new.tsx @@ -4,7 +4,7 @@ import { type ActionFunction, type LoaderArgs, } from "@remix-run/node"; -import { Form, useLoaderData } from "@remix-run/react"; +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; import * as React from "react"; import { z } from "zod"; import { AbilitiesSelector } from "~/components/AbilitiesSelector"; @@ -14,9 +14,13 @@ import { Image } from "~/components/Image"; import { Label } from "~/components/Label"; import { RequiredHiddenInput } from "~/components/RequiredHiddenInput"; import { SubmitButton } from "~/components/SubmitButton"; -import { BUILD, EMPTY_BUILD } from "~/constants"; +import { BUILD } from "~/constants"; import { db } from "~/db"; import type { GearType } from "~/db/types"; +import { + validatedBuildFromSearchParams, + validatedWeaponIdFromSearchParams, +} from "~/features/build-analyzer"; import { useTranslation } from "~/hooks/useTranslation"; import { requireUser } from "~/modules/auth"; import { requireUserId } from "~/modules/auth/user.server"; @@ -176,13 +180,16 @@ export const loader = async ({ request }: LoaderArgs) => { Object.fromEntries(url.searchParams) ); - if (!params.success || params.data.userId !== user.id) + if (!params.success || params.data.userId !== user.id) { return json({ buildToEdit: null }); + } const usersBuilds = db.builds.buildsByUserId(params.data.userId); const buildToEdit = usersBuilds.find((b) => b.id === params.data.buildId); - return json({ buildToEdit }); + return json({ + buildToEdit, + }); }; export default function NewBuildPage() { @@ -298,6 +305,7 @@ function ModeCheckboxes() { } function WeaponsSelector() { + const [searchParams] = useSearchParams(); const { buildToEdit } = useLoaderData(); const { t } = useTranslation(["common", "weapons", "builds"]); const [count, setCount] = React.useState(buildToEdit?.weapons.length ?? 1); @@ -316,7 +324,10 @@ function WeaponsSelector() { inputName="weapon" id="weapon" required - initialWeaponId={buildToEdit?.weapons[i]} + initialWeaponId={ + buildToEdit?.weapons[i] ?? + validatedWeaponIdFromSearchParams(searchParams) + } />
{i === count - 1 && ( @@ -378,10 +389,11 @@ function GearSelector({ type }: { type: GearType }) { } function Abilities() { + const [searchParams] = useSearchParams(); const { buildToEdit } = useLoaderData(); const [abilities, setAbilities] = React.useState( - buildToEdit?.abilities ?? EMPTY_BUILD + buildToEdit?.abilities ?? validatedBuildFromSearchParams(searchParams) ); return ( diff --git a/app/styles/common.css b/app/styles/common.css index 84eda2164..e161822ee 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -991,13 +991,13 @@ dialog::backdrop { } .playwire__img { - width: 200px; - margin-left: auto; - margin-right: auto; display: block; + width: 200px; + margin-right: auto; + margin-left: auto; } .playwire__text { - text-align: center; font-size: var(--fonts-sm); + text-align: center; } diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 89056cd17..3dbd5e8d3 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -14,11 +14,13 @@ import type { SpecialWeaponId, SubWeaponId, StageId, + BuildAbilitiesTupleWithUnknown, } from "~/modules/in-game-lists/types"; import type navItems from "~/components/layout/nav-items.json"; import { type AuthErrorCode } from "~/modules/auth"; import type { StageBackgroundStyle } from "~/features/map-planner"; import type { ImageUploadType } from "~/features/img-upload"; +import { serializeBuild } from "~/features/build-analyzer"; const staticAssetsUrl = ({ folder, @@ -114,8 +116,20 @@ export const userResultsPage = (user: UserLinkArgs) => `${userPage(user)}/results`; export const userResultsEditHighlightsPage = (user: UserLinkArgs) => `${userResultsPage(user)}/highlights`; -export const userNewBuildPage = (user: UserLinkArgs) => - `${userBuildsPage(user)}/new`; +export const userNewBuildPage = ( + user: UserLinkArgs, + params?: { weapon: MainWeaponId; build: BuildAbilitiesTupleWithUnknown } +) => + `${userBuildsPage(user)}/new${ + params + ? `?${String( + new URLSearchParams({ + weapon: String(params.weapon), + build: serializeBuild(params.build), + }) + )}` + : "" + }`; export const teamPage = (customUrl: string) => `/t/${customUrl}`; export const editTeamPage = (customUrl: string) => diff --git a/e2e/analyzer.spec.ts b/e2e/analyzer.spec.ts new file mode 100644 index 000000000..6399f8016 --- /dev/null +++ b/e2e/analyzer.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from "@playwright/test"; +import { + impersonate, + navigate, + seed, + isNotVisible, + selectWeapon, +} from "~/utils/playwright"; +import { ANALYZER_URL } from "~/utils/urls"; + +test.describe("Build Analyzer", () => { + test("analyzes a build and links to new build page with same abilities", async ({ + page, + }) => { + await seed(page); + await impersonate(page, 1); + await navigate({ page, url: ANALYZER_URL }); + + const newBuildPrompt = page.getByTestId("new-build-prompt"); + + await isNotVisible(newBuildPrompt); + + await selectWeapon({ page, name: "Splattershot" }); + + await page.getByTestId("movement-category").click(); + + const swimSpeedBase = page.getByTestId("swim-speed-base"); + const swimSpeedSplattershot = (await swimSpeedBase.textContent())!; + + await selectWeapon({ page, name: "Luna Blaster" }); + + // Luna Blaster is a light weapon so it should have lower base swim speed than Splattershot + await expect(swimSpeedBase).not.toHaveText(swimSpeedSplattershot); + + // shows comparison value when you have relevant abilities selected + const swimSpeedBuildValueTitle = page.getByTestId("swim-speed-build-title"); + await isNotVisible(swimSpeedBuildValueTitle); + await page.getByTestId("SSU-ability-button").click(); + await swimSpeedBuildValueTitle.isVisible(); + + // on new build page with preselected values + await newBuildPrompt.click(); + await expect(page).toHaveURL(/new/); + await expect(page.getByTestId("weapon-combobox-input")).toHaveValue( + "Luna Blaster" + ); + await page.getByTestId("SSU-ability").isVisible(); + }); +}); diff --git a/public/locales/en/analyzer.json b/public/locales/en/analyzer.json index 937abb4b3..d2dbe79b5 100644 --- a/public/locales/en/analyzer.json +++ b/public/locales/en/analyzer.json @@ -4,6 +4,7 @@ "attribute.weight.Slow": "Heavy", "attribute.weight.Normal": "Normal", "objCalcAd": "For info on Object Shredder check out Object DMG Calc", + "newBuildPrompt": "Add a new build with the selected abilities", "stat.category.main": "Main weapon", "stat.category.sub": "Sub weapon", "stat.category.special": "Special weapon",