import * as R from "remeda"; import type { AbilityPoints, AnalyzedBuild, AnyWeapon, Damage, DamageType, } from "~/features/build-analyzer/analyzer-types"; import type { MainWeaponId, SpecialWeaponId, SubWeaponId, } from "~/modules/in-game-lists/types"; import { weaponIdToBaseWeaponId } from "~/modules/in-game-lists/weapon-ids"; import invariant from "~/utils/invariant"; import { roundToNDecimalPlaces } from "~/utils/number"; import { DAMAGE_RECEIVERS, damagePriorities, damageTypesToCombine, } from "../calculator-constants"; import type { CombineWith, DamageReceiver } from "../calculator-types"; import objectDamages from "./object-dmg.json"; import { objectHitPoints } from "./objectHitPoints"; function damageTypeToMultipliers({ type, weapon, }: { type: DamageType; weapon: AnyWeapon; }) { const matchingKeys: Array = []; for (const [key, objectDamagesObj] of Object.entries(objectDamages)) { if ( weapon.type === "MAIN" && (objectDamagesObj.mainWeaponIds as MainWeaponId[]).includes( weaponIdToBaseWeaponId(weapon.id), ) ) { matchingKeys.push(key as keyof typeof objectDamages); } else if ( weapon.type === "SUB" && (objectDamagesObj.subWeaponIds as SubWeaponId[]).includes(weapon.id) ) { matchingKeys.push(key as keyof typeof objectDamages); } else if ( weapon.type === "SPECIAL" && (objectDamagesObj.specialWeaponIds as SpecialWeaponId[]).includes( weapon.id, ) ) { matchingKeys.push(key as keyof typeof objectDamages); } } if (matchingKeys.length === 0) return null; const relevantKey = resolveRelevantKey({ keys: matchingKeys, type, weapon }); return objectDamages[relevantKey].rates; } function resolveRelevantKey({ keys, type, weapon, }: { keys: Array; type: DamageType; weapon: AnyWeapon; }): keyof typeof objectDamages { if (keys.length === 1) return keys[0]; const actualKeys = keys.filter((k) => k !== "Default"); if (actualKeys.length === 1) return actualKeys[0]; for (const [weaponType, weaponIds, damageType, key] of damagePriorities) { // handle alt kits e.g. Splatteshot might have id 10 but Tentatek Splattershot has id 11 // but in the context of this function they are one and the same const normalizedWeaponId = weapon.type === "MAIN" ? weaponIdToBaseWeaponId(weapon.id) : weapon.id; if (weaponType !== weapon.type) continue; if (!weaponIds.includes(normalizedWeaponId)) continue; if (damageType !== type) continue; if (!actualKeys.includes(key as Exclude)) { throw new Error( `Invalid damagePriorities (no key in object-dmg.json for the weapon): ${JSON.stringify( [weaponType, weaponIds, damageType, key], )}`, ); } return key; } throw new Error( `Could not resolve relevant key from ${actualKeys.join(", ")}; weapon: ${ weapon.type }_${weapon.id}; damage type: ${type} - please update damagePriorities`, ); } function multipliersToRecordWithFallbacks( multipliers: ReturnType, ) { return Object.fromEntries( DAMAGE_RECEIVERS.map((receiver) => [ receiver, multipliers?.find((m) => m.target === receiver)?.rate ?? 1, ]), ) as Record; } export function resolveAllUniqueDamageTypes({ analyzed, anyWeapon, }: { analyzed: AnalyzedBuild; anyWeapon: AnyWeapon; }) { const damageTypes = anyWeapon.type === "SUB" ? [] : anyWeapon.type === "SPECIAL" ? analyzed.stats.specialWeaponDamages.map((d) => d.type) : analyzed.stats.damages.map((d) => d.type); return R.unique(damageTypes).filter( (dmg) => !dmg.includes("SECONDARY") && dmg !== "COMBO", ); } function resolveFilteredDamages({ analyzed, damageType, isMultiShot, toCombine, anyWeapon, }: { analyzed: AnalyzedBuild; damageType: DamageType; isMultiShot: boolean; toCombine?: CombineWith; anyWeapon: AnyWeapon; }) { if (anyWeapon.type === "SUB") { return analyzed.stats.subWeaponDefenseDamages.filter( (damage) => damage.subWeaponId === anyWeapon.id, ); } const damageWithMultishots = (dmg: Damage, multiShots: number) => { // initially only Dread Wringer const isAsymmetric = analyzed.stats.damages.some( (dmg) => dmg.type === "DIRECT_SECONDARY_MIN", ); if (!isAsymmetric) return dmg.value * multiShots; const otherKey: DamageType = dmg.type === "DIRECT_MAX" ? "DIRECT_SECONDARY_MAX" : "DIRECT_SECONDARY_MIN"; const secondaryDamage = analyzed.stats.damages.find( (dmg) => dmg.type === otherKey, ); invariant(secondaryDamage, "secondary damage not found"); return dmg.value + secondaryDamage.value; }; const damages = anyWeapon.type === "SPECIAL" ? analyzed.stats.specialWeaponDamages : analyzed.stats.damages; return damages .filter((d) => d.type === damageType || toCombine?.combineWith === d.type) .map((damage) => { if (!isMultiShot || !damage.multiShots) return damage; return { ...damage, value: damageWithMultishots(damage, damage.multiShots), }; }); } const objectShredderMultipliers = objectDamages.ObjectEffect_Up.rates; export function calculateDamage({ analyzed, anyWeapon, abilityPoints, damageType, isMultiShot, }: { analyzed: AnalyzedBuild; anyWeapon: AnyWeapon; abilityPoints: AbilityPoints; damageType: DamageType; isMultiShot: boolean; }) { const toCombine = anyWeapon.type === "MAIN" ? (damageTypesToCombine[weaponIdToBaseWeaponId(anyWeapon.id)] ?? []).find( (c) => c.when === damageType, ) : undefined; const filteredDamages = resolveFilteredDamages({ analyzed, damageType, isMultiShot, toCombine, anyWeapon, }); const hitPoints = objectHitPoints(abilityPoints); const multipliers = Object.fromEntries( filteredDamages.map((damage) => { return [ damage.type, multipliersToRecordWithFallbacks( damageTypeToMultipliers({ type: damage.type, weapon: anyWeapon, }), ), ]; }), ); return DAMAGE_RECEIVERS.map((receiver) => { const damageReceiverHp = hitPoints[receiver]; return { receiver, hitPoints: damageReceiverHp, damages: filteredDamages .flatMap((damage) => [ { ...damage, objectShredder: false }, { ...damage, objectShredder: true }, ]) .flatMap((damage) => { if (toCombine?.combineWith === damage.type) { return []; } const otherDamage = () => { //[Special Case] Booyah ignores Tri-Stringer's otherDamage at full charge. In-game bug if ( anyWeapon.type === "MAIN" && weaponIdToBaseWeaponId(anyWeapon.id) === 7010 && receiver === "NiceBall_Armor" ) { return 0; } const result = filteredDamages.find( (damage) => damage.type === toCombine?.combineWith, )?.value; invariant(result); return result; }; const dmg = () => { if (toCombine && !toCombine?.multiplierOnly) { return damage.value + otherDamage(); } return damage.value; }; const baseMultiplier = () => { const normalMultiplier = multipliers[damage.type][receiver]; if (toCombine) { const actualDamage = () => { if (toCombine.multiplierOnly) { // undo "baked in" damage (see above) return damage.value - otherDamage(); } return damage.value; }; const otherMultiplier = multipliers[toCombine.combineWith][receiver]; // calculate "made up" multiplier that is taking the // weighted average of the two multipliers return ( (normalMultiplier * actualDamage() + otherMultiplier * otherDamage()) / (actualDamage() + otherDamage()) ); } return normalMultiplier; }; const objectShredderMultiplier = objectShredderMultipliers.find((m) => m.target === receiver) ?.rate ?? 1; const multiplier = baseMultiplier() * (damage.objectShredder ? objectShredderMultiplier : 1); const damagePerHit = roundToNDecimalPlaces(dmg() * multiplier); const hitsToDestroy = Math.ceil(damageReceiverHp / damagePerHit); return { value: damagePerHit, hitsToDestroy, multiplier: roundToNDecimalPlaces(multiplier, 2), type: damage.type, id: `${damage.id}-${String(damage.objectShredder)}`, distance: damage.distance, objectShredder: damage.objectShredder, }; }), }; }); }