Improve LDE analyzer UI
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

Closes #1000
This commit is contained in:
Kalle
2026-09-25 18:28:28 +03:00
parent fe6d369cb7
commit dbbef1a504
24 changed files with 135 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({
</div>
<div>
{effect.type === "LDE" ? (
<select
value={ldeIntensity}
onChange={(e) =>
handleLdeIntensityChange(Number(e.target.value))
}
className={styles.ldeIntensitySelect}
>
{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>
<LdeIntensitySelect
ldeIntensity={ldeIntensity}
onChange={handleLdeIntensityChange}
/>
) : (
<SendouSwitch
isSelected={effects.includes(effect.type)}
@@ -1395,6 +1379,45 @@ function EffectsSelector({
);
}
function LdeIntensitySelect({
ldeIntensity,
onChange,
}: {
ldeIntensity: number;
onChange: (newLdeIntensity: number) => 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 (
<SendouSelect
selectedKey={ldeIntensity}
onSelectionChange={(intensity) => onChange(Number(intensity))}
aria-label="Last-Ditch Effort intensity"
className={styles.ldeIntensitySelect}
>
{nullFilledArray(LDE_CLOCK_INTENSITY + 1).map((_, intensity) => (
<SendouSelectItem key={intensity} id={intensity}>
{intensityLabel(intensity)}
</SendouSelectItem>
))}
</SendouSelect>
);
}
function AbilityChunksRequired({
build,
}: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -165,6 +165,9 @@
"patch": "バージョン:",
"abilityPoints.short": "GP",
"abilityChunks": "必要なギアパワーのかけら",
"lde.inactive": "",
"lde.points": "",
"lde.secondsLeft": "",
"torpedoExplanation": "ゲームの仕様上、1度に1つ以上のトーピードを使用することはできません。",
"trackingSubDefExplanation": "ポイントセンサー、トラップ、ラインマーカーのマーキング時間は、サブ性能アップのギアなしで計算しています。",
"damageSubDefExplanation": "スプリンクラーとスプラッシュシールドはテーブルに表示されていませんが、これらによるダメージはサブ影響軽減によって減少されます。",

View File

@@ -165,6 +165,9 @@
"patch": "패치:",
"abilityPoints.short": "AP",
"abilityChunks": "필요 기어 조각",
"lde.inactive": "",
"lde.points": "",
"lde.secondsLeft": "",
"torpedoExplanation": "게임 시스템 상 토피도를 한 번에 하나밖에 던지지 못합니다.",
"trackingSubDefExplanation": "포인트 센서, 트랩 그리고 라인 마커 추적 시간은 서브 성능 업을 착용하지 않은 상대를 대상으로 계산됐습니다.",
"damageSubDefExplanation": "스프링클러와 스플래시 실드는 표에 나타나지 않지만 마찬가지로 서브 영향 감소로 대미지를 줄일 수 있습니다.",

View File

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

View File

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

View File

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

View File

@@ -173,6 +173,9 @@
"patch": "Патч:",
"abilityPoints.short": "AP",
"abilityChunks": "Требуется чанков свойства",
"lde.inactive": "",
"lde.points": "",
"lde.secondsLeft": "",
"torpedoExplanation": "Из-за внутриигровых ограничений невозможно бросить 2 и более торпеды подряд.",
"trackingSubDefExplanation": "В расчётах продолжительности маркировки Маркера Движения, Мины и Углострела используется игрок с 0AP Стойкости Запаса.",
"damageSubDefExplanation": "Распылятор и Чернильный Занавес не указаны в таблице, но их урон можно снизить Стойкостью Запаса.",

View File

@@ -166,6 +166,9 @@
"patch": "游戏版本:",
"abilityPoints.short": "AP",
"abilityChunks": "所需碎片",
"lde.inactive": "",
"lde.points": "",
"lde.secondsLeft": "",
"torpedoExplanation": "同时只能使用一个鱼雷。",
"trackingSubDefExplanation": "定点侦测器、墨汁陷阱和标线器的索敌时间是根据没有携带提升次要武器性能的敌人计算得出。",
"damageSubDefExplanation": "洒墨器和斯普拉防护墙未在表内显示,但是它们所造成的伤害也会被减轻次要武器影响的效果降低。",