From dbbef1a504ddc951fbba248093a1a0e49780ca15 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:28:28 +0300 Subject: [PATCH] Improve LDE analyzer UI Closes #1000 --- .../build-analyzer/analyzer-constants.ts | 11 ++- .../analyzer-search-params.test.ts | 4 +- .../build-analyzer/analyzer-search-params.ts | 4 +- .../core/specialEffects.test.ts | 15 +++- .../build-analyzer/core/specialEffects.ts | 7 +- .../build-analyzer/routes/analyzer.module.css | 3 +- .../build-analyzer/routes/analyzer.tsx | 69 ++++++++++++------- .../2026-09-25-lde-activation-explained.md | 5 ++ locales/da/analyzer.json | 3 + locales/de/analyzer.json | 3 + locales/en/analyzer.json | 3 + locales/es-ES/analyzer.json | 3 + locales/es-US/analyzer.json | 3 + locales/fr-CA/analyzer.json | 3 + locales/fr-EU/analyzer.json | 3 + locales/he/analyzer.json | 3 + locales/it/analyzer.json | 3 + locales/ja/analyzer.json | 3 + locales/ko/analyzer.json | 3 + locales/nl/analyzer.json | 3 + locales/pl/analyzer.json | 3 + locales/pt-BR/analyzer.json | 3 + locales/ru/analyzer.json | 3 + locales/zh/analyzer.json | 3 + 24 files changed, 135 insertions(+), 31 deletions(-) create mode 100644 changelog/2026-09-25-lde-activation-explained.md diff --git a/app/features/build-analyzer/analyzer-constants.ts b/app/features/build-analyzer/analyzer-constants.ts index 07cc9c01d..ea22d612c 100644 --- a/app/features/build-analyzer/analyzer-constants.ts +++ b/app/features/build-analyzer/analyzer-constants.ts @@ -1,6 +1,15 @@ import type { DamageType, TenacityPlayerDeficit } from "./analyzer-types"; -export const MAX_LDE_INTENSITY = 21; +/** Opponent's remaining points when Last-Ditch Effort starts ramping up in ranked modes. */ +export const LDE_START_POINTS = 50; + +/** Opponent's remaining points when Last-Ditch Effort reaches its maximum. */ +const LDE_MAX_POINTS = 30; + +const MAX_LDE_INTENSITY = LDE_START_POINTS - LDE_MAX_POINTS + 1; + +/** Intensity standing for the clock based activation, which always grants the maximum boost. */ +export const LDE_CLOCK_INTENSITY = MAX_LDE_INTENSITY + 1; /** Every ability point total reachable by some main/sub slot combination, ascending. */ export function possibleApValues() { diff --git a/app/features/build-analyzer/analyzer-search-params.test.ts b/app/features/build-analyzer/analyzer-search-params.test.ts index ca9e55a1a..0c80c495b 100644 --- a/app/features/build-analyzer/analyzer-search-params.test.ts +++ b/app/features/build-analyzer/analyzer-search-params.test.ts @@ -25,7 +25,7 @@ describe("analyzerSearchParams", () => { weapon: [0, 10, 8000], build: [EMPTY_BUILD, FULL_BUILD, PARTIAL_BUILD], build2: [EMPTY_BUILD, FULL_BUILD], - lde: [0, 21, 10], + lde: [0, 22, 10], effect: [[], ["LDE"], ["OG", "TACTICOOLER"]], focused: [1, 2, 3], }); @@ -43,7 +43,7 @@ describe("analyzerSearchParams", () => { ["XX,XX,XX,XX,XX,XX,XX,XX,XX,XX,XX,XX"], ]); assertDecodesToDefault(analyzerSearchParams, "lde", [ - ["22"], + ["23"], ["-1"], ["x"], ]); diff --git a/app/features/build-analyzer/analyzer-search-params.ts b/app/features/build-analyzer/analyzer-search-params.ts index 19a2526a2..428345244 100644 --- a/app/features/build-analyzer/analyzer-search-params.ts +++ b/app/features/build-analyzer/analyzer-search-params.ts @@ -4,7 +4,7 @@ import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import * as SearchParams from "~/modules/search-params/search-params"; import { codec, SP } from "~/modules/search-params/search-params"; import { numericEnum } from "~/utils/schema"; -import { MAX_LDE_INTENSITY } from "./analyzer-constants"; +import { LDE_CLOCK_INTENSITY } from "./analyzer-constants"; import type { SpecialEffectType } from "./analyzer-types"; import { deserializeBuild, serializeBuild } from "./core/serializer"; import { SPECIAL_EFFECTS } from "./core/specialEffects"; @@ -43,7 +43,7 @@ export const analyzerSearchParams = SearchParams.define({ v.number(), v.integer(), v.minValue(0), - v.maxValue(MAX_LDE_INTENSITY), + v.maxValue(LDE_CLOCK_INTENSITY), ), { default: 0, diff --git a/app/features/build-analyzer/core/specialEffects.test.ts b/app/features/build-analyzer/core/specialEffects.test.ts index bc20452a8..ea28b029e 100644 --- a/app/features/build-analyzer/core/specialEffects.test.ts +++ b/app/features/build-analyzer/core/specialEffects.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "vitest"; -import { applySpecialEffects } from "./specialEffects"; +import { + applySpecialEffects, + lastDitchEffortIntensityToOpponentPoints, +} from "./specialEffects"; describe("applySpecialEffects()", () => { test("Adds an effect to empty build", () => { @@ -84,3 +87,13 @@ describe("applySpecialEffects()", () => { expect(aps.get("ISM")).toBe(13); }); }); + +describe("lastDitchEffortIntensityToOpponentPoints()", () => { + test.each([ + { why: "first step of the ramp", intensity: 1, points: 50 }, + { why: "middle of the ramp", intensity: 15, points: 36 }, + { why: "maximum boost", intensity: 21, points: 30 }, + ])("$why", ({ intensity, points }) => { + expect(lastDitchEffortIntensityToOpponentPoints(intensity)).toBe(points); + }); +}); diff --git a/app/features/build-analyzer/core/specialEffects.ts b/app/features/build-analyzer/core/specialEffects.ts index ca9d91672..632e1204e 100644 --- a/app/features/build-analyzer/core/specialEffects.ts +++ b/app/features/build-analyzer/core/specialEffects.ts @@ -1,4 +1,4 @@ -import { MAX_AP } from "../analyzer-constants"; +import { LDE_START_POINTS, MAX_AP } from "../analyzer-constants"; import type { AbilityPoints } from "../analyzer-types"; export const SPECIAL_EFFECTS = [ @@ -135,6 +135,11 @@ export function lastDitchEffortIntensityToAp(intensity: number) { return Math.floor((18 / 21) * intensity); } +/** Opponent's remaining points in an Anarchy Battle that the given Last-Ditch Effort intensity corresponds to. */ +export function lastDitchEffortIntensityToOpponentPoints(intensity: number) { + return LDE_START_POINTS + 1 - intensity; +} + function lastDitchEffortValues(intensity: number) { const ap = lastDitchEffortIntensityToAp(intensity); diff --git a/app/features/build-analyzer/routes/analyzer.module.css b/app/features/build-analyzer/routes/analyzer.module.css index 521106a6c..06c3961e8 100644 --- a/app/features/build-analyzer/routes/analyzer.module.css +++ b/app/features/build-analyzer/routes/analyzer.module.css @@ -56,8 +56,9 @@ place-items: center; } +/* Fixed width so picking a longer option doesn't resize the column */ .ldeIntensitySelect { - font-size: var(--font-2xs); + width: 12.5rem; } .apSummary { diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx index 8600c6484..8a81ad400 100644 --- a/app/features/build-analyzer/routes/analyzer.tsx +++ b/app/features/build-analyzer/routes/analyzer.tsx @@ -8,6 +8,7 @@ import * as R from "remeda"; import { AbilitiesSelector } from "~/components/AbilitiesSelector"; import { Ability } from "~/components/Ability"; import { Chart } from "~/components/Chart"; +import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; import { SendouSwitch } from "~/components/elements/Switch"; import { SendouTab, @@ -59,8 +60,8 @@ import { SendouPopover } from "../../../components/elements/Popover"; import { metaTags, ogPageImage } from "../../../utils/remix"; import { damageTypeToWeaponType, + LDE_CLOCK_INTENSITY, MAX_AP, - MAX_LDE_INTENSITY, TENACITY_PLAYER_DEFICITS, } from "../analyzer-constants"; import { useAnalyzeBuild } from "../analyzer-hooks"; @@ -82,6 +83,7 @@ import { } from "../core/abilityChunksCalc"; import { lastDitchEffortIntensityToAp, + lastDitchEffortIntensityToOpponentPoints, SPECIAL_EFFECTS, } from "../core/specialEffects"; import { buildStats } from "../core/stats"; @@ -1332,8 +1334,6 @@ function EffectsSelector({ handleAddEffect: (effect: SpecialEffectType) => void; handleRemoveEffect: (effect: SpecialEffectType) => void; }) { - const { t } = useTranslation(["weapons", "analyzer"]); - const effectsToShow = SPECIAL_EFFECTS.filter( (effect) => !isAbility(effect.type) || @@ -1357,26 +1357,10 @@ function EffectsSelector({
{effect.type === "LDE" ? ( - + ) : ( void; +}) { + const { t } = useTranslation(["analyzer"]); + + const intensityLabel = (intensity: number) => { + if (intensity === 0) return t("analyzer:lde.inactive"); + + const activation = + intensity === LDE_CLOCK_INTENSITY + ? t("analyzer:lde.secondsLeft") + : t("analyzer:lde.points", { + points: lastDitchEffortIntensityToOpponentPoints(intensity), + }); + const ap = lastDitchEffortIntensityToAp(intensity); + + return `${activation} (+${ap} ${t("analyzer:abilityPoints.short")})`; + }; + + return ( + onChange(Number(intensity))} + aria-label="Last-Ditch Effort intensity" + className={styles.ldeIntensitySelect} + > + {nullFilledArray(LDE_CLOCK_INTENSITY + 1).map((_, intensity) => ( + + {intensityLabel(intensity)} + + ))} + + ); +} + function AbilityChunksRequired({ build, }: { diff --git a/changelog/2026-09-25-lde-activation-explained.md b/changelog/2026-09-25-lde-activation-explained.md new file mode 100644 index 000000000..2e252d81f --- /dev/null +++ b/changelog/2026-09-25-lde-activation-explained.md @@ -0,0 +1,5 @@ +--- +navItem: analyzer +type: feature +--- +Last-Ditch Effort dropdown shows what activates the ability (the opposing team's points left, or the last 30 seconds) instead of a percentage diff --git a/locales/da/analyzer.json b/locales/da/analyzer.json index eb1d54151..bc5752db9 100644 --- a/locales/da/analyzer.json +++ b/locales/da/analyzer.json @@ -169,6 +169,9 @@ "patch": "Patch:", "abilityPoints.short": "AP", "abilityChunks": "Pris i Egenskabs-fragmenter", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "Man kan kun have én aktiv torpedo ad gangen.", "trackingSubDefExplanation": "Point, sensor, Ink Mine og Angle shooter sporingsvarighedsværdier er udregnet for fjender, der bruger 0AP af Sub power up. (Opgraderet sub-våben)", "damageSubDefExplanation": "Sprinker og Splash-vægge er ikke vist i tabellen, men skaden fra dem kan også reduceres via ”Sub Resistance Up” (forsvar mod sub-våben)", diff --git a/locales/de/analyzer.json b/locales/de/analyzer.json index c6c5b42ef..288189a2e 100644 --- a/locales/de/analyzer.json +++ b/locales/de/analyzer.json @@ -169,6 +169,9 @@ "patch": "Patch:", "abilityPoints.short": "EP", "abilityChunks": "Effekt-Fragmente erforderlich", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "Es ist unmöglich, mehr als einen Torpedo gleichzeitig abzufeuern (wegen der Spielmechanik).", "trackingSubDefExplanation": "Die Markierungsdauern von Detektor, Tintenmine and Winkelmarker basieren auf einem Gegner mit 0 EP Sekundärstärke +.", "damageSubDefExplanation": "", diff --git a/locales/en/analyzer.json b/locales/en/analyzer.json index a9ba44694..e7eae426a 100644 --- a/locales/en/analyzer.json +++ b/locales/en/analyzer.json @@ -169,6 +169,9 @@ "patch": "Patch:", "abilityPoints.short": "AP", "abilityChunks": "Ability Chunks Required", + "lde.inactive": "Not active", + "lde.points": "{{points}} points", + "lde.secondsLeft": "30 sec left", "torpedoExplanation": "You can't launch more than one torpedo at a time due to game mechanics.", "trackingSubDefExplanation": "Point Sensor, Ink Mine and Angle Shooter tracking times are calculated against an opponent with 0AP of Sub Power Up.", "damageSubDefExplanation": "Sprinkler and Splash Wall are not shown in the table but the damage caused by them can also be lowered by Sub Resistance Up", diff --git a/locales/es-ES/analyzer.json b/locales/es-ES/analyzer.json index a9e2e8424..047c39a60 100644 --- a/locales/es-ES/analyzer.json +++ b/locales/es-ES/analyzer.json @@ -171,6 +171,9 @@ "patch": "Parche:", "abilityPoints.short": "AP", "abilityChunks": "Fragmentos de potenciador requeridos", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "No se puede lanzar más de un torpedo a la vez.", "trackingSubDefExplanation": "Los tiempos de rastreo del Rastreador, la Bomba trampa y el Tiralíneas se calculan contra un oponente con 0 AP de Resistencia secundaria.", "damageSubDefExplanation": "El daño debido al aspersor y al telón de tinta también puede ser reducido con Resistencia secundaria.", diff --git a/locales/es-US/analyzer.json b/locales/es-US/analyzer.json index 52c9e851c..f00cd2d60 100644 --- a/locales/es-US/analyzer.json +++ b/locales/es-US/analyzer.json @@ -171,6 +171,9 @@ "patch": "Parche:", "abilityPoints.short": "AP", "abilityChunks": "Fragmentos de potenciador requeridos", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "No se puede lanzar más de un torpedo a la vez.", "trackingSubDefExplanation": "Tiempos de rastreo de Rastreador, Mina de Tinta y Dardo Resaltador son calculados contra un oponente con 0AP de Resistencia Secundaria.", "damageSubDefExplanation": "Daño debido al aspersor y barricada también puede ser reducido con Resistencia secundaria.", diff --git a/locales/fr-CA/analyzer.json b/locales/fr-CA/analyzer.json index cf818a69c..287ab793c 100644 --- a/locales/fr-CA/analyzer.json +++ b/locales/fr-CA/analyzer.json @@ -171,6 +171,9 @@ "patch": "Version:", "abilityPoints.short": " PC", "abilityChunks": "Fragments de bonus requis", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "Vous ne pouvez pas lancer plus d'une bentorpille à la fois à cause d'une mécanique du jeu.", "trackingSubDefExplanation": "Le temps de traçage du détecteur, de la mine et du ricocheur sont calculés contre un adversaire avec 0 PC d'arme secondaire +.", "damageSubDefExplanation": "La Fontaine et le Mur d'encre ne sont pas listés mais les dégâts qu'ils infligent peuvent aussi être réduits par Filtre à secondaires.", diff --git a/locales/fr-EU/analyzer.json b/locales/fr-EU/analyzer.json index a1d68222e..146bae0ca 100644 --- a/locales/fr-EU/analyzer.json +++ b/locales/fr-EU/analyzer.json @@ -171,6 +171,9 @@ "patch": "Version:", "abilityPoints.short": " PC", "abilityChunks": "Fragments de bonus requis", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "Vous ne pouvez pas lancer plus d'une bentorpille à la fois à cause d'une mécanique du jeu.", "trackingSubDefExplanation": "Le temps de traçage du détecteur, de la mine et du ricocheur sont calculés contre un adversaire avec 0 PC d'arme secondaire +.", "damageSubDefExplanation": "La Fontaine et le Mur d'encre ne sont pas listés mais les dégâts qu'ils infligent peuvent aussi être réduits par Filtre à secondaires.", diff --git a/locales/he/analyzer.json b/locales/he/analyzer.json index 83ca2b668..aa4ca615c 100644 --- a/locales/he/analyzer.json +++ b/locales/he/analyzer.json @@ -171,6 +171,9 @@ "patch": "עדכון:", "abilityPoints.short": "נ'י", "abilityChunks": "כמות צ'קאנס יכולות הנדרשת", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "לא ניתן לשגר יותר מטורפדו אחד בכל פעם בעקבות מכניקת המשחק.", "trackingSubDefExplanation": "זמני המעקב של Point Sensor, Ink Mine ו-Angle Shooter המחושבים מול יריב עם 0AP של Sub Power Up.", "damageSubDefExplanation": "Sprinkler ו-Splash Wall אינם מוצגים בטבלה אך ניתן להוריד את הנזק שנגרם מהם גם על ידי Sub Resistance Up.", diff --git a/locales/it/analyzer.json b/locales/it/analyzer.json index 7dc58acc5..d8012aaee 100644 --- a/locales/it/analyzer.json +++ b/locales/it/analyzer.json @@ -171,6 +171,9 @@ "patch": "Versione gioco:", "abilityPoints.short": "AP", "abilityChunks": "Frammenti abilità necessari", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "Non puoi lanciare più di un Torpedinatore alla volta.", "trackingSubDefExplanation": "I tempi di localizzazione della cimice, la mina e lo sparalinee sono calcolati contro un nemico con 0AP di Arma sec. impermeabile.", "damageSubDefExplanation": "Lo Spruzzatore e il Muro di Colore non sono mostrati nella tabella ma il danno causato da essi può anch'esso essere diminuito da Arma sec. impermeabile", diff --git a/locales/ja/analyzer.json b/locales/ja/analyzer.json index 57254d1cd..9511e4ffb 100644 --- a/locales/ja/analyzer.json +++ b/locales/ja/analyzer.json @@ -165,6 +165,9 @@ "patch": "バージョン:", "abilityPoints.short": "GP", "abilityChunks": "必要なギアパワーのかけら", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "ゲームの仕様上、1度に1つ以上のトーピードを使用することはできません。", "trackingSubDefExplanation": "ポイントセンサー、トラップ、ラインマーカーのマーキング時間は、サブ性能アップのギアなしで計算しています。", "damageSubDefExplanation": "スプリンクラーとスプラッシュシールドはテーブルに表示されていませんが、これらによるダメージはサブ影響軽減によって減少されます。", diff --git a/locales/ko/analyzer.json b/locales/ko/analyzer.json index 7e5d1f04a..9219da071 100644 --- a/locales/ko/analyzer.json +++ b/locales/ko/analyzer.json @@ -165,6 +165,9 @@ "patch": "패치:", "abilityPoints.short": "AP", "abilityChunks": "필요 기어 조각", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "게임 시스템 상 토피도를 한 번에 하나밖에 던지지 못합니다.", "trackingSubDefExplanation": "포인트 센서, 트랩 그리고 라인 마커 추적 시간은 서브 성능 업을 착용하지 않은 상대를 대상으로 계산됐습니다.", "damageSubDefExplanation": "스프링클러와 스플래시 실드는 표에 나타나지 않지만 마찬가지로 서브 영향 감소로 대미지를 줄일 수 있습니다.", diff --git a/locales/nl/analyzer.json b/locales/nl/analyzer.json index 3203c1e7a..9c474ff21 100644 --- a/locales/nl/analyzer.json +++ b/locales/nl/analyzer.json @@ -169,6 +169,9 @@ "patch": "Softwareversie:", "abilityPoints.short": "EP", "abilityChunks": "", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "", "trackingSubDefExplanation": "Detector, Inktmijn en Ketskogel markeringstijden worden berekend voor een tegenstander met 0 Subtopper EP.", "damageSubDefExplanation": "", diff --git a/locales/pl/analyzer.json b/locales/pl/analyzer.json index 66aee63c7..b67214151 100644 --- a/locales/pl/analyzer.json +++ b/locales/pl/analyzer.json @@ -173,6 +173,9 @@ "patch": "Patch:", "abilityPoints.short": "AP", "abilityChunks": "Ilość potrzebnych chunków", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "Nie możesz strzelić więcej niż jedno torpedo na raz ze względu na mechanizmy gry.", "trackingSubDefExplanation": "Czas śledzenia przeciwnika bronią Point Sensor, Ink Mine i Angle Shooter z użyciem 0AP efektu Sub Power Up.", "damageSubDefExplanation": "", diff --git a/locales/pt-BR/analyzer.json b/locales/pt-BR/analyzer.json index 11ddeac29..ac22d62b5 100644 --- a/locales/pt-BR/analyzer.json +++ b/locales/pt-BR/analyzer.json @@ -171,6 +171,9 @@ "patch": "Patch (versão):", "abilityPoints.short": "AP", "abilityChunks": "Ability Chunks necessários", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "Você não pode lançar mais de um torpedo de uma vez devido à mecânicas do jogo.", "trackingSubDefExplanation": "O tempo de rastreamento do Point Sensor, Ink Mine e Angle Shooter são calculados contra um oponente com 0AP de Sub Power Up.", "damageSubDefExplanation": "O Sprinkler e a Splash Wall não são mostrados na tabela mas o dano causado por eles também pode ser diminuído pelo Sub Resistance Up.", diff --git a/locales/ru/analyzer.json b/locales/ru/analyzer.json index d96c89274..21b14ccc1 100644 --- a/locales/ru/analyzer.json +++ b/locales/ru/analyzer.json @@ -173,6 +173,9 @@ "patch": "Патч:", "abilityPoints.short": "AP", "abilityChunks": "Требуется чанков свойства", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "Из-за внутриигровых ограничений невозможно бросить 2 и более торпеды подряд.", "trackingSubDefExplanation": "В расчётах продолжительности маркировки Маркера Движения, Мины и Углострела используется игрок с 0AP Стойкости Запаса.", "damageSubDefExplanation": "Распылятор и Чернильный Занавес не указаны в таблице, но их урон можно снизить Стойкостью Запаса.", diff --git a/locales/zh/analyzer.json b/locales/zh/analyzer.json index a5193b2e6..938b0dba6 100644 --- a/locales/zh/analyzer.json +++ b/locales/zh/analyzer.json @@ -166,6 +166,9 @@ "patch": "游戏版本:", "abilityPoints.short": "AP", "abilityChunks": "所需碎片", + "lde.inactive": "", + "lde.points": "", + "lde.secondsLeft": "", "torpedoExplanation": "同时只能使用一个鱼雷。", "trackingSubDefExplanation": "定点侦测器、墨汁陷阱和标线器的索敌时间是根据没有携带提升次要武器性能的敌人计算得出。", "damageSubDefExplanation": "洒墨器和斯普拉防护墙未在表内显示,但是它们所造成的伤害也会被减轻次要武器影响的效果降低。",