Build Analyzer: Create new build prompt

This commit is contained in:
Kalle 2023-01-28 15:31:07 +02:00
parent cc7d0bfc7c
commit 27cb5be472
13 changed files with 205 additions and 88 deletions

View File

@ -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}

View File

@ -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) => {

View File

@ -61,3 +61,5 @@ export const multiShot: Partial<Record<MainWeaponId, number>> = {
};
export const RAINMAKER_SPEED_PENALTY_MODIFIER = 0.8;
export const UNKNOWN_SHORT = "U";

View File

@ -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<AbilityType>,
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"))

View File

@ -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,

View File

@ -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<AbilityType>,
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");

View File

@ -1,6 +1,8 @@
export {
possibleApValues,
validatedWeaponIdFromSearchParams,
validatedBuildFromSearchParams,
serializeBuild,
hpDivided,
} from "./core/utils";
export type {

View File

@ -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() {
</StatCategory>
)}
<StatCategory title={t("analyzer:stat.category.movement")}>
<StatCategory
title={t("analyzer:stat.category.movement")}
testId="movement-category"
>
<StatCard
isComparing={isComparing}
title={t("analyzer:attribute.weight")}
@ -748,6 +752,7 @@ export default function BuildAnalyzerPage() {
abilityPoints={abilityPoints}
stat={statKeyToTuple("swimSpeed")}
title={t("analyzer:stat.swimSpeed")}
testId="swim-speed"
/>
<StatCard
isComparing={isComparing}
@ -876,6 +881,24 @@ export default function BuildAnalyzerPage() {
{t("analyzer:objCalcAd")}
</Link>
)}
{user && focusedBuild && !buildIsEmpty(focusedBuild) ? (
<Link
className="analyzer__noticeable-link"
to={userNewBuildPage(user, {
weapon: mainWeaponId,
build: focusedBuild,
})}
data-testid="new-build-prompt"
>
<Image
path={navIconUrl("builds")}
width={24}
height={24}
alt=""
/>
{t("analyzer:newBuildPrompt")}
</Link>
) : null}
</div>
</div>
</Main>
@ -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 (
<details className="analyzer__details">
<summary className="analyzer__summary">
<summary className="analyzer__summary" data-testid={testId}>
{title}
{summaryRightContent}
</summary>
@ -1110,6 +1135,7 @@ function StatCard({
popoverInfo,
abilityPoints,
isComparing,
testId,
}: {
title: string;
stat: [Stat, Stat] | [Stat<string>, Stat<string>] | 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")}
</h4>{" "}
<div className="analyzer__stat-card__value__number">
<div
className="analyzer__stat-card__value__number"
data-testid={testId ? `${testId}-base` : undefined}
>
{showComparison ? (stat as [Stat, Stat])[0].value : baseValue}
{suffix}
</div>
</div>
{showBuildValue() ? (
<div className="analyzer__stat-card__value">
<h4 className="analyzer__stat-card__value__title">
<h4
className="analyzer__stat-card__value__title"
data-testid={testId ? `${testId}-build-title` : undefined}
>
{showComparison ? t("build2") : t("build")}
</h4>{" "}
<div className="analyzer__stat-card__value__number">

View File

@ -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<typeof loader>();
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)
}
/>
</div>
{i === count - 1 && (
@ -378,10 +389,11 @@ function GearSelector({ type }: { type: GearType }) {
}
function Abilities() {
const [searchParams] = useSearchParams();
const { buildToEdit } = useLoaderData<typeof loader>();
const [abilities, setAbilities] =
React.useState<BuildAbilitiesTupleWithUnknown>(
buildToEdit?.abilities ?? EMPTY_BUILD
buildToEdit?.abilities ?? validatedBuildFromSearchParams(searchParams)
);
return (

View File

@ -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;
}

View File

@ -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) =>

49
e2e/analyzer.spec.ts Normal file
View File

@ -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();
});
});

View File

@ -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",