import { type LinksFunction, type V2_MetaFunction } from "@remix-run/node";
import type { ShouldRevalidateFunction } from "@remix-run/react";
import { Link } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "~/hooks/useTranslation";
import { AbilitiesSelector } from "~/components/AbilitiesSelector";
import { Ability } from "~/components/Ability";
import { WeaponCombobox } from "~/components/Combobox";
import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
import { Popover } from "~/components/Popover";
import { Toggle } from "~/components/Toggle";
import { useSetTitle } from "~/hooks/useSetTitle";
import {
ANGLE_SHOOTER_ID,
INK_MINE_ID,
INK_STORM_ID,
isAbility,
POINT_SENSOR_ID,
TOXIC_MIST_ID,
TORPEDO_ID,
type BuildAbilitiesTupleWithUnknown,
type MainWeaponId,
type SubWeaponId,
abilitiesShort,
} from "~/modules/in-game-lists";
import styles from "../analyzer.css";
import { damageTypeTranslationString } from "~/utils/i18next";
import { type SendouRouteHandle } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import {
ANALYZER_URL,
mainWeaponImageUrl,
navIconUrl,
objectDamageCalculatorPage,
specialWeaponImageUrl,
subWeaponImageUrl,
userNewBuildPage,
} from "~/utils/urls";
import clsx from "clsx";
import {
ABILITIES_WITHOUT_CHUNKS,
getAbilityChunksMapAsArray,
} from "../core/abilityChunksCalc";
import type {
AbilityPoints,
AnalyzedBuild,
SpecialEffectType,
Stat,
} from "../analyzer-types";
import {
lastDitchEffortIntensityToAp,
SPECIAL_EFFECTS,
} from "../core/specialEffects";
import {
damageTypeToWeaponType,
MAX_LDE_INTENSITY,
} from "../analyzer-constants";
import { useAnalyzeBuild } from "../analyzer-hooks";
import { Tabs, Tab } from "~/components/Tabs";
import {
buildIsEmpty,
damageIsSubWeaponDamage,
isStackableAbility,
} from "../core/utils";
import { useUser } from "~/modules/auth";
import { atOrError } from "~/utils/arrays";
export const CURRENT_PATCH = "3.1";
export const meta: V2_MetaFunction = () => {
return [
{ title: makeTitle("Build Analyzer") },
{
name: "description",
content: "Detailed stats for any weapon and build in Splatoon 3.",
},
];
};
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const handle: SendouRouteHandle = {
i18n: ["weapons", "analyzer"],
breadcrumb: () => ({
imgPath: navIconUrl("analyzer"),
href: ANALYZER_URL,
type: "IMAGE",
}),
};
// Resolves this Github issue: https://github.com/Sendouc/sendou.ink/issues/1053
export const shouldRevalidate: ShouldRevalidateFunction = () => false;
export default function BuildAnalyzerPage() {
const user = useUser();
const { t } = useTranslation(["analyzer", "common", "weapons"]);
useSetTitle(t("common:pages.analyzer"));
const {
build,
build2,
focusedBuild,
mainWeaponId,
handleChange,
analyzed,
analyzed2,
focused,
abilityPoints,
abilityPoints2,
ldeIntensity,
allEffects,
} = useAnalyzeBuild();
const statKeyToTuple = (key: keyof AnalyzedBuild["stats"]) => {
return [analyzed.stats[key], analyzed2.stats[key]] as [Stat, Stat];
};
const objectShredderSelected = build[2][0] === "OS" || build2[2][0] === "OS";
const isComparing = !buildIsEmpty(build) && !buildIsEmpty(build2);
const mainWeaponCategoryItems = [
analyzed.stats.shotSpreadAir && (
),
typeof analyzed.stats.shotSpreadGround === "number" && (
),
typeof analyzed.stats.mainWeaponWhiteInkSeconds === "number" && (
),
typeof analyzed.weapon.brellaCanopyHp === "number" && (
),
typeof analyzed.weapon.fullChargeSeconds === "number" && (
),
typeof analyzed.weapon.maxChargeHoldSeconds === "number" && (
),
].filter(Boolean);
// Handles edge case where a primary slot-only ability (e.g. Ninja Squid) is selected & the 'abilityPoints' count is still 0,
// and also fixes an edge case with Ability Doubler as the only ability in the build
const showAbilityChunksRequired: boolean = build.some(
(gear) =>
gear.filter((ability) => !ABILITIES_WITHOUT_CHUNKS.has(ability)).length
);
return (
opt &&
handleChange({
newMainWeaponId: Number(opt.value) as MainWeaponId,
})
}
fullWidth
/>
handleChange({ newFocused: 1 })}
testId="build1-tab"
>
{t("analyzer:build1")}
handleChange({ newFocused: 2 })}
testId="build2-tab"
>
{t("analyzer:build2")}
handleChange({ newFocused: 3 })}
testId="ap-tab"
>
{t("analyzer:compare")}
{focusedBuild ? (
{
const firstBuildIsEmpty = build
.flat()
.every((ability) => ability === "UNKNOWN");
const buildWasEmptied =
!firstBuildIsEmpty &&
newBuild
.flat()
.every((ability) => ability === "UNKNOWN") &&
focused === 1;
// if we don't do this the
// build2 would be duplicated
if (buildWasEmptied) {
handleChange({
newBuild: build2,
newBuild2: newBuild,
newFocused: 1,
});
return;
}
handleChange({
[focused === 1 || firstBuildIsEmpty
? "newBuild"
: "newBuild2"]: newBuild,
newFocused: firstBuildIsEmpty ? 1 : undefined,
});
}}
/>
) : (
)}
handleChange({ newLdeIntensity })
}
handleAddEffect={(newEffect) =>
handleChange({ newEffects: [...allEffects, newEffect] })
}
handleRemoveEffect={(effectToRemove) =>
handleChange({
newEffects: allEffects.filter((e) => e !== effectToRemove),
})
}
effects={allEffects}
/>
{showAbilityChunksRequired && (
)}
{t("analyzer:patch")} {CURRENT_PATCH}
{mainWeaponCategoryItems.length > 0 && (
{t(`weapons:MAIN_${mainWeaponId}`)}
}
>
{mainWeaponCategoryItems}
)}
{t(`weapons:SUB_${analyzed.weapon.subWeaponSplId}`)}
}
>
{analyzed.stats.subVelocity && (
)}
{analyzed.stats.subFirstPhaseDuration && (
)}
{analyzed.stats.subSecondPhaseDuration && (
)}
{analyzed.stats.subMarkingTimeInSeconds && (
)}
{analyzed.stats.subMarkingRadius && (
)}
{analyzed.stats.subExplosionRadius && (
)}
{analyzed.stats.subHp && (
)}
{analyzed.stats.subQsjBoost && (
)}
{t(`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`)}
}
>
{analyzed.stats.specialDurationInSeconds && (
)}
{analyzed.stats.specialDamageDistance && (
)}
{analyzed.stats.specialPaintRadius && (
)}
{analyzed.stats.specialFieldHp && (
)}
{analyzed.stats.specialDeviceHp && (
)}
{analyzed.stats.specialHookInkConsumptionPercentage && (
)}
{analyzed.stats.specialInkConsumptionPerSecondPercentage && (
)}
{analyzed.stats.specialReticleRadius && (
)}
{analyzed.stats.specialThrowDistance && (
)}
{analyzed.stats.specialAutoChargeRate && (
)}
{analyzed.stats.specialMaxRadius && (
)}
{analyzed.stats.specialRadiusRange && (
)}
{analyzed.stats.specialPowerUpDuration && (
)}
{analyzed.stats.subWeaponDefenseDamages.length > 0 && (
{(["SRU"] as const).some(
(ability) => (abilityPoints.get(ability) ?? 0) > 0
) ? (
) : null}
)}
{analyzed.stats.damages.length > 0 && (
)}
{analyzed.stats.fullInkTankOptions.length > 0 && (
{(["ISM", "ISS"] as const).some(
(ability) => (abilityPoints.get(ability) ?? 0) > 0
) ? (
) : null}
)}
{analyzed.stats.shootingRunSpeed && (
)}
{analyzed.stats.shootingRunSpeedCharging && (
)}
{analyzed.stats.shootingRunSpeedFullCharge && (
)}
{objectShredderSelected && (
{t("analyzer:objCalcAd")}
)}
{user && focusedBuild && !buildIsEmpty(focusedBuild) ? (
{t("analyzer:newBuildPrompt")}
) : null}
);
}
function APCompare({
abilityPoints,
abilityPoints2,
build,
build2,
}: {
abilityPoints: AbilityPoints;
abilityPoints2: AbilityPoints;
build: BuildAbilitiesTupleWithUnknown;
build2: BuildAbilitiesTupleWithUnknown;
}) {
const { t } = useTranslation(["analyzer"]);
const buildMains = build
.flat()
.filter((ability) => !isStackableAbility(ability) && ability !== "UNKNOWN");
const build2Mains = build2
.flat()
.filter((ability) => !isStackableAbility(ability) && ability !== "UNKNOWN");
const hasAtLeastOneMainOnlyAbility =
buildMains.length > 0 || build2Mains.length > 0;
return (
{hasAtLeastOneMainOnlyAbility ? (
<>
{buildMains.map((ability) => (
))}
{build2Mains.map((ability) => (
))}
>
) : null}
{([...abilitiesShort, "UNKNOWN"] as const).map((ability) => {
const ap = abilityPoints.get(ability) ?? 0;
const ap2 = abilityPoints2.get(ability) ?? 0;
if (!ap && !ap2) return null;
return (
{ap}
{t("analyzer:abilityPoints.short")}
= ap2,
})}
style={{ width: `${ap}px` }}
/>
{ap2}
{t("analyzer:abilityPoints.short")}
);
})}
);
}
function EffectsSelector({
build,
build2,
effects,
ldeIntensity,
handleLdeIntensityChange,
handleAddEffect,
handleRemoveEffect,
}: {
build: BuildAbilitiesTupleWithUnknown;
build2: BuildAbilitiesTupleWithUnknown;
effects: Array;
ldeIntensity: number;
handleLdeIntensityChange: (newLdeIntensity: number) => void;
handleAddEffect: (effect: SpecialEffectType) => void;
handleRemoveEffect: (effect: SpecialEffectType) => void;
}) {
const { t } = useTranslation(["weapons", "analyzer"]);
const effectsToShow = SPECIAL_EFFECTS.filter(
(effect) =>
!isAbility(effect.type) ||
build.flat().includes(effect.type) ||
build2.flat().includes(effect.type)
).reverse(); // reverse to show Tacticooler first as it always shows
return (
{effectsToShow.map((effect) => {
return (
{isAbility(effect.type) ? (
) : (
)}
{effect.type === "LDE" ? (
handleLdeIntensityChange(Number(e.target.value))
}
className="analyzer__lde-intensity-select"
>
{new Array(MAX_LDE_INTENSITY + 1).fill(null).map((_, i) => {
const percentage = ((i / MAX_LDE_INTENSITY) * 100)
.toFixed(2)
.replace(".00", "");
return (
{percentage}% (+{lastDitchEffortIntensityToAp(i)}{" "}
{t("analyzer:abilityPoints.short")})
);
})}
) : (
checked
? handleAddEffect(effect.type)
: handleRemoveEffect(effect.type)
}
tiny
/>
)}
);
})}
);
}
function AbilityChunksRequired({
build,
}: {
build: BuildAbilitiesTupleWithUnknown;
}) {
const { t } = useTranslation("analyzer");
const abilityChunksMapAsArray = getAbilityChunksMapAsArray(build);
return (
{t("abilityChunks")}
{abilityChunksMapAsArray.map((a) => {
const mainAbilityName = a[0];
const numChunksRequired = a[1];
return (
);
})}
);
}
function StatCategory({
title,
children,
containerClassName = "analyzer__stat-collection",
textBelow,
summaryRightContent,
testId,
}: {
title: string;
children: React.ReactNode;
containerClassName?: string;
textBelow?: string;
summaryRightContent?: React.ReactNode;
testId?: string;
}) {
return (
{title}
{summaryRightContent}
{children}
{textBelow && (
{textBelow}
)}
);
}
function StatCard({
title,
stat,
suffix,
popoverInfo,
abilityPoints,
isComparing,
testId,
}: {
title: string;
stat: [Stat, Stat] | [Stat, Stat] | number | string;
suffix?: string;
popoverInfo?: string;
abilityPoints: AbilityPoints;
isComparing: boolean;
testId?: string;
}) {
const { t } = useTranslation("analyzer");
const isStaticValue = typeof stat === "number" || typeof stat === "string";
const baseValue = isStaticValue ? stat : stat[0].baseValue;
const showBuildValue = () => {
if (isStaticValue) return false;
if (isComparing) return true;
// slightly hacky but handles the edge case
// where baseValue === value which can happen when
// you have Ninja Squid and stack swim speed
// -> we still want to show the build value
return [stat[0].modifiedBy].flat().some((ability) => {
const hasStackable = (abilityPoints.get(ability) ?? 0) > 0;
const hasEffect = baseValue !== stat[0].value;
return hasEffect || hasStackable;
});
};
const showComparison = isComparing && !isStaticValue;
const isHighlighted = () => {
if (!showComparison) return showBuildValue();
return (
stat[0].value !== stat[0].baseValue || stat[1].value !== stat[1].baseValue
);
};
return (
{title}{" "}
{popoverInfo && (
?>}
>
{popoverInfo}
)}
{typeof stat === "number"
? t("value")
: showComparison
? t("build1")
: t("base")}
{" "}
{showComparison ? (stat as [Stat, Stat])[0].value : baseValue}
{suffix}
{showBuildValue() ? (
{showComparison ? t("build2") : t("build")}
{" "}
{(stat as [Stat, Stat])[showComparison ? 1 : 0].value}
{suffix}
) : null}
{!isStaticValue && (
)}
);
}
function ModifiedByAbilities({ abilities }: { abilities: Stat["modifiedBy"] }) {
const abilitiesArray = Array.isArray(abilities) ? abilities : [abilities];
return (
{abilitiesArray.map((ability) => (
))}
);
}
function DamageTable({
values,
multiShots,
}: {
values:
| AnalyzedBuild["stats"]["damages"]
| AnalyzedBuild["stats"]["subWeaponDefenseDamages"];
multiShots: AnalyzedBuild["weapon"]["multiShots"];
}) {
const { t } = useTranslation(["weapons", "analyzer"]);
const showDistanceColumn = values.some((val) => val.distance);
const firstRow = atOrError(values, 0);
const showDamageColumn =
!damageIsSubWeaponDamage(firstRow) ||
// essentially checking that we are using some sub resistance up
values.some((val) => val.value !== (val as any).baseValue);
return (
<>
{t("analyzer:damage.header.type")}
{showDistanceColumn && (
{t("analyzer:damage.header.distance")}
)}
{damageIsSubWeaponDamage(firstRow) ? (
{t("analyzer:damage.header.baseDamage")}
) : null}
{showDamageColumn && {t("analyzer:damage.header.damage")} }
{values.map((val) => {
const damage =
multiShots && damageTypeToWeaponType[val.type] === "MAIN"
? new Array(multiShots).fill(val.value).join(" + ")
: val.value;
const typeRowName = damageIsSubWeaponDamage(val)
? (`weapons:SUB_${val.subWeaponId}` as const)
: damageTypeTranslationString({
damageType: val.type,
});
return (
{damageIsSubWeaponDamage(val) ? (
) : null}{" "}
{t(typeRowName)}{" "}
{damageIsSubWeaponDamage(val) && val.type === "SPLASH" ? (
<>({t("analyzer:damage.SPLASH")})>
) : null}
{showDistanceColumn && (
{typeof val.distance === "number"
? val.distance
: val.distance?.join("-")}
)}
{damageIsSubWeaponDamage(val) && {val.baseValue} }
{showDamageColumn && (
{damage}{" "}
{val.shotsToSplat && (
{t("analyzer:damage.toSplat", {
count: val.shotsToSplat,
})}
)}
)}
);
})}
>
);
}
function ConsumptionTable({
isComparing,
options,
subWeaponId,
}: {
isComparing: boolean;
options: [
AnalyzedBuild["stats"]["fullInkTankOptions"],
AnalyzedBuild["stats"]["fullInkTankOptions"]
];
subWeaponId: SubWeaponId;
}) {
const [options1, options2] = options;
const { t } = useTranslation(["analyzer", "weapons"]);
const maxSubsToUse =
subWeaponId === TORPEDO_ID
? 1
: Math.max(...options.flat().map((opt) => opt.subsUsed));
const types = Array.from(new Set(options1.map((opt) => opt.type)));
return (
<>
{t(`weapons:SUB_${subWeaponId}`)}
{types.map((type) => (
{t(`analyzer:stat.consumption.${type}`)}
))}
{new Array(maxSubsToUse + 1).fill(null).map((_, subsUsed) => {
const options1ForThisSubsUsed = options1.filter(
(opt) => opt.subsUsed === subsUsed
);
const options2ForThisSubsUsed = options2.filter(
(opt) => opt.subsUsed === subsUsed
);
const cells: React.ReactNode[] = [];
// weird using basic for loop in react code but here we are essentially
// zipping these two arrays into one cell and if one of the arrays
// doesn't have value then it shows as a dash instead
for (
let i = 0;
i <
Math.max(
options1ForThisSubsUsed.length,
options2ForThisSubsUsed.length
);
i++
) {
const opt1 = options1ForThisSubsUsed[i];
const opt2 = options2ForThisSubsUsed[i];
const contents = !isComparing
? opt1.value
: `${opt1?.value ?? "-"}/${opt2?.value ?? "-"}`;
cells.push({contents} );
}
return (
×{subsUsed}
{cells}
);
})}
{subWeaponId === TORPEDO_ID && (
<> {t("analyzer:torpedoExplanation")}>
)}
>
);
}