sendou.ink/app/features/build-analyzer/routes/analyzer.tsx
Kalle ef78d3a2c2
Tournament full (#1373)
* Got something going

* Style overwrites

* width != height

* More playing with lines

* Migrations

* Start bracket initial

* Unhardcode stage generation params

* Link to match page

* Matches page initial

* Support directly adding seed to map list generator

* Add docs

* Maps in matches page

* Add invariant about tie breaker map pool

* Fix PICNIC lacking tie breaker maps

* Only link in bracket when tournament has started

* Styled tournament roster inputs

* Prefer IGN in tournament match page

* ModeProgressIndicator

* Some conditional rendering

* Match action initial + better error display

* Persist bestOf in DB

* Resolve best of ahead of time

* Move brackets-manager to core

* Score reporting works

* Clear winner on score report

* ModeProgressIndicator: highlight winners

* Fix inconsistent input

* Better text when submitting match

* mapCountPlayedInSetWithCertainty that works

* UNDO_REPORT_SCORE implemented

* Permission check when starting tournament

* Remove IGN from upsert

* View match results page

* Source in DB

* Match page waiting for teams

* Move tournament bracket to feature folder

* REOPEN_MATCH initial

* Handle proper resetting of match

* Inline bracket-manager

* Syncify

* Transactions

* Handle match is locked gracefully

* Match page auto refresh

* Fix match refresh called "globally"

* Bracket autoupdate

* Move fillWithNullTillPowerOfTwo to utils with testing

* Fix map lists not visible after tournament started

* Optimize match events

* Show UI while in progress to members

* Fix start tournament alert not being responsive

* Teams can check in

* Fix map list 400

* xxx -> TODO

* Seeds page

* Remove map icons for team page

* Don't display link to seeds after tournament has started

* Admin actions initial

* Change captain admin action

* Make all hooks ts

* Admin actions functioning

* Fix validate error not displaying in CatchBoundary

* Adjust validate args order

* Remove admin loader

* Make delete team button menancing

* Only include checked in teams to bracket

* Optimize to.id route loads

* Working show map list generator toggle

* Update full tournaments flow

* Make full tournaments work with many start times

* Handle undefined in crud

* Dynamic stage banner

* Handle default strat if map list generation fails

* Fix crash on brackets if less than 2 teams

* Add commented out test for reference

* Add TODO

* Add players from team during register

* TrustRelationship

* Prefers not to host feature

* Last before merge

* Rename some vars

* More renames
2023-05-15 22:37:43 +03:00

1419 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
key="jumpShotSpread"
stat={statKeyToTuple("shotSpreadAir")}
title={t("analyzer:stat.jumpShotSpread")}
suffix="°"
/>
),
typeof analyzed.stats.shotSpreadGround === "number" && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
key="groundShotSpread"
stat={analyzed.stats.shotSpreadGround}
title={t("analyzer:stat.groundShotSpread")}
suffix="°"
/>
),
typeof analyzed.stats.mainWeaponWhiteInkSeconds === "number" && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
key="whiteInkSeconds"
stat={analyzed.stats.mainWeaponWhiteInkSeconds}
title={t("analyzer:stat.whiteInk")}
suffix={t("analyzer:suffix.seconds")}
/>
),
typeof analyzed.weapon.brellaCanopyHp === "number" && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
key="brellaCanopyHp"
stat={analyzed.weapon.brellaCanopyHp}
title={t("analyzer:stat.canopyHp")}
suffix={t("analyzer:suffix.hp")}
/>
),
typeof analyzed.weapon.fullChargeSeconds === "number" && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
key="fullChargeSeconds"
stat={analyzed.weapon.fullChargeSeconds}
title={t("analyzer:stat.fullChargeSeconds")}
suffix={t("analyzer:suffix.seconds")}
/>
),
typeof analyzed.weapon.maxChargeHoldSeconds === "number" && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
key="maxChargeHoldSeconds"
stat={analyzed.weapon.maxChargeHoldSeconds}
title={t("analyzer:stat.maxChargeHoldSeconds")}
suffix={t("analyzer:suffix.seconds")}
/>
),
].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 (
<Main>
<div className="analyzer__container">
<div className="analyzer__left-column">
<div className="stack sm items-center w-full">
<div className="w-full">
<WeaponCombobox
inputName="weapon"
onChange={(opt) =>
opt &&
handleChange({
newMainWeaponId: Number(opt.value) as MainWeaponId,
})
}
fullWidth
/>
</div>
</div>
<div className="stack md items-center w-full">
<div className="w-full">
<Tabs className="analyzer__sub-nav">
<Tab
active={focused === 1}
onClick={() => handleChange({ newFocused: 1 })}
testId="build1-tab"
>
{t("analyzer:build1")}
</Tab>
<Tab
active={focused === 2}
onClick={() => handleChange({ newFocused: 2 })}
testId="build2-tab"
>
{t("analyzer:build2")}
</Tab>
<Tab
active={focused === 3}
onClick={() => handleChange({ newFocused: 3 })}
testId="ap-tab"
>
{t("analyzer:compare")}
</Tab>
</Tabs>
{focusedBuild ? (
<AbilitiesSelector
selectedAbilities={focusedBuild}
onChange={(newBuild) => {
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,
});
}}
/>
) : (
<APCompare
abilityPoints={abilityPoints}
abilityPoints2={abilityPoints2}
build={build}
build2={build2}
/>
)}
</div>
<EffectsSelector
build={build}
build2={build2}
ldeIntensity={ldeIntensity}
handleLdeIntensityChange={(newLdeIntensity) =>
handleChange({ newLdeIntensity })
}
handleAddEffect={(newEffect) =>
handleChange({ newEffects: [...allEffects, newEffect] })
}
handleRemoveEffect={(effectToRemove) =>
handleChange({
newEffects: allEffects.filter((e) => e !== effectToRemove),
})
}
effects={allEffects}
/>
{showAbilityChunksRequired && (
<AbilityChunksRequired build={build} />
)}
</div>
<div className="analyzer__patch">
{t("analyzer:patch")} {CURRENT_PATCH}
</div>
</div>
<div className="stack md">
{mainWeaponCategoryItems.length > 0 && (
<StatCategory
title={t("analyzer:stat.category.main")}
summaryRightContent={
<div className="analyzer__weapon-info-badge">
<Image
path={mainWeaponImageUrl(mainWeaponId)}
width={20}
height={20}
alt={t(`weapons:MAIN_${mainWeaponId}`)}
/>
{t(`weapons:MAIN_${mainWeaponId}`)}
</div>
}
>
{mainWeaponCategoryItems}
</StatCategory>
)}
<StatCategory
title={t("analyzer:stat.category.sub")}
summaryRightContent={
<div className="analyzer__weapon-info-badge">
<Image
path={subWeaponImageUrl(analyzed.weapon.subWeaponSplId)}
width={20}
height={20}
alt={t(`weapons:SUB_${analyzed.weapon.subWeaponSplId}`)}
/>
{t(`weapons:SUB_${analyzed.weapon.subWeaponSplId}`)}
</div>
}
>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("subWeaponInkConsumptionPercentage")}
title={t("analyzer:stat.subWeaponInkConsumptionPercentage")}
suffix="%"
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={analyzed.stats.subWeaponWhiteInkSeconds}
title={t("analyzer:stat.whiteInk")}
suffix={t("analyzer:suffix.seconds")}
/>
{analyzed.stats.subVelocity && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("subVelocity")}
title={t("analyzer:stat.sub.velocity")}
/>
)}
{analyzed.stats.subFirstPhaseDuration && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("subFirstPhaseDuration")}
title={t("analyzer:stat.sub.firstPhaseDuration")}
suffix={t("analyzer:suffix.seconds")}
/>
)}
{analyzed.stats.subSecondPhaseDuration && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("subSecondPhaseDuration")}
title={t("analyzer:stat.sub.secondPhaseDuration")}
suffix={t("analyzer:suffix.seconds")}
/>
)}
{analyzed.stats.subMarkingTimeInSeconds && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("subMarkingTimeInSeconds")}
title={t("analyzer:stat.sub.markingTimeInSeconds")}
suffix={t("analyzer:suffix.seconds")}
/>
)}
{analyzed.stats.subMarkingRadius && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("subMarkingRadius")}
title={t("analyzer:stat.sub.markingRadius")}
/>
)}
{analyzed.stats.subExplosionRadius && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("subExplosionRadius")}
title={t("analyzer:stat.sub.explosionRadius")}
/>
)}
{analyzed.stats.subHp && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("subHp")}
title={t("analyzer:stat.sub.hp")}
suffix={t("analyzer:suffix.hp")}
/>
)}
{analyzed.stats.subQsjBoost && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("subQsjBoost")}
title={t("analyzer:stat.sub.qsjBoost")}
suffix={t("analyzer:abilityPoints.short")}
/>
)}
</StatCategory>
<StatCategory
title={t("analyzer:stat.category.special")}
summaryRightContent={
<div className="analyzer__weapon-info-badge">
<Image
path={specialWeaponImageUrl(
analyzed.weapon.specialWeaponSplId
)}
width={20}
height={20}
alt={t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
)}
/>
{t(`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`)}
</div>
}
>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialPoint")}
title={t("analyzer:stat.specialPoints")}
suffix={t("analyzer:suffix.specialPointsShort")}
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialLost")}
title={t("analyzer:stat.specialLost")}
suffix="%"
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialLostSplattedByRP")}
title={t("analyzer:stat.specialLostSplattedByRP")}
suffix="%"
/>
{analyzed.stats.specialDurationInSeconds && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialDurationInSeconds")}
title={t("analyzer:stat.special.duration", {
weapon: t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
),
})}
suffix={t("analyzer:suffix.seconds")}
popoverInfo={
analyzed.weapon.specialWeaponSplId === INK_STORM_ID
? t("analyzer:stat.special.duration.inkStormExplanation")
: undefined
}
/>
)}
{analyzed.stats.specialDamageDistance && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialDamageDistance")}
title={t("analyzer:stat.special.damageDistance", {
weapon: t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
),
})}
/>
)}
{analyzed.stats.specialPaintRadius && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialPaintRadius")}
title={t("analyzer:stat.special.paintRadius", {
weapon: t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
),
})}
/>
)}
{analyzed.stats.specialFieldHp && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialFieldHp")}
title={t("analyzer:stat.special.shieldHp", {
weapon: t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
),
})}
suffix={t("analyzer:suffix.hp")}
/>
)}
{analyzed.stats.specialDeviceHp && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialDeviceHp")}
title={t("analyzer:stat.special.deviceHp", {
weapon: t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
),
})}
suffix={t("analyzer:suffix.hp")}
/>
)}
{analyzed.stats.specialHookInkConsumptionPercentage && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialHookInkConsumptionPercentage")}
title={t("analyzer:stat.special.inkConsumptionHook", {
weapon: t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
),
})}
suffix="%"
/>
)}
{analyzed.stats.specialInkConsumptionPerSecondPercentage && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple(
"specialInkConsumptionPerSecondPercentage"
)}
title={t("analyzer:stat.special.inkConsumptionPerSecond", {
weapon: t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
),
})}
suffix="%"
/>
)}
{analyzed.stats.specialReticleRadius && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialReticleRadius")}
title={t("analyzer:stat.special.reticleRadius", {
weapon: t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
),
})}
/>
)}
{analyzed.stats.specialThrowDistance && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialThrowDistance")}
title={t("analyzer:stat.special.throwDistance", {
weapon: t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
),
})}
/>
)}
{analyzed.stats.specialAutoChargeRate && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialAutoChargeRate")}
title={t("analyzer:stat.special.autoChargeRate", {
weapon: t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
),
})}
/>
)}
{analyzed.stats.specialMaxRadius && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialMaxRadius")}
title={t("analyzer:stat.special.maxRadius", {
weapon: t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
),
})}
popoverInfo={t("analyzer:stat.special.maxRadius.explanation")}
/>
)}
{analyzed.stats.specialRadiusRange && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialRadiusRange")}
title={t("analyzer:stat.special.radiusRange", {
weapon: t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
),
})}
/>
)}
{analyzed.stats.specialPowerUpDuration && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("specialPowerUpDuration")}
title={t("analyzer:stat.special.powerUpDuration", {
weapon: t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`
),
})}
suffix={t("analyzer:suffix.seconds")}
/>
)}
</StatCategory>
<StatCategory
title={t("analyzer:stat.category.subDef")}
textBelow={t("analyzer:trackingSubDefExplanation")}
>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("subDefToxicMistMovementReduction")}
title={t("analyzer:stat.movementReduction", {
weapon: t(`weapons:SUB_${TOXIC_MIST_ID}`),
})}
suffix="%"
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("subDefPointSensorMarkedTimeInSeconds")}
title={t("analyzer:stat.markedTime", {
weapon: t(`weapons:SUB_${POINT_SENSOR_ID}`),
})}
suffix={t("analyzer:suffix.seconds")}
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("subDefInkMineMarkedTimeInSeconds")}
title={t("analyzer:stat.markedTime", {
weapon: t(`weapons:SUB_${INK_MINE_ID}`),
})}
suffix={t("analyzer:suffix.seconds")}
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("subDefAngleShooterMarkedTimeInSeconds")}
title={t("analyzer:stat.markedTime", {
weapon: t(`weapons:SUB_${ANGLE_SHOOTER_ID}`),
})}
suffix={t("analyzer:suffix.seconds")}
/>
</StatCategory>
{analyzed.stats.subWeaponDefenseDamages.length > 0 && (
<StatCategory
title={t("analyzer:stat.category.subWeaponDefenseDamages")}
containerClassName="analyzer__table-container"
textBelow={t("analyzer:damageSubDefExplanation")}
>
{(["SRU"] as const).some(
(ability) => (abilityPoints.get(ability) ?? 0) > 0
) ? (
<div className="analyzer__stat-card-highlighted" />
) : null}
<DamageTable
values={analyzed.stats.subWeaponDefenseDamages}
multiShots={analyzed.weapon.multiShots}
/>
</StatCategory>
)}
{analyzed.stats.damages.length > 0 && (
<StatCategory
title={t("analyzer:stat.category.damage")}
containerClassName="analyzer__table-container"
>
<DamageTable
values={analyzed.stats.damages}
multiShots={analyzed.weapon.multiShots}
/>
</StatCategory>
)}
{analyzed.stats.fullInkTankOptions.length > 0 && (
<StatCategory
title={t("analyzer:stat.category.actionsPerInkTank")}
containerClassName="analyzer__table-container"
>
{(["ISM", "ISS"] as const).some(
(ability) => (abilityPoints.get(ability) ?? 0) > 0
) ? (
<div className="analyzer__stat-card-highlighted" />
) : null}
<ConsumptionTable
isComparing={isComparing}
options={[
analyzed.stats.fullInkTankOptions,
analyzed2.stats.fullInkTankOptions,
]}
subWeaponId={analyzed.weapon.subWeaponSplId}
/>
</StatCategory>
)}
<StatCategory
title={t("analyzer:stat.category.movement")}
testId="movement-category"
>
<StatCard
isComparing={isComparing}
title={t("analyzer:attribute.weight")}
abilityPoints={abilityPoints}
stat={t(`analyzer:attribute.weight.${analyzed.weapon.speedType}`)}
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("swimSpeed")}
title={t("analyzer:stat.swimSpeed")}
testId="swim-speed"
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("swimSpeedHoldingRainmaker")}
title={t("analyzer:stat.swimSpeedHoldingRainmaker")}
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("runSpeed")}
title={t("analyzer:stat.runSpeed")}
/>
{analyzed.stats.shootingRunSpeed && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("shootingRunSpeed")}
title={t("analyzer:stat.shootingRunSpeed")}
/>
)}
{analyzed.stats.shootingRunSpeedCharging && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("shootingRunSpeedCharging")}
title={t("analyzer:stat.shootingRunSpeedCharging")}
/>
)}
{analyzed.stats.shootingRunSpeedFullCharge && (
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("shootingRunSpeedFullCharge")}
title={t("analyzer:stat.shootingRunSpeedFullCharge")}
/>
)}
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("squidSurgeChargeFrames")}
title={t("analyzer:stat.squidSurgeChargeFrames")}
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("runSpeedInEnemyInk")}
title={t("analyzer:stat.runSpeedInEnemyInk")}
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("framesBeforeTakingDamageInEnemyInk")}
title={t("analyzer:stat.framesBeforeTakingDamageInEnemyInk")}
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("damageTakenInEnemyInkPerSecond")}
title={t("analyzer:stat.damageTakenInEnemyInkPerSecond")}
suffix={t("analyzer:suffix.hp")}
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("enemyInkDamageLimit")}
title={t("analyzer:stat.enemyInkDamageLimit")}
suffix={t("analyzer:suffix.hp")}
/>
</StatCategory>
<StatCategory title={t("analyzer:stat.category.misc")}>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("squidFormInkRecoverySeconds")}
title={t("analyzer:stat.squidFormInkRecoverySeconds")}
suffix={t("analyzer:suffix.seconds")}
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("humanoidFormInkRecoverySeconds")}
title={t("analyzer:stat.humanoidFormInkRecoverySeconds")}
suffix={t("analyzer:suffix.seconds")}
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("quickRespawnTime")}
title={t("analyzer:stat.quickRespawnTime")}
suffix={t("analyzer:suffix.seconds")}
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("quickRespawnTimeSplattedByRP")}
title={t("analyzer:stat.quickRespawnTimeSplattedByRP")}
suffix={t("analyzer:suffix.seconds")}
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("superJumpTimeGroundFrames")}
title={t("analyzer:stat.superJumpTimeGround")}
/>
<StatCard
isComparing={isComparing}
abilityPoints={abilityPoints}
stat={statKeyToTuple("superJumpTimeTotal")}
title={t("analyzer:stat.superJumpTimeTotal")}
suffix={t("analyzer:suffix.seconds")}
/>
</StatCategory>
{objectShredderSelected && (
<Link
className="analyzer__noticeable-link"
to={objectDamageCalculatorPage(mainWeaponId)}
>
<Image
path={navIconUrl("object-damage-calculator")}
width={24}
height={24}
alt=""
/>
{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>
);
}
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 (
<div className="analyzer__ap-compare">
{hasAtLeastOneMainOnlyAbility ? (
<>
<div className="analyzer__ap-compare__mains">
{buildMains.map((ability) => (
<Ability key={ability} ability={ability} size="TINY" />
))}
</div>
<div />
<div className="analyzer__ap-compare__mains">
{build2Mains.map((ability) => (
<Ability key={ability} ability={ability} size="TINY" />
))}
</div>
</>
) : 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 (
<React.Fragment key={ability}>
<div
className={clsx("justify-self-end", {
invisible: !ap,
})}
data-testid="ap-compare-1"
>
{ap}
{t("analyzer:abilityPoints.short")}
</div>
<div
className={clsx("analyzer__ap-compare__bar", "justify-self-end", {
analyzer__better: ap >= ap2,
})}
style={{ width: `${ap}px` }}
/>
<Ability ability={ability} size="TINY" />
<div
className={clsx("analyzer__ap-compare__bar", {
analyzer__better: ap <= ap2,
})}
style={{ width: `${ap2}px` }}
/>
<div
className={clsx({ invisible: !ap2 })}
data-testid="ap-compare-2"
>
{ap2}
{t("analyzer:abilityPoints.short")}
</div>
</React.Fragment>
);
})}
</div>
);
}
function EffectsSelector({
build,
build2,
effects,
ldeIntensity,
handleLdeIntensityChange,
handleAddEffect,
handleRemoveEffect,
}: {
build: BuildAbilitiesTupleWithUnknown;
build2: BuildAbilitiesTupleWithUnknown;
effects: Array<SpecialEffectType>;
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 (
<div className="analyzer__effects-selector">
{effectsToShow.map((effect) => {
return (
<React.Fragment key={effect.type}>
<div>
{isAbility(effect.type) ? (
<Ability ability={effect.type} size="SUB" />
) : (
<Image
path={specialWeaponImageUrl(15)}
alt={t("weapons:SPECIAL_15")}
height={32}
width={32}
/>
)}
</div>
<div>
{effect.type === "LDE" ? (
<select
value={ldeIntensity}
onChange={(e) =>
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 (
<option key={i} value={i}>
{percentage}% (+{lastDitchEffortIntensityToAp(i)}{" "}
{t("analyzer:abilityPoints.short")})
</option>
);
})}
</select>
) : (
<Toggle
checked={effects.includes(effect.type)}
setChecked={(checked) =>
checked
? handleAddEffect(effect.type)
: handleRemoveEffect(effect.type)
}
tiny
/>
)}
</div>
</React.Fragment>
);
})}
</div>
);
}
function AbilityChunksRequired({
build,
}: {
build: BuildAbilitiesTupleWithUnknown;
}) {
const { t } = useTranslation("analyzer");
const abilityChunksMapAsArray = getAbilityChunksMapAsArray(build);
return (
<details className="w-full">
<summary className="analyzer__ap-summary">{t("abilityChunks")}</summary>
<div className="stack sm horizontal flex-wrap mt-4">
{abilityChunksMapAsArray.map((a) => {
const mainAbilityName = a[0];
const numChunksRequired = a[1];
return (
<div
key={`abilityChunksRequired_${mainAbilityName}`}
className="stack items-center"
>
<Ability ability={mainAbilityName} size="TINY" />
<div className="analyzer__ap-text">{numChunksRequired}</div>
</div>
);
})}
</div>
</details>
);
}
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 (
<details className="analyzer__details">
<summary className="analyzer__summary" data-testid={testId}>
{title}
{summaryRightContent}
</summary>
<div className={containerClassName}>{children}</div>
{textBelow && (
<div className="analyzer__stat-category-explanation">{textBelow}</div>
)}
</details>
);
}
function StatCard({
title,
stat,
suffix,
popoverInfo,
abilityPoints,
isComparing,
testId,
}: {
title: string;
stat: [Stat, Stat] | [Stat<string>, Stat<string>] | 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 (
<div
className={clsx("analyzer__stat-card", {
"analyzer__stat-card-highlighted": isHighlighted(),
})}
data-testid={testId}
>
<div className="analyzer__stat-card__title-and-value-container">
<h3 className="analyzer__stat-card__title">
{title}{" "}
{popoverInfo && (
<Popover
containerClassName="analyzer__stat-card__popover"
triggerClassName="analyzer__stat-card__popover-trigger"
buttonChildren={<>?</>}
>
{popoverInfo}
</Popover>
)}
</h3>
<div className="analyzer__stat-card-values">
<div className="analyzer__stat-card__value">
<h4 className="analyzer__stat-card__value__title">
{typeof stat === "number"
? t("value")
: showComparison
? t("build1")
: t("base")}
</h4>{" "}
<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"
data-testid={testId ? `${testId}-build-title` : undefined}
>
{showComparison ? t("build2") : t("build")}
</h4>{" "}
<div className="analyzer__stat-card__value__number">
{(stat as [Stat, Stat])[showComparison ? 1 : 0].value}
{suffix}
</div>
</div>
) : null}
</div>
</div>
<div className="analyzer__stat-card__ability-container">
{!isStaticValue && (
<ModifiedByAbilities abilities={stat[0].modifiedBy} />
)}
</div>
</div>
);
}
function ModifiedByAbilities({ abilities }: { abilities: Stat["modifiedBy"] }) {
const abilitiesArray = Array.isArray(abilities) ? abilities : [abilities];
return (
<div className="stack horizontal sm items-center justify-center">
{abilitiesArray.map((ability) => (
<Ability key={ability} ability={ability} size="TINY" />
))}
</div>
);
}
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 (
<>
<table>
<thead>
<tr>
<th>{t("analyzer:damage.header.type")}</th>
{showDistanceColumn && (
<th>{t("analyzer:damage.header.distance")}</th>
)}
{damageIsSubWeaponDamage(firstRow) ? (
<th>{t("analyzer:damage.header.baseDamage")}</th>
) : null}
{showDamageColumn && <th>{t("analyzer:damage.header.damage")}</th>}
</tr>
</thead>
<tbody>
{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 (
<tr key={val.id}>
<td className="stack horizontal xs items-center">
{damageIsSubWeaponDamage(val) ? (
<Image
alt=""
path={subWeaponImageUrl(val.subWeaponId)}
width={12}
height={12}
/>
) : null}{" "}
{t(typeRowName)}{" "}
{damageIsSubWeaponDamage(val) && val.type === "SPLASH" ? (
<>({t("analyzer:damage.SPLASH")})</>
) : null}
</td>
{showDistanceColumn && (
<td>
{typeof val.distance === "number"
? val.distance
: val.distance?.join("-")}
</td>
)}
{damageIsSubWeaponDamage(val) && <td>{val.baseValue}</td>}
{showDamageColumn && (
<td>
{damage}{" "}
{val.shotsToSplat && (
<span className="analyzer__shots-to-splat">
{t("analyzer:damage.toSplat", {
count: val.shotsToSplat,
})}
</span>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</>
);
}
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 (
<>
<table>
<thead>
<tr>
<th>{t(`weapons:SUB_${subWeaponId}`)}</th>
{types.map((type) => (
<th key={type}>{t(`analyzer:stat.consumption.${type}`)}</th>
))}
</tr>
</thead>
<tbody>
{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(<td key={opt1?.id ?? opt2.id}>{contents}</td>);
}
return (
<tr key={subsUsed}>
<td>×{subsUsed}</td>
{cells}
</tr>
);
})}
</tbody>
</table>
{subWeaponId === TORPEDO_ID && (
<div className="analyzer__consumption-table-explanation">
<> {t("analyzer:torpedoExplanation")}</>
</div>
)}
</>
);
}