Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,54 @@
.bars {
display: flex;
flex-direction: column;
gap: var(--s-2);
min-width: min(420px, 80vw);
padding-inline: var(--s-2);
}
.row {
display: flex;
align-items: center;
gap: var(--s-2);
}
.weapon {
display: flex;
align-items: center;
gap: var(--s-1);
width: 130px;
flex-shrink: 0;
}
.name {
font-size: var(--font-2xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.barTrack {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.barFill {
height: 18px;
min-width: 2px;
border-radius: var(--radius-field);
background-color: var(--color-accent);
}
.barFillCurrent {
background-color: var(--color-second);
}
.value {
font-variant-numeric: tabular-nums;
font-weight: var(--weight-semi);
font-size: var(--font-xs);
flex-shrink: 0;
width: 52px;
}

View File

@@ -0,0 +1,66 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import * as R from "remeda";
import { SendouDialog } from "~/components/elements/Dialog";
import * as WeaponParams from "../core/WeaponParams";
import type {
ParamComparisonEntry,
WeaponParamKind,
} from "../weapon-params-types";
import styles from "./ParamComparisonDialog.module.css";
import { WeaponParamImage } from "./WeaponParamsTable";
/**
* Modal with a simple horizontal bar chart comparing one parameter's numeric value across the
* currently visible weapons. Bars are scaled relative to the largest absolute value so weapons can
* be compared at a glance.
*/
export function ParamComparisonDialog({
kind,
label,
entries,
currentWeaponId,
onClose,
}: {
kind: WeaponParamKind;
label: string;
entries: ParamComparisonEntry[];
currentWeaponId: number;
onClose: () => void;
}) {
const { t } = useTranslation(["params"]);
const sortedEntries = R.sortBy(entries, [(entry) => entry.value, "desc"]);
const maxValue = Math.max(...entries.map((entry) => Math.abs(entry.value)));
return (
<SendouDialog
heading={t("params:compare.heading", { parameter: label })}
onClose={onClose}
>
<div className={styles.bars}>
{sortedEntries.map((entry) => (
<div key={entry.weaponId} className={styles.row}>
<div className={styles.weapon}>
<WeaponParamImage kind={kind} id={entry.weaponId} size={28} />
<span className={styles.name}>{entry.name}</span>
</div>
<div className={styles.barTrack}>
<div
className={clsx(styles.barFill, {
[styles.barFillCurrent]: entry.weaponId === currentWeaponId,
})}
style={{
width: `${maxValue === 0 ? 0 : (Math.abs(entry.value) / maxValue) * 100}%`,
}}
/>
</div>
<span className={styles.value}>
{WeaponParams.formatValue(entry.value)}
</span>
</div>
))}
</div>
</SendouDialog>
);
}

View File

@@ -0,0 +1,44 @@
.kits {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--s-2);
list-style: none;
padding: 0;
margin: 0;
}
.kit {
display: inline-flex;
align-items: center;
gap: var(--s-2);
font-size: var(--font-xs);
font-weight: var(--weight-bold);
background-color: var(--color-bg-high);
border: var(--border-style);
border-radius: var(--radius-field);
padding: var(--s-1) var(--s-3);
}
.kitName {
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kitGear {
display: inline-flex;
align-items: center;
gap: var(--s-1);
margin-inline-start: var(--s-1);
}
.gearLink {
display: inline-flex;
align-items: center;
border-radius: var(--radius-rounded);
&:hover {
filter: brightness(1.2);
}
}

View File

@@ -0,0 +1,50 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
SpecialWeaponImage,
SubWeaponImage,
WeaponImage,
} from "~/components/Image";
import { mySlugify, weaponParamsPage } from "~/utils/urls";
import type { WeaponKitInfo } from "../weapon-params-types";
import styles from "./WeaponKits.module.css";
export function WeaponKits({ kits }: { kits: WeaponKitInfo[] }) {
const { t } = useTranslation(["weapons"]);
return (
<ul className={styles.kits}>
{kits.map((kit) => (
<li key={kit.weaponId} className={styles.kit}>
<WeaponImage weaponSplId={kit.weaponId} variant="badge" size={28} />
<span className={styles.kitName}>
{t(`weapons:MAIN_${kit.weaponId}`)}
</span>
<span className={styles.kitGear}>
<Link
to={weaponParamsPage(
mySlugify(t(`weapons:SUB_${kit.subWeaponId}`, { lng: "en" })),
)}
className={styles.gearLink}
>
<SubWeaponImage subWeaponId={kit.subWeaponId} size={22} />
</Link>
<Link
to={weaponParamsPage(
mySlugify(
t(`weapons:SPECIAL_${kit.specialWeaponId}`, { lng: "en" }),
),
)}
className={styles.gearLink}
>
<SpecialWeaponImage
specialWeaponId={kit.specialWeaponId}
size={22}
/>
</Link>
</span>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,278 @@
.container {
margin-top: var(--s-4);
width: 100%;
max-height: 600px;
overflow: auto;
align-self: stretch;
}
.hiddenBar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1-5);
margin-bottom: var(--s-2);
padding: var(--s-1-5) var(--s-2);
background-color: var(--color-bg-high);
border: 1px solid var(--color-border);
border-radius: var(--radius-box);
}
.hiddenBarLabel {
flex-shrink: 0;
color: var(--color-text-high);
}
.hiddenBadge {
display: inline-flex;
align-items: center;
gap: var(--s-1);
}
.hiddenBadgeName {
font-size: var(--font-2xs);
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hideButton {
position: absolute;
top: var(--s-1);
right: var(--s-1);
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: var(--font-xs);
text-align: left;
& tbody {
& tr:hover {
background-color: var(--color-bg-high);
}
& td {
padding: var(--s-2) var(--s-3);
border-bottom: 1px solid var(--color-border);
}
& tr:first-child td {
border-top: 1px solid var(--color-border);
}
}
}
.thead {
position: sticky;
top: 0;
z-index: 1;
& th {
padding: var(--s-2);
background-color: var(--color-bg);
}
}
.paramHeader {
min-width: 180px;
}
.weaponHeader {
position: relative;
min-width: 90px;
padding: var(--s-2);
text-align: center;
}
.weaponHeaderContent {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
}
.weaponName {
font-size: var(--font-2xs);
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.categoryHeader {
background-color: var(--color-bg-high);
font-weight: var(--weight-bold);
font-size: var(--font-xs);
letter-spacing: 0.5px;
padding: var(--s-1-5) var(--s-2);
}
.paramName {
font-size: var(--font-xs);
min-width: 180px;
vertical-align: top;
}
.paramNameInner {
display: flex;
align-items: center;
gap: var(--s-1);
}
.expandableRow .paramName {
cursor: pointer;
&:hover {
background-color: var(--color-bg-high);
}
}
.paramNameText {
flex: 1;
}
.paramInfo {
display: flex;
align-items: center;
flex-shrink: 0;
}
.historyIndicator {
display: flex;
align-items: center;
flex-shrink: 0;
color: var(--color-accent);
}
.paramCell {
font-size: var(--font-xs);
vertical-align: top;
min-width: 90px;
}
.cellContent {
display: flex;
align-items: center;
gap: var(--s-1);
}
.currentValue {
font-variant-numeric: tabular-nums;
font-weight: var(--weight-semi);
}
.specialPointKits {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.specialPointKit {
display: flex;
align-items: center;
gap: var(--s-1);
}
.historyBadge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding-inline: var(--s-0-5);
border-radius: var(--radius-full);
background-color: var(--color-accent-low);
color: var(--color-accent);
font-size: var(--font-xs);
font-weight: var(--weight-bold);
}
.noValue {
color: var(--color-text-high);
}
.historyList {
margin-top: var(--s-1);
display: flex;
flex-direction: column;
gap: var(--s-0-5);
font-size: var(--font-xs);
border-top: 1px solid var(--color-border);
padding-top: var(--s-1);
}
.historyItem {
display: flex;
align-items: baseline;
gap: var(--s-1-5);
}
.historyVersion {
color: var(--color-text-high);
font-size: var(--font-2xs);
font-style: italic;
white-space: nowrap;
}
.historyValue {
font-variant-numeric: tabular-nums;
color: var(--color-text-high);
min-width: 1.75rem;
}
.specialPointHistory {
margin-top: var(--s-1);
display: flex;
flex-direction: column;
gap: var(--s-1-5);
border-top: 1px solid var(--color-border);
padding-top: var(--s-1);
}
.specialPointHistoryKit {
display: flex;
align-items: flex-start;
gap: var(--s-1);
& > div:first-child {
flex-shrink: 0;
}
}
.specialPointHistoryKitList {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--s-0-5);
font-size: var(--font-xs);
}
.legend {
margin-top: var(--s-3);
display: flex;
flex-direction: column;
gap: var(--s-1);
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.legendTitle {
font-weight: var(--weight-bold);
color: var(--color-text);
}
.legendItem {
margin: 0;
padding-left: var(--s-3);
position: relative;
&::before {
content: "•";
position: absolute;
left: var(--s-1);
}
}

View File

@@ -0,0 +1,679 @@
import clsx from "clsx";
import {
ChartColumnBig,
ChevronDown,
ChevronUp,
EyeOff,
X,
} from "lucide-react";
import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import * as R from "remeda";
import { SendouButton } from "~/components/elements/Button";
import {
SpecialWeaponImage,
SubWeaponImage,
WeaponImage,
} from "~/components/Image";
import { InfoPopover } from "~/components/InfoPopover";
import { translateDamageReceiver } from "~/features/object-damage-calculator/calculator-constants";
import type { DamageReceiver } from "~/features/object-damage-calculator/calculator-types";
import type {
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import { useSearchParam } from "~/modules/search-params/hooks";
import { mySlugify, weaponParamsPage } from "~/utils/urls";
import { getParamExplanation } from "../core/param-explanations";
import * as WeaponParams from "../core/WeaponParams";
import { weaponParamsSearchParams } from "../params-search-params";
import { SPECIAL_POINTS_PARAM_KEY } from "../weapon-params-constants";
import type {
DamageMultiplierWithHistory,
ParamComparisonEntry,
ParamValueWithHistory,
SpecialPointWithHistory,
WeaponParamKind,
WeaponParamsTableProps,
} from "../weapon-params-types";
import { weaponTranslationKey } from "../weapon-params-types";
import { ParamComparisonDialog } from "./ParamComparisonDialog";
import styles from "./WeaponParamsTable.module.css";
const DAMAGE_RATE_INFO_CATEGORY = "DamageRateInfo";
export function WeaponParamImage({
kind,
id,
size,
}: {
kind: WeaponParamKind;
id: number;
size: number;
}) {
if (kind === "sub") {
return <SubWeaponImage subWeaponId={id as SubWeaponId} size={size} />;
}
if (kind === "special") {
return (
<SpecialWeaponImage specialWeaponId={id as SpecialWeaponId} size={size} />
);
}
return (
<WeaponImage weaponSplId={id as MainWeaponId} variant="badge" size={size} />
);
}
// The display name and (English) url slug of a weapon, resolved from the right `weapons`
// translation key for the table's kind.
function useWeaponParamNaming(kind: WeaponParamKind) {
const { t } = useTranslation(["weapons"]);
const name = (id: number): string =>
t(weaponTranslationKey(kind, id) as never);
const slug = (id: number) =>
mySlugify(t(weaponTranslationKey(kind, id) as never, { lng: "en" }));
return { name, slug };
}
export function WeaponParamsTable({
kind,
currentWeaponId,
categoryWeaponIds,
weaponParams,
specialPoints,
damageMultipliers,
}: WeaponParamsTableProps) {
const { t } = useTranslation(["weapons", "common", "analyzer", "params"]);
const naming = useWeaponParamNaming(kind);
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
const [comparison, setComparison] = useState<{
label: string;
entries: ParamComparisonEntry[];
} | null>(null);
const paramDefinitions = WeaponParams.allParamKeys(weaponParams);
const paramsByCategory = R.groupBy(paramDefinitions, (def) => def.category);
const toggleRow = (fullKey: string) => {
setExpandedRows((prev) => {
const next = new Set(prev);
if (next.has(fullKey)) {
next.delete(fullKey);
} else {
next.add(fullKey);
}
return next;
});
};
const sortedWeaponIds = [
currentWeaponId,
...categoryWeaponIds.filter((id) => id !== currentWeaponId),
];
const [hiddenParamIds, setHiddenWeaponIds] = useSearchParam(
weaponParamsSearchParams,
"hidden",
);
const hiddenWeaponIds = hiddenParamIds.filter(
(id) => id !== currentWeaponId && categoryWeaponIds.includes(id),
);
const hiddenSet = new Set(hiddenWeaponIds);
const visibleWeaponIds = sortedWeaponIds.filter((id) => !hiddenSet.has(id));
const hideWeapon = (weaponId: number) =>
setHiddenWeaponIds([...hiddenWeaponIds, weaponId]);
const restoreWeapon = (weaponId: number) =>
setHiddenWeaponIds(hiddenWeaponIds.filter((id) => id !== weaponId));
const showAllWeapons = () => setHiddenWeaponIds([]);
const currentWeaponHasParam = (category: string, key: string) => {
return Boolean(
weaponParams[String(currentWeaponId)]?.categories[category]?.[key],
);
};
const rowHasHistory = (category: string, key: string) => {
return visibleWeaponIds.some((id) => {
const param = weaponParams[String(id)]?.categories[category]?.[key];
return param && WeaponParams.hasHistory(param);
});
};
// Bars are only drawn for plain numbers, so string-valued (or array/object) params and hidden
// weapons are skipped here. The compare button shows up only when at least two weapons remain.
const comparisonEntries = (
getValue: (weaponId: number) => number | string | undefined,
) =>
visibleWeaponIds
.map((weaponId) => {
const value = getValue(weaponId);
return typeof value === "number"
? { weaponId, value, name: naming.name(weaponId) }
: null;
})
.filter((entry): entry is ParamComparisonEntry => entry !== null);
const openComparison = (label: string, entries: ParamComparisonEntry[]) =>
setComparison({ label, entries });
return (
<>
<div className={styles.container}>
{hiddenWeaponIds.length > 0 ? (
<HiddenWeaponsBar
kind={kind}
hiddenWeaponIds={hiddenWeaponIds}
onRestore={restoreWeapon}
onShowAll={showAllWeapons}
/>
) : null}
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
<th className={styles.paramHeader}>
{t("params:header.parameter")}
</th>
{visibleWeaponIds.map((weaponId) => {
const weaponName = naming.name(weaponId);
const slug = naming.slug(weaponId);
return (
<th key={weaponId} className={clsx(styles.weaponHeader, {})}>
<Link
to={weaponParamsPage(slug)}
className={styles.weaponHeaderContent}
>
<WeaponParamImage kind={kind} id={weaponId} size={32} />
<span className={styles.weaponName}>{weaponName}</span>
</Link>
{weaponId !== currentWeaponId ? (
<SendouButton
variant="minimal-destructive"
size="miniscule"
shape="square"
icon={<X />}
className={styles.hideButton}
onPress={() => hideWeapon(weaponId)}
aria-label={t("common:actions.hide")}
testId={`hide-weapon-${weaponId}`}
/>
) : null}
</th>
);
})}
</tr>
</thead>
<tbody>
{kind === "main" && specialPoints ? (
<SpecialPointsRow
visibleWeaponIds={visibleWeaponIds}
specialPoints={specialPoints}
isExpanded={expandedRows.has(SPECIAL_POINTS_PARAM_KEY)}
onToggle={() => toggleRow(SPECIAL_POINTS_PARAM_KEY)}
/>
) : null}
{Object.entries(paramsByCategory).map(([category, params]) => {
const filteredParams = params.filter(({ key }) =>
currentWeaponHasParam(category, key),
);
if (filteredParams.length === 0) {
return null;
}
return (
<Fragment key={category}>
<tr>
<td
colSpan={visibleWeaponIds.length + 1}
className={styles.categoryHeader}
>
{category}
</td>
</tr>
{filteredParams.map(({ key, fullKey }) => {
const isExpanded = expandedRows.has(fullKey);
const hasHistory = rowHasHistory(category, key);
const explanation = getParamExplanation(category, key);
return (
<tr
key={fullKey}
className={clsx({
[styles.expandableRow]: hasHistory,
})}
>
<td
className={styles.paramName}
onClick={
hasHistory ? () => toggleRow(fullKey) : undefined
}
>
<div className={styles.paramNameInner}>
<span className={styles.paramNameText}>{key}</span>
{hasHistory ? (
<span className={styles.historyIndicator}>
{isExpanded ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)}
</span>
) : null}
{explanation ? (
// biome-ignore lint/a11y/noStaticElementInteractions: stops the help popover click from toggling the history row
<span
className={styles.paramInfo}
onClick={(e) => e.stopPropagation()}
>
<InfoPopover tiny>{explanation}</InfoPopover>
</span>
) : null}
<ComparisonButton
label={key}
entries={comparisonEntries(
(weaponId) =>
weaponParams[String(weaponId)]?.categories[
category
]?.[key]?.current,
)}
onCompare={openComparison}
/>
</div>
</td>
{visibleWeaponIds.map((weaponId) => (
<ParamCell
key={weaponId}
param={
weaponParams[String(weaponId)]?.categories[
category
]?.[key]
}
isExpanded={isExpanded}
/>
))}
</tr>
);
})}
</Fragment>
);
})}
{damageMultipliers ? (
<DamageRateInfoSection
visibleWeaponIds={visibleWeaponIds}
currentWeaponId={currentWeaponId}
damageMultipliers={damageMultipliers}
expandedRows={expandedRows}
onToggle={toggleRow}
comparisonEntries={comparisonEntries}
onCompare={openComparison}
/>
) : null}
</tbody>
</table>
</div>
<ParamsLegend />
{comparison ? (
<ParamComparisonDialog
kind={kind}
label={comparison.label}
entries={comparison.entries}
currentWeaponId={currentWeaponId}
onClose={() => setComparison(null)}
/>
) : null}
</>
);
}
function ComparisonButton({
label,
entries,
onCompare,
}: {
label: string;
entries: ParamComparisonEntry[];
onCompare: (label: string, entries: ParamComparisonEntry[]) => void;
}) {
const { t } = useTranslation(["params"]);
if (entries.length < 2) {
return null;
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: stops the compare button click from toggling the history row
<span className={styles.paramInfo} onClick={(e) => e.stopPropagation()}>
<SendouButton
variant="minimal"
size="miniscule"
shape="square"
icon={<ChartColumnBig />}
onPress={() => onCompare(label, entries)}
aria-label={t("params:compare.action")}
testId="compare-param"
/>
</span>
);
}
function ParamsLegend() {
const { t } = useTranslation(["params"]);
return (
<dl className={styles.legend}>
<dt className={styles.legendTitle}>{t("params:legend.title")}</dt>
<dd className={styles.legendItem}>{t("params:legend.damage")}</dd>
<dd className={styles.legendItem}>{t("params:legend.frames")}</dd>
<dd className={styles.legendItem}>{t("params:legend.powerUp")}</dd>
</dl>
);
}
function DamageRateInfoSection({
visibleWeaponIds,
currentWeaponId,
damageMultipliers,
expandedRows,
onToggle,
comparisonEntries,
onCompare,
}: {
visibleWeaponIds: number[];
currentWeaponId: number;
damageMultipliers: Record<string, DamageMultiplierWithHistory[]>;
expandedRows: Set<string>;
onToggle: (fullKey: string) => void;
comparisonEntries: (
getValue: (weaponId: number) => number | string | undefined,
) => ParamComparisonEntry[];
onCompare: (label: string, entries: ParamComparisonEntry[]) => void;
}) {
const { t } = useTranslation(["weapons", "analyzer", "game-misc"]);
const targets = (damageMultipliers[String(currentWeaponId)] ?? []).map(
(multiplier) => multiplier.target,
);
if (targets.length === 0) {
return null;
}
const multiplierFor = (weaponId: number, target: string) =>
damageMultipliers[String(weaponId)]?.find((m) => m.target === target);
return (
<Fragment>
<tr>
<td
colSpan={visibleWeaponIds.length + 1}
className={styles.categoryHeader}
>
{DAMAGE_RATE_INFO_CATEGORY}
</td>
</tr>
{targets.map((target) => {
const fullKey = `${DAMAGE_RATE_INFO_CATEGORY}.${target}`;
const isExpanded = expandedRows.has(fullKey);
const hasHistory = visibleWeaponIds.some(
(id) => (multiplierFor(id, target)?.history.length ?? 0) > 0,
);
const targetLabel = translateDamageReceiver(
t,
target as DamageReceiver,
);
return (
<tr
key={fullKey}
className={clsx({ [styles.expandableRow]: hasHistory })}
>
<td
className={styles.paramName}
onClick={hasHistory ? () => onToggle(fullKey) : undefined}
>
<div className={styles.paramNameInner}>
<span className={styles.paramNameText}>{targetLabel}</span>
<ComparisonButton
label={targetLabel}
entries={comparisonEntries(
(weaponId) => multiplierFor(weaponId, target)?.current,
)}
onCompare={onCompare}
/>
{hasHistory ? (
<span className={styles.historyIndicator}>
{isExpanded ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)}
</span>
) : null}
</div>
</td>
{visibleWeaponIds.map((weaponId) => (
<ParamCell
key={weaponId}
param={multiplierFor(weaponId, target)}
isExpanded={isExpanded}
/>
))}
</tr>
);
})}
</Fragment>
);
}
function HiddenWeaponsBar({
kind,
hiddenWeaponIds,
onRestore,
onShowAll,
}: {
kind: WeaponParamKind;
hiddenWeaponIds: number[];
onRestore: (weaponId: number) => void;
onShowAll: () => void;
}) {
const { t } = useTranslation(["weapons", "common"]);
const naming = useWeaponParamNaming(kind);
return (
<div className={styles.hiddenBar}>
<EyeOff
size={16}
className={styles.hiddenBarLabel}
aria-label={t("common:actions.showAll")}
/>
{hiddenWeaponIds.map((weaponId) => (
<SendouButton
key={weaponId}
variant="minimal"
size="miniscule"
className={styles.hiddenBadge}
onPress={() => onRestore(weaponId)}
testId={`restore-weapon-${weaponId}`}
>
<WeaponParamImage kind={kind} id={weaponId} size={20} />
<span className={styles.hiddenBadgeName}>
{naming.name(weaponId)}
</span>
<X size={12} />
</SendouButton>
))}
<SendouButton
variant="minimal"
size="miniscule"
onPress={onShowAll}
testId="show-all-weapons"
>
{t("common:actions.showAll")}
</SendouButton>
</div>
);
}
function SpecialPointsRow({
visibleWeaponIds,
specialPoints,
isExpanded,
onToggle,
}: {
visibleWeaponIds: number[];
specialPoints: Record<string, SpecialPointWithHistory[]>;
isExpanded: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation(["analyzer"]);
const hasHistory = visibleWeaponIds.some((id) =>
specialPoints[String(id)]?.some((kit) => kit.history.length > 0),
);
return (
<tr className={clsx({ [styles.expandableRow]: hasHistory })}>
<td
className={styles.paramName}
onClick={hasHistory ? onToggle : undefined}
>
<div className={styles.paramNameInner}>
<span className={styles.paramNameText}>
{t("analyzer:stat.specialPoints")}
</span>
{hasHistory ? (
<span className={styles.historyIndicator}>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
) : null}
</div>
</td>
{visibleWeaponIds.map((weaponId) => (
<SpecialPointCell
key={weaponId}
kits={specialPoints[String(weaponId)] ?? []}
isExpanded={isExpanded}
/>
))}
</tr>
);
}
function SpecialPointCell({
kits,
isExpanded,
}: {
kits: SpecialPointWithHistory[];
isExpanded: boolean;
}) {
if (kits.length === 0) {
return (
<td className={styles.paramCell}>
<span className={styles.noValue}>—</span>
</td>
);
}
const kitsWithHistory = kits.filter((kit) => kit.history.length > 0);
const showHistory = isExpanded && kitsWithHistory.length > 0;
const multiKit = kits.length > 1;
return (
<td className={styles.paramCell}>
<div className={styles.cellContent}>
<div className={styles.specialPointKits}>
{kits.map((kit) => (
<div key={kit.weaponId} className={styles.specialPointKit}>
{multiKit ? (
<WeaponImage
weaponSplId={kit.weaponId}
variant="badge"
size={18}
/>
) : null}
<span className={styles.currentValue}>{kit.current}</span>
</div>
))}
</div>
{kitsWithHistory.length > 0 && !isExpanded ? (
<span className={styles.historyBadge}>{kitsWithHistory.length}</span>
) : null}
</div>
{showHistory ? (
<div className={styles.specialPointHistory}>
{kitsWithHistory.map((kit) => (
<div key={kit.weaponId} className={styles.specialPointHistoryKit}>
{multiKit ? (
<WeaponImage
weaponSplId={kit.weaponId}
variant="badge"
size={16}
/>
) : null}
<div className={styles.specialPointHistoryKitList}>
{kit.history.toReversed().map(({ version, value }) => (
<div key={version} className={styles.historyItem}>
<span className={styles.historyValue}>{value}</span>
<span className={styles.historyVersion}>{version}</span>
</div>
))}
</div>
</div>
))}
</div>
) : null}
</td>
);
}
function ParamCell({
param,
isExpanded,
}: {
param: ParamValueWithHistory | undefined;
isExpanded: boolean;
}) {
if (!param) {
return (
<td className={styles.paramCell}>
<span className={styles.noValue}>—</span>
</td>
);
}
const showHistory = isExpanded && param.history.length > 0;
return (
<td className={styles.paramCell}>
<div className={styles.cellContent}>
<span className={styles.currentValue}>
{WeaponParams.formatValue(param.current)}
</span>
{param.history.length > 0 && !isExpanded ? (
<span className={styles.historyBadge}>{param.history.length}</span>
) : null}
</div>
{showHistory ? (
<div className={styles.historyList}>
{param.history.toReversed().map(({ version, value }) => (
<div key={version} className={styles.historyItem}>
<span className={styles.historyValue}>
{WeaponParams.formatValue(value)}
</span>
<span className={styles.historyVersion}>{version}</span>
</div>
))}
</div>
) : null}
</td>
);
}

View File

@@ -0,0 +1,16 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-4);
}
.tabs {
width: 100%;
}
.dataCredit {
font-size: var(--font-2xs);
color: var(--color-text-high);
text-decoration: underline;
}

View File

@@ -0,0 +1,108 @@
import { useTranslation } from "react-i18next";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { Main } from "~/components/Main";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { useSearchParam } from "~/modules/search-params/hooks";
import { weaponParamsSearchParams } from "../params-search-params";
import type {
DamageMultiplierWithHistory,
KitPatchHistory,
ParsedWeaponParams,
SpecialPointWithHistory,
WeaponKitInfo,
WeaponParamKind,
WeaponPatch,
} from "../weapon-params-types";
import { WeaponKits } from "./WeaponKits";
import { WeaponParamsTable } from "./WeaponParamsTable";
import styles from "./WeaponParamsView.module.css";
import {
WeaponPatchHistory,
WeaponPatchHistoryByKit,
} from "./WeaponPatchHistory";
export function WeaponParamsView({
kind,
weaponId,
categoryWeaponIds,
weaponParams,
specialPoints,
damageMultipliers,
versions,
patchHistory,
kitPatchHistories,
kits,
}: {
kind: WeaponParamKind;
weaponId: number;
categoryWeaponIds: number[];
weaponParams: Record<string, ParsedWeaponParams>;
specialPoints?: Record<string, SpecialPointWithHistory[]>;
damageMultipliers?: Record<string, DamageMultiplierWithHistory[]>;
versions: string[];
patchHistory: WeaponPatch[];
kitPatchHistories?: KitPatchHistory[];
kits?: WeaponKitInfo[];
}) {
const { t } = useTranslation(["params"]);
const [tab, setTab] = useSearchParam(weaponParamsSearchParams, "tab");
const viewedKitPatchCount =
kitPatchHistories?.find((kit) => kit.weaponId === weaponId)?.patches
.length ?? patchHistory.length;
return (
<Main className={styles.container} bigger>
{kits ? <WeaponKits kits={kits} /> : null}
<SendouTabs
selectedKey={tab}
onSelectionChange={(key) =>
setTab(key === "patches" ? "patches" : "params")
}
className={styles.tabs}
>
<SendouTabList>
<SendouTab id="params">{t("params:tab.params")}</SendouTab>
<SendouTab id="patches" number={viewedKitPatchCount}>
{t("params:tab.patches")}
</SendouTab>
</SendouTabList>
<SendouTabPanel id="params">
<WeaponParamsTable
kind={kind}
currentWeaponId={weaponId}
categoryWeaponIds={categoryWeaponIds}
weaponParams={weaponParams}
specialPoints={specialPoints}
damageMultipliers={damageMultipliers}
versions={versions}
/>
</SendouTabPanel>
<SendouTabPanel id="patches">
{kitPatchHistories && kitPatchHistories.length > 0 ? (
<WeaponPatchHistoryByKit
kits={kitPatchHistories}
defaultWeaponId={weaponId as MainWeaponId}
/>
) : (
<WeaponPatchHistory patches={patchHistory} />
)}
</SendouTabPanel>
</SendouTabs>
<a
href="https://leanny.github.io/"
target="_blank"
rel="noopener noreferrer"
className={styles.dataCredit}
>
{t("params:dataCredit.lean")}
</a>
</Main>
);
}

View File

@@ -0,0 +1,176 @@
.kitHistory {
display: flex;
flex-direction: column;
gap: var(--s-3);
width: 100%;
align-self: stretch;
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2) var(--s-4);
}
.kitChip {
display: flex;
align-items: center;
gap: var(--s-1);
}
.container {
display: flex;
gap: var(--s-3);
width: 100%;
overflow-x: auto;
padding-bottom: var(--s-2);
align-self: stretch;
}
.divider {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
&:not(:first-child) {
margin-block: var(--s-3);
}
}
.dividerLabel {
display: flex;
align-items: center;
gap: var(--s-1);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.column {
display: flex;
flex-direction: column;
gap: var(--s-2);
flex: 0 0 auto;
width: 15rem;
}
.header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s-1);
padding: var(--s-3) var(--s-2);
border: 2px solid var(--color-border-high);
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
}
.version {
font-size: var(--font-xl);
font-weight: var(--weight-extra);
line-height: 1;
}
.date {
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.changes {
display: flex;
flex-direction: column;
gap: var(--s-1-5);
}
.change {
min-height: 2rem;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--s-1) var(--s-2);
padding: var(--s-1) var(--s-2);
border: 1px solid var(--color-border);
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
font-size: var(--font-2xs);
&.wide,
&.incoming {
flex-direction: column;
align-items: stretch;
}
&.buff {
border-color: var(--color-success);
background-color: var(--color-success-low);
}
&.nerf {
border-color: var(--color-error);
background-color: var(--color-error-low);
}
}
.changeName {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: var(--s-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.change.wide & {
white-space: normal;
}
}
.changeIcon {
flex-shrink: 0;
}
.changeValues {
display: flex;
align-items: center;
gap: var(--s-1);
flex-shrink: 0;
font-weight: var(--weight-semi);
font-variant-numeric: tabular-nums;
.change.wide & {
flex-wrap: wrap;
}
}
.attackers {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1) var(--s-2);
min-width: 0;
}
.attackerIcons {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1);
}
.attackerSuffix {
font-weight: var(--weight-semi);
}
.arrow {
color: var(--color-text-high);
}
.empty {
color: var(--color-text-high);
font-size: var(--font-sm);
padding: var(--s-4);
}

View File

@@ -0,0 +1,381 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Divider } from "~/components/Divider";
import {
SendouChipRadio,
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
import { SendouSwitch } from "~/components/elements/Switch";
import {
SpecialWeaponImage,
SubWeaponImage,
WeaponImage,
} from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import {
damageReceiverSuffix,
translateDamageReceiver,
} from "~/features/object-damage-calculator/calculator-constants";
import type { DamageReceiver } from "~/features/object-damage-calculator/calculator-types";
import type {
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import { useSearchParam } from "~/modules/search-params/hooks";
import * as WeaponParams from "../core/WeaponParams";
import { weaponParamsSearchParams } from "../params-search-params";
import {
DAMAGE_MULTIPLIER_PARAM_KEY,
INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
SPECIAL_POINTS_PARAM_KEY,
} from "../weapon-params-constants";
import type {
IncomingDamageAttackers,
KitPatchHistory,
PatchChange,
WeaponPatch,
} from "../weapon-params-types";
import styles from "./WeaponPatchHistory.module.css";
const PATCH_DATE_OPTIONS: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "short",
year: "numeric",
};
export function WeaponPatchHistory({ patches }: { patches: WeaponPatch[] }) {
const { t } = useTranslation(["params"]);
if (patches.length === 0) {
return <div className={styles.empty}>{t("params:noPatches")}</div>;
}
return (
<div className={styles.container}>
{patches.map((patch) => (
<div key={patch.version} className={styles.column}>
<PatchColumnHeader version={patch.version} date={patch.date} />
<div className={styles.changes}>
{patch.changes.map((change, i) => (
<ChangeBadge key={changeKey(change, i)} change={change} />
))}
</div>
</div>
))}
</div>
);
}
/**
* Patch history of a main weapon shown one kit at a time: the selected kit's main weapon, sub
* weapon and special weapon changes are grouped under dividers within the same patch column. Sub
* and special weapon changes can be toggled off.
*/
export function WeaponPatchHistoryByKit({
kits,
defaultWeaponId,
}: {
kits: KitPatchHistory[];
defaultWeaponId: MainWeaponId;
}) {
const { t } = useTranslation(["params"]);
const kitIds = kits.map((kit) => kit.weaponId);
const [kit, setKit] = useSearchParam(weaponParamsSearchParams, "kit");
const selectedWeaponId =
typeof kit === "number" && kitIds.includes(kit) ? kit : defaultWeaponId;
const selectKit = (weaponId: MainWeaponId) => {
setKit(weaponId === defaultWeaponId ? null : weaponId);
};
const [showSubSpecial, setShowSubSpecial] = useSearchParam(
weaponParamsSearchParams,
"kitExtras",
);
const selectedKit =
kits.find((kit) => kit.weaponId === selectedWeaponId) ?? kits[0];
const patches = selectedKit.patches
.map((patch) => ({
...patch,
changes: showSubSpecial
? patch.changes
: patch.changes.filter((change) => change.source === "main"),
}))
.filter((patch) => patch.changes.length > 0);
return (
<div className={styles.kitHistory}>
<div className={styles.controls}>
{kits.length > 1 ? (
<KitFilter
kits={kits}
selectedWeaponId={selectedKit.weaponId}
onSelect={selectKit}
/>
) : null}
<SendouSwitch isSelected={showSubSpecial} onChange={setShowSubSpecial}>
{t("params:patches.showSubSpecial")}
</SendouSwitch>
</div>
{patches.length === 0 ? (
<div className={styles.empty}>{t("params:noPatches")}</div>
) : (
<div className={styles.container}>
{patches.map((patch) => (
<KitPatchColumn
key={patch.version}
patch={patch}
kit={selectedKit}
/>
))}
</div>
)}
</div>
);
}
function KitFilter({
kits,
selectedWeaponId,
onSelect,
}: {
kits: KitPatchHistory[];
selectedWeaponId: MainWeaponId;
onSelect: (weaponId: MainWeaponId) => void;
}) {
const { t } = useTranslation(["weapons"]);
return (
<SendouChipRadioGroup>
{kits.map((kit) => (
<SendouChipRadio
key={kit.weaponId}
name="patch-history-kit"
value={String(kit.weaponId)}
checked={kit.weaponId === selectedWeaponId}
onChange={(value) => onSelect(Number(value) as MainWeaponId)}
>
<span className={styles.kitChip}>
<WeaponImage weaponSplId={kit.weaponId} variant="badge" size={20} />
{t(`weapons:MAIN_${kit.weaponId}`)}
</span>
</SendouChipRadio>
))}
</SendouChipRadioGroup>
);
}
function KitPatchColumn({
patch,
kit,
}: {
patch: WeaponPatch;
kit: KitPatchHistory;
}) {
const mainChanges = patch.changes.filter(
(change) => change.source === "main",
);
const subChanges = patch.changes.filter((change) => change.source === "sub");
const specialChanges = patch.changes.filter(
(change) => change.source === "special",
);
return (
<div className={styles.column}>
<PatchColumnHeader version={patch.version} date={patch.date} />
<div className={styles.changes}>
{mainChanges.map((change, i) => (
<ChangeBadge key={changeKey(change, i)} change={change} />
))}
{subChanges.length > 0 ? (
<>
<SubWeaponDivider subWeaponId={kit.subWeaponId} />
{subChanges.map((change, i) => (
<ChangeBadge key={changeKey(change, i)} change={change} />
))}
</>
) : null}
{specialChanges.length > 0 ? (
<>
<SpecialWeaponDivider specialWeaponId={kit.specialWeaponId} />
{specialChanges.map((change, i) => (
<ChangeBadge key={changeKey(change, i)} change={change} />
))}
</>
) : null}
</div>
</div>
);
}
function SubWeaponDivider({ subWeaponId }: { subWeaponId: SubWeaponId }) {
const { t } = useTranslation(["weapons"]);
return (
<Divider smallText className={styles.divider}>
<span className={styles.dividerLabel}>
<SubWeaponImage subWeaponId={subWeaponId} size={18} />
{t(`weapons:SUB_${subWeaponId}`)}
</span>
</Divider>
);
}
function SpecialWeaponDivider({
specialWeaponId,
}: {
specialWeaponId: SpecialWeaponId;
}) {
const { t } = useTranslation(["weapons"]);
return (
<Divider smallText className={styles.divider}>
<span className={styles.dividerLabel}>
<SpecialWeaponImage specialWeaponId={specialWeaponId} size={18} />
{t(`weapons:SPECIAL_${specialWeaponId}`)}
</span>
</Divider>
);
}
function PatchColumnHeader({
version,
date,
}: {
version: string;
date: string | null;
}) {
return (
<div className={styles.header}>
<div className={styles.version}>{version}</div>
{date ? (
<LocaleTime
date={new Date(date)}
options={PATCH_DATE_OPTIONS}
className={styles.date}
/>
) : null}
</div>
);
}
function changeKey(change: PatchChange, index: number) {
return `${change.category}.${change.key}.${change.weaponId ?? ""}.${change.source ?? ""}.${index}`;
}
function ChangeBadge({ change }: { change: PatchChange }) {
const { t } = useTranslation(["analyzer", "weapons", "game-misc"]);
if (
change.category === INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY &&
change.attackers
) {
return <IncomingChangeBadge change={change} attackers={change.attackers} />;
}
const isSpecialPoints = change.category === SPECIAL_POINTS_PARAM_KEY;
const isDamageMultiplier = change.category === DAMAGE_MULTIPLIER_PARAM_KEY;
// Damage falloff curves serialize to long "damage @ distance" lists that need their own line.
const isWideValue =
typeof change.from === "string" && change.from.includes("@");
const label = isSpecialPoints
? t("analyzer:stat.specialPoints")
: isDamageMultiplier
? translateDamageReceiver(t, change.key as DamageReceiver)
: change.key;
return (
<div
className={clsx(styles.change, {
[styles.buff]: change.kind === "buff",
[styles.nerf]: change.kind === "nerf",
[styles.wide]: isWideValue,
})}
title={
isSpecialPoints || isDamageMultiplier
? undefined
: `${change.category}.${change.key}`
}
>
<span className={styles.changeName}>
{isSpecialPoints && change.weaponId ? (
<WeaponImage
weaponSplId={change.weaponId}
variant="badge"
size={20}
className={styles.changeIcon}
/>
) : null}
{label}
</span>
<span className={styles.changeValues}>
{WeaponParams.formatValue(change.from)}
<span className={styles.arrow}>→</span>
{WeaponParams.formatValue(change.to)}
</span>
</div>
);
}
/**
* An incoming damage multiplier change: a set of attacking weapons whose shared damage rate against
* the page's sub or special weapon changed. Shows the attacking weapons' icons (with a suffix for
* multi-part objects, e.g. a Big Bubbler's shield vs. weak point) and the from→to rate.
*/
function IncomingChangeBadge({
change,
attackers,
}: {
change: PatchChange;
attackers: IncomingDamageAttackers;
}) {
const { t } = useTranslation(["analyzer", "weapons", "game-misc"]);
const suffix = damageReceiverSuffix(t, change.key as DamageReceiver);
return (
<div
className={clsx(styles.change, styles.incoming, {
[styles.buff]: change.kind === "buff",
[styles.nerf]: change.kind === "nerf",
})}
title={translateDamageReceiver(t, change.key as DamageReceiver)}
>
<div className={styles.attackers}>
<span className={styles.attackerIcons}>
{attackers.mainWeaponIds.map((id) => (
<WeaponImage
key={`m-${id}`}
weaponSplId={id}
variant="badge"
size={20}
/>
))}
{attackers.subWeaponIds.map((id) => (
<SubWeaponImage key={`s-${id}`} subWeaponId={id} size={20} />
))}
{attackers.specialWeaponIds.map((id) => (
<SpecialWeaponImage
key={`x-${id}`}
specialWeaponId={id}
size={20}
/>
))}
</span>
{suffix ? (
<span className={styles.attackerSuffix}>{suffix}</span>
) : null}
</div>
<span className={styles.changeValues}>
{WeaponParams.formatValue(change.from)}
<span className={styles.arrow}>→</span>
{WeaponParams.formatValue(change.to)}
</span>
</div>
);
}

View File

@@ -0,0 +1,473 @@
import { describe, expect, test } from "vitest";
import {
DAMAGE_MULTIPLIER_PARAM_KEY,
INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
SPECIAL_POINTS_PARAM_KEY,
} from "../weapon-params-constants";
import type {
DamageMultiplierWithHistory,
ParsedWeaponParams,
} from "../weapon-params-types";
import { classifyParamChange } from "./param-directions";
import * as WeaponParams from "./WeaponParams";
const VERSIONS = ["1.0.0", "2.0.0", "3.0.0"];
const emptyParsed = (weaponId: number): ParsedWeaponParams => ({
weaponId,
categories: {},
});
const row = (overrides: {
mainWeaponIds?: number[];
subWeaponIds?: number[];
specialWeaponIds?: number[];
targets: DamageMultiplierWithHistory[];
}) => ({
mainWeaponIds: [],
subWeaponIds: [],
specialWeaponIds: [],
...overrides,
});
describe("damageMultipliersForWeapon", () => {
test("collects only rows applying to the weapon for the given kind", () => {
const rows = {
a: row({
specialWeaponIds: [11],
targets: [{ target: "Chariot", current: 2, history: [] }],
}),
b: row({
specialWeaponIds: [12],
targets: [{ target: "ShockSonar", current: 2, history: [] }],
}),
};
const result = WeaponParams.damageMultipliersForWeapon(rows, 11, "special");
expect(result.map((m) => m.target)).toEqual(["Chariot"]);
});
test("de-duplicates identical target histories shared across rows", () => {
const sharedTarget: DamageMultiplierWithHistory = {
target: "GreatBarrier_Barrier",
current: 1.4,
history: [{ version: "1.0.0", value: 2.8 }],
};
const rows = {
bullet: row({ specialWeaponIds: [10], targets: [sharedTarget] }),
bombCore: row({
specialWeaponIds: [10],
targets: [{ ...sharedTarget }],
}),
};
const result = WeaponParams.damageMultipliersForWeapon(rows, 10, "special");
expect(result).toHaveLength(1);
});
test("merges several rows of the same target into the most informative entry", () => {
const rows = {
swing: row({
specialWeaponIds: [11],
targets: [{ target: "Chariot", current: 4.5, history: [] }],
}),
throwBombCore: row({
specialWeaponIds: [11],
targets: [
{
target: "Chariot",
current: 3.273,
history: [
{ version: "2.0.0", value: 2 },
{ version: "3.0.0", value: 6 },
],
},
],
}),
};
const result = WeaponParams.damageMultipliersForWeapon(rows, 11, "special");
expect(result).toHaveLength(1);
expect(result[0].history).toHaveLength(2);
expect(result[0].current).toBe(3.273);
});
test("orders entries like DAMAGE_RECEIVERS", () => {
const rows = {
a: row({
specialWeaponIds: [11],
targets: [
{ target: "Wsb_Shield", current: 2, history: [] },
{ target: "Chariot", current: 3, history: [] },
],
}),
};
const result = WeaponParams.damageMultipliersForWeapon(rows, 11, "special");
// Chariot precedes Wsb_Shield in DAMAGE_RECEIVERS
expect(result.map((m) => m.target)).toEqual(["Chariot", "Wsb_Shield"]);
});
});
describe("patchHistory damage multipliers", () => {
const buildWith = (multiplier: DamageMultiplierWithHistory) =>
WeaponParams.patchHistory(emptyParsed(11), VERSIONS, [], [multiplier]);
test("attributes a change to the version after the recorded one and flags a higher rate as a buff", () => {
const patches = buildWith({
target: "Wsb_Shield",
current: 2.2,
history: [{ version: "1.0.0", value: 2 }],
});
expect(patches).toHaveLength(1);
expect(patches[0].version).toBe("2.0.0");
expect(patches[0].changes).toEqual([
{
category: DAMAGE_MULTIPLIER_PARAM_KEY,
key: "Wsb_Shield",
from: 2,
to: 2.2,
kind: "buff",
},
]);
});
test("flags a lower rate as a nerf", () => {
const patches = buildWith({
target: "NiceBall_Armor",
current: 1.82,
history: [{ version: "2.0.0", value: 2.6 }],
});
expect(patches).toHaveLength(1);
expect(patches[0].version).toBe("3.0.0");
expect(patches[0].changes[0].kind).toBe("nerf");
});
});
describe("incomingDamageMultipliersForWeapon", () => {
test("collects other weapons' rates against the weapon's receiver targets", () => {
const rows = {
fromSpecial: row({
specialWeaponIds: [10],
targets: [
{
target: "GreatBarrier_Barrier",
current: 1.4,
history: [{ version: "1.0.0", value: 2.8 }],
},
],
}),
fromMains: row({
mainWeaponIds: [200, 201],
targets: [
{
target: "GreatBarrier_WeakPoint",
current: 3,
history: [{ version: "2.0.0", value: 2 }],
},
],
}),
unrelated: row({
mainWeaponIds: [400],
targets: [{ target: "Chariot", current: 2, history: [] }],
}),
};
// special id 2 is Big Bubbler (GreatBarrier_Barrier + GreatBarrier_WeakPoint)
const result = WeaponParams.incomingDamageMultipliersForWeapon(
rows,
2,
"special",
);
expect(result.map((m) => m.target)).toEqual([
"GreatBarrier_Barrier",
"GreatBarrier_WeakPoint",
]);
expect(result[1].attackers.mainWeaponIds).toEqual([200, 201]);
});
test("de-duplicates the same attacker group and target across rows", () => {
const target = {
target: "GreatBarrier_Barrier",
current: 1.4,
history: [{ version: "1.0.0", value: 2.8 }],
};
const rows = {
bullet: row({ specialWeaponIds: [10], targets: [target] }),
bombCore: row({ specialWeaponIds: [10], targets: [{ ...target }] }),
};
const result = WeaponParams.incomingDamageMultipliersForWeapon(
rows,
2,
"special",
);
expect(result).toHaveLength(1);
});
test("returns nothing for a weapon that is not a damageable object", () => {
const rows = {
a: row({
specialWeaponIds: [10],
targets: [
{
target: "GreatBarrier_Barrier",
current: 1,
history: [{ version: "1.0.0", value: 2 }],
},
],
}),
};
// special id 1 (Trizooka) is not in INCOMING_DAMAGE_RECEIVERS
expect(
WeaponParams.incomingDamageMultipliersForWeapon(rows, 1, "special"),
).toEqual([]);
});
});
describe("patchHistory incoming damage multipliers", () => {
test("flags a higher incoming rate as a nerf to the defending weapon and carries the attackers", () => {
const patches = WeaponParams.patchHistory(
emptyParsed(2),
VERSIONS,
[],
[],
[
{
target: "GreatBarrier_Barrier",
attackers: {
mainWeaponIds: [200],
subWeaponIds: [],
specialWeaponIds: [],
},
current: 2.8,
history: [{ version: "1.0.0", value: 1.4 }],
},
],
);
expect(patches).toHaveLength(1);
expect(patches[0].version).toBe("2.0.0");
expect(patches[0].changes[0]).toMatchObject({
category: INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
key: "GreatBarrier_Barrier",
from: 1.4,
to: 2.8,
kind: "nerf",
attackers: { mainWeaponIds: [200] },
});
});
test("flags a lower incoming rate as a buff to the defending weapon", () => {
const patches = WeaponParams.patchHistory(
emptyParsed(2),
VERSIONS,
[],
[],
[
{
target: "GreatBarrier_Barrier",
attackers: {
mainWeaponIds: [200],
subWeaponIds: [],
specialWeaponIds: [],
},
current: 1.4,
history: [{ version: "1.0.0", value: 2.8 }],
},
],
);
expect(patches[0].changes[0].kind).toBe("buff");
});
});
describe("parse damage falloff curves", () => {
test("serializes a DistanceDamage array into a scaled damage @ distance string", () => {
const parsed = WeaponParams.parse(
0,
{
BlastParam: {
DistanceDamage: [
{ Damage: 1800, Distance: 3.6 },
{ Damage: 300, Distance: 7 },
],
},
},
VERSIONS,
);
expect(parsed.categories.BlastParam.DistanceDamage.current).toBe(
"180 @ 3.6, 30 @ 7",
);
});
test("flattens nested breakpoint arrays", () => {
const parsed = WeaponParams.parse(
0,
{
BlastParam: {
DistanceDamage: [
[{ Damage: 1800, Distance: 3.6 }],
[{ Damage: 300, Distance: 7 }],
],
},
},
VERSIONS,
);
expect(parsed.categories.BlastParam.DistanceDamage.current).toBe(
"180 @ 3.6, 30 @ 7",
);
});
test("tracks per-version history of a damage falloff curve", () => {
const parsed = WeaponParams.parse(
0,
{
BlastParam: {
DistanceDamage: [{ Damage: 600, Distance: 4 }],
"DistanceDamage@2.0.0": [{ Damage: 400, Distance: 4 }],
},
},
VERSIONS,
);
expect(parsed.categories.BlastParam.DistanceDamage.history).toEqual([
{ version: "2.0.0", value: "40 @ 4" },
]);
});
});
describe("classifyParamChange damage falloff curves", () => {
test("flags higher damage as a buff", () => {
expect(
classifyParamChange("BlastParam", "DistanceDamage", "40 @ 4", "60 @ 4"),
).toBe("buff");
});
test("flags lower damage as a nerf", () => {
expect(
classifyParamChange("BlastParam", "DistanceDamage", "60 @ 4", "40 @ 4"),
).toBe("nerf");
});
test("flags longer reach at the same damage as a buff", () => {
expect(
classifyParamChange(
"BlastParam",
"DistanceDamage",
"70 @ 0.94, 50 @ 3.3",
"70 @ 1.01, 50 @ 3.37",
),
).toBe("buff");
});
test("flags shorter reach at the same damage as a nerf", () => {
expect(
classifyParamChange(
"BlastParam",
"DistanceDamage",
"70 @ 1.01, 50 @ 3.37",
"70 @ 0.975, 50 @ 3.37",
),
).toBe("nerf");
});
test("is neutral when damage rises but reach shrinks", () => {
expect(
classifyParamChange("BlastParam", "DistanceDamage", "60 @ 4", "70 @ 3.5"),
).toBe("neutral");
});
test("is neutral when the curve gains or loses a breakpoint", () => {
expect(
classifyParamChange(
"BlastParam",
"DistanceDamage",
"60 @ 4",
"60 @ 4, 30 @ 8",
),
).toBe("neutral");
});
});
describe("kitPatchHistories", () => {
const kitHistory = () =>
WeaponParams.kitPatchHistories({
mainParsed: emptyParsed(11),
versions: VERSIONS,
kits: [{ weaponId: 11, subWeaponId: 1, specialWeaponId: 2 }],
specialPointsByKit: {
"11": {
weaponId: 11,
current: 180,
history: [{ version: "1.0.0", value: 200 }],
},
},
mainDamageMultipliers: [
{
target: "Wsb_Shield",
current: 2.2,
history: [{ version: "1.0.0", value: 2 }],
},
],
subParams: { "1": emptyParsed(1) },
subDamageMultipliers: {
"1": [
{
target: "Chariot",
current: 3,
history: [{ version: "1.0.0", value: 2 }],
},
],
},
subIncomingDamageMultipliers: {},
specialParams: { "2": emptyParsed(2) },
specialDamageMultipliers: {
"2": [
{
target: "NiceBall_Armor",
current: 1.5,
history: [{ version: "2.0.0", value: 2 }],
},
],
},
specialIncomingDamageMultipliers: {},
});
test("folds the kit's main, sub and special weapon changes into one descending history", () => {
const [history] = kitHistory();
expect(history.weaponId).toBe(11);
expect(history.patches.map((patch) => patch.version)).toEqual([
"3.0.0",
"2.0.0",
]);
});
test("tags each change with its source and groups main before sub before special", () => {
const [history] = kitHistory();
const v2 = history.patches.find((patch) => patch.version === "2.0.0")!;
// special points + main damage rate (both main), then the sub weapon's damage rate
expect(v2.changes.map((change) => change.source)).toEqual([
"main",
"main",
"sub",
]);
expect(v2.changes[0].category).toBe(SPECIAL_POINTS_PARAM_KEY);
const v3 = history.patches.find((patch) => patch.version === "3.0.0")!;
expect(v3.changes.map((change) => change.source)).toEqual(["special"]);
});
});

View File

@@ -0,0 +1,774 @@
import { PATCHES } from "~/features/builds/builds-constants";
import { DAMAGE_RECEIVERS } from "~/features/object-damage-calculator/calculator-constants";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
mainWeaponIds,
weaponCategories,
weaponIdToBaseWeaponId,
weaponIdToType,
} from "~/modules/in-game-lists/weapon-ids";
import {
DAMAGE_MULTIPLIER_PARAM_KEY,
INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
INCOMING_DAMAGE_RECEIVERS,
SPECIAL_POINTS_PARAM_KEY,
} from "../weapon-params-constants";
import type {
DamageMultiplierWithHistory,
IncomingDamageAttackers,
IncomingDamageMultiplierWithHistory,
KitPatchHistory,
ParamDefinition,
ParamValueWithHistory,
ParsedWeaponParams,
PatchChange,
SpecialPointWithHistory,
WeaponKitInfo,
WeaponParamKind,
WeaponPatch,
} from "../weapon-params-types";
import { classifyParamChange } from "./param-directions";
/**
* Shape of the committed `all-version-*-params.json` data files: a map of weapon id to its raw
* per-version params, the ordered list of tracked game versions, and (weapons only) special
* points history.
*/
export interface AllVersionParams {
metadata: { versions: string[] };
weapons: Record<string, Record<string, Record<string, unknown>>>;
specialPoints?: Record<
string,
{ history: Array<{ version: string; value: number }> }
>;
}
function parseParamKey(key: string): {
baseKey: string;
version: string | null;
} {
const atIndex = key.indexOf("@");
if (atIndex === -1) {
return { baseKey: key, version: null };
}
return {
baseKey: key.slice(0, atIndex),
version: key.slice(atIndex + 1),
};
}
interface DistanceDamageBreakpoint {
Damage: number;
Distance: number;
}
function isDistanceDamageBreakpoint(
value: unknown,
): value is DistanceDamageBreakpoint {
return (
typeof value === "object" &&
value !== null &&
typeof (value as DistanceDamageBreakpoint).Damage === "number" &&
typeof (value as DistanceDamageBreakpoint).Distance === "number"
);
}
/**
* Whether `value` is a damage falloff curve: an array of {@link DistanceDamageBreakpoint}, with
* each entry possibly being a nested array of breakpoints (e.g. fizzy bomb bounces).
*/
function isDistanceDamageArray(
value: unknown[],
): value is Array<DistanceDamageBreakpoint | DistanceDamageBreakpoint[]> {
return (
value.length > 0 &&
value.every(
(el) =>
isDistanceDamageBreakpoint(el) ||
(Array.isArray(el) &&
el.length > 0 &&
el.every(isDistanceDamageBreakpoint)),
)
);
}
/**
* Serializes a damage falloff curve into a compact `"<damage> @ <distance>"` string (damage
* scaled to displayed HP, i.e. divided by 10) so its per-version changes flow through the same
* scalar param pipeline as plain values. Nested breakpoint arrays are flattened.
*/
function formatDistanceDamageArray(
value: Array<DistanceDamageBreakpoint | DistanceDamageBreakpoint[]>,
): string {
return value
.flat()
.map(
(breakpoint) =>
`${formatValue(breakpoint.Damage / 10)} @ ${formatValue(breakpoint.Distance)}`,
)
.join(", ");
}
function flattenScalarParams(
params: Record<string, unknown>,
prefix = "",
): Array<[string, number | string]> {
const result: Array<[string, number | string]> = [];
for (const [key, value] of Object.entries(params)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === "number" || typeof value === "string") {
result.push([fullKey, value]);
} else if (Array.isArray(value)) {
// Damage falloff curves and arrays of plain numbers/strings (e.g.
// SplashSpawnParam.ForceSpawnNearestAddNumArray) are kept as a single joined string so
// their per-version changes still show up. Other arrays of objects are too structured
// to represent this way and are skipped.
if (isDistanceDamageArray(value)) {
result.push([fullKey, formatDistanceDamageArray(value)]);
} else if (
value.length > 0 &&
value.every((el) => typeof el === "number" || typeof el === "string")
) {
result.push([
fullKey,
`[${value.map((el) => formatValue(el)).join(", ")}]`,
]);
}
} else if (typeof value === "object" && value !== null) {
result.push(
...flattenScalarParams(value as Record<string, unknown>, fullKey),
);
}
}
return result;
}
/**
* Parses a single weapon's raw per-version params into the {@link ParsedWeaponParams} shape: each
* parameter's current value plus its tracked history, grouped by category.
*/
export function parse(
weaponId: number,
rawParams: Record<string, Record<string, unknown>>,
versions: string[],
): ParsedWeaponParams {
const categories: Record<string, Record<string, ParamValueWithHistory>> = {};
for (const [categoryName, categoryParams] of Object.entries(rawParams)) {
if (
typeof categoryParams !== "object" ||
categoryParams === null ||
Object.keys(categoryParams).length === 0
) {
continue;
}
const parsedParams: Record<string, ParamValueWithHistory> = {};
const paramHistory: Record<
string,
{ current: number | string; versions: Map<string, number | string> }
> = {};
for (const [key, value] of flattenScalarParams(categoryParams)) {
const { baseKey, version } = parseParamKey(key);
if (!paramHistory[baseKey]) {
paramHistory[baseKey] = {
current: value,
versions: new Map(),
};
}
if (version === null) {
paramHistory[baseKey].current = value;
} else {
paramHistory[baseKey].versions.set(version, value);
}
}
for (const [baseKey, data] of Object.entries(paramHistory)) {
const history: Array<{ version: string; value: number | string }> = [];
for (const version of versions) {
const historicalValue = data.versions.get(version);
if (historicalValue !== undefined) {
history.push({ version, value: historicalValue });
}
}
parsedParams[baseKey] = {
current: data.current,
history,
};
}
if (Object.keys(parsedParams).length > 0) {
categories[categoryName] = parsedParams;
}
}
return { weaponId, categories };
}
/**
* Parses the params of every given weapon id from a static all-version params data file, keyed by
* weapon id (as a string). Ids with no entry in the data are skipped. `toDataKey` maps a weapon id
* to the id its params are stored under — main weapons share params with their base weapon, while
* subs and specials use their own id (the default identity mapping).
*/
export function parseMany<Id extends number>(
ids: readonly Id[],
data: AllVersionParams,
toDataKey: (id: Id) => number = (id) => id,
): Record<string, ParsedWeaponParams> {
const result: Record<string, ParsedWeaponParams> = {};
for (const id of ids) {
const rawParams = data.weapons[String(toDataKey(id))];
if (rawParams) {
result[String(id)] = parse(id, rawParams, data.metadata.versions);
}
}
return result;
}
/**
* Collects every distinct `${category}.${key}` parameter present across the given weapons, sorted
* by category then key, for use as the comparison table's row definitions.
*/
export function allParamKeys(
weaponParams: Record<string, ParsedWeaponParams>,
): ParamDefinition[] {
const seenKeys = new Set<string>();
const definitions: ParamDefinition[] = [];
for (const parsed of Object.values(weaponParams)) {
for (const [category, params] of Object.entries(parsed.categories)) {
for (const key of Object.keys(params)) {
const fullKey = `${category}.${key}`;
if (!seenKeys.has(fullKey)) {
seenKeys.add(fullKey);
definitions.push({ category, key, fullKey });
}
}
}
}
definitions.sort((a, b) => {
if (a.category !== b.category) {
return a.category.localeCompare(b.category);
}
return a.key.localeCompare(b.key);
});
return definitions;
}
function getWeaponCategory(weaponId: MainWeaponId) {
return weaponCategories.find((cat) =>
(cat.weaponIds as readonly number[]).includes(weaponId),
);
}
/**
* Returns the base main weapon ids of the given weapon's category, used as the columns its params
* are compared against. A non-base weapon is kept first, followed by the other base weapons.
*/
export function categoryWeaponIds(weaponId: MainWeaponId): MainWeaponId[] {
const category = getWeaponCategory(weaponId);
if (!category) {
return [weaponId];
}
const baseWeapons = (category.weaponIds as readonly MainWeaponId[]).filter(
(id) => weaponIdToType(id) === "BASE",
);
if (baseWeapons.includes(weaponId)) {
return baseWeapons;
}
const currentWeaponBaseId = weaponIdToBaseWeaponId(weaponId);
return [weaponId, ...baseWeapons.filter((id) => id !== currentWeaponBaseId)];
}
/**
* Returns the main weapon ids that are kit siblings of the given weapon, i.e. they share
* the same base weapon (e.g. a weapon and its alternate kit) but excluding cosmetic alt
* skins. The returned list includes the given weapon itself.
*/
export function kitSiblingIds(weaponId: MainWeaponId): MainWeaponId[] {
const baseId = weaponIdToBaseWeaponId(weaponId);
return mainWeaponIds.filter(
(id) =>
weaponIdToBaseWeaponId(id) === baseId &&
weaponIdToType(id) !== "ALT_SKIN",
);
}
/** Whether the given parameter has any tracked per-version history. */
export function hasHistory(param: ParamValueWithHistory): boolean {
return param.history.length > 0;
}
interface DamageRateHistoryRow {
mainWeaponIds: number[];
subWeaponIds: number[];
specialWeaponIds: number[];
targets: DamageMultiplierWithHistory[];
}
const DAMAGE_RECEIVER_ORDER = new Map(
DAMAGE_RECEIVERS.map((receiver, i) => [receiver as string, i]),
);
const EMPTY_ATTACKERS: IncomingDamageAttackers = {
mainWeaponIds: [],
subWeaponIds: [],
specialWeaponIds: [],
};
/** Whether `candidate` is a better single representative of a target than the `current` pick. */
function isMoreInformativeMultiplier(
candidate: DamageMultiplierWithHistory,
current: DamageMultiplierWithHistory,
): boolean {
if (candidate.history.length !== current.history.length) {
return candidate.history.length > current.history.length;
}
return candidate.current > current.current;
}
/**
* Collects the damage multiplier history of every damage rate row that applies to the given
* weapon, reduced to a single entry per object target. A weapon can map to several rows (e.g.
* different attacks) that share the same target; the most informative one (longest tracked
* history, then highest current rate) is kept. Entries are ordered like {@link DAMAGE_RECEIVERS}.
*/
export function damageMultipliersForWeapon(
rows: Record<string, DamageRateHistoryRow>,
weaponId: number,
kind: WeaponParamKind,
): DamageMultiplierWithHistory[] {
const applies = (row: DamageRateHistoryRow) => {
if (kind === "sub") return row.subWeaponIds.includes(weaponId);
if (kind === "special") return row.specialWeaponIds.includes(weaponId);
return (
row.mainWeaponIds.includes(weaponId) ||
row.mainWeaponIds.includes(
weaponIdToBaseWeaponId(weaponId as MainWeaponId),
)
);
};
const byTarget = new Map<string, DamageMultiplierWithHistory>();
for (const row of Object.values(rows)) {
if (!applies(row)) continue;
for (const target of row.targets) {
const existing = byTarget.get(target.target);
if (!existing || isMoreInformativeMultiplier(target, existing)) {
byTarget.set(target.target, target);
}
}
}
return [...byTarget.values()].sort(
(a, b) =>
(DAMAGE_RECEIVER_ORDER.get(a.target) ?? Number.MAX_SAFE_INTEGER) -
(DAMAGE_RECEIVER_ORDER.get(b.target) ?? Number.MAX_SAFE_INTEGER),
);
}
/** A stable identifier for a group of attacking weapons, used to de-duplicate incoming entries. */
function attackerGroupKey(attackers: IncomingDamageAttackers): string {
const part = (ids: number[]) => [...ids].sort((a, b) => a - b).join(",");
return `m${part(attackers.mainWeaponIds)};s${part(attackers.subWeaponIds)};x${part(attackers.specialWeaponIds)}`;
}
/**
* Collects, for the given sub or special weapon (which must itself be a damageable object), the
* history of every *other* weapon's damage multiplier against it. Each entry is one group of
* attacking weapons that shared a rate change against one of the weapon's receiver targets; per
* (attacker group, target) the most informative entry (longest history, then highest rate) is
* kept. Entries are ordered like {@link DAMAGE_RECEIVERS}, then by attacker group.
*/
export function incomingDamageMultipliersForWeapon(
rows: Record<string, DamageRateHistoryRow>,
weaponId: number,
kind: "sub" | "special",
): IncomingDamageMultiplierWithHistory[] {
const receiverTargets = INCOMING_DAMAGE_RECEIVERS[kind][weaponId];
if (!receiverTargets) return [];
const targetSet = new Set<string>(receiverTargets);
const byKey = new Map<string, IncomingDamageMultiplierWithHistory>();
for (const row of Object.values(rows)) {
const attackers: IncomingDamageAttackers = {
mainWeaponIds:
row.mainWeaponIds as IncomingDamageAttackers["mainWeaponIds"],
subWeaponIds: row.subWeaponIds as IncomingDamageAttackers["subWeaponIds"],
specialWeaponIds:
row.specialWeaponIds as IncomingDamageAttackers["specialWeaponIds"],
};
const attackerKey = attackerGroupKey(attackers);
for (const target of row.targets) {
if (!targetSet.has(target.target)) continue;
const key = `${attackerKey}|${target.target}`;
const existing = byKey.get(key);
if (!existing || isMoreInformativeMultiplier(target, existing)) {
byKey.set(key, {
target: target.target,
attackers,
current: target.current,
history: target.history,
});
}
}
}
return [...byKey.values()].sort((a, b) => {
const order =
(DAMAGE_RECEIVER_ORDER.get(a.target) ?? Number.MAX_SAFE_INTEGER) -
(DAMAGE_RECEIVER_ORDER.get(b.target) ?? Number.MAX_SAFE_INTEGER);
if (order !== 0) return order;
return attackerGroupKey(a.attackers).localeCompare(
attackerGroupKey(b.attackers),
);
});
}
function changesFromHistory(
history: Array<{ version: string; value: number | string }>,
current: number | string,
versions: string[],
versionIndex: Map<string, number>,
): Array<{ patchVersion: string; from: number | string; to: number | string }> {
const result: Array<{
patchVersion: string;
from: number | string;
to: number | string;
}> = [];
for (let i = 0; i < history.length; i++) {
const { version, value: from } = history[i];
const to = i < history.length - 1 ? history[i + 1].value : current;
// A recorded value is the value *before* a change, so the change took effect at the
// next tracked game version.
const recordedIndex = versionIndex.get(version);
if (recordedIndex === undefined) continue;
const patchVersion = versions[recordedIndex + 1];
if (!patchVersion) continue;
result.push({ patchVersion, from, to });
}
return result;
}
/**
* Groups every tracked parameter change of a single weapon by the game version (patch) that
* introduced it. Optionally folds the weapon's special points history into the same grouping.
*
* Within each patch the changes are sorted with special points first, then alphabetically by
* category and key.
*/
function computeWeaponPatchChanges(
parsed: ParsedWeaponParams,
versions: string[],
specialPoints?: SpecialPointWithHistory[],
damageMultipliers?: DamageMultiplierWithHistory[],
source?: WeaponParamKind,
incomingDamageMultipliers?: IncomingDamageMultiplierWithHistory[],
): Map<string, PatchChange[]> {
const versionIndex = new Map(versions.map((version, i) => [version, i]));
const byVersion = new Map<string, PatchChange[]>();
const push = (patchVersion: string, change: PatchChange) => {
const existing = byVersion.get(patchVersion);
if (existing) {
existing.push(change);
} else {
byVersion.set(patchVersion, [change]);
}
};
for (const [category, params] of Object.entries(parsed.categories)) {
for (const [key, param] of Object.entries(params)) {
for (const { patchVersion, from, to } of changesFromHistory(
param.history,
param.current,
versions,
versionIndex,
)) {
push(patchVersion, {
category,
key,
from,
to,
kind: classifyParamChange(category, key, from, to),
source,
});
}
}
}
for (const kit of specialPoints ?? []) {
for (const { patchVersion, from, to } of changesFromHistory(
kit.history,
kit.current,
versions,
versionIndex,
)) {
// Fewer special points needed means the special charges faster.
const kind = from === to ? "neutral" : to < from ? "buff" : "nerf";
push(patchVersion, {
category: SPECIAL_POINTS_PARAM_KEY,
key: SPECIAL_POINTS_PARAM_KEY,
from,
to,
kind,
weaponId: kit.weaponId,
source,
});
}
}
for (const multiplier of damageMultipliers ?? []) {
for (const { patchVersion, from, to } of changesFromHistory(
multiplier.history,
multiplier.current,
versions,
versionIndex,
)) {
// A higher damage rate means the weapon deals more damage to the object.
const kind = from === to ? "neutral" : to > from ? "buff" : "nerf";
push(patchVersion, {
category: DAMAGE_MULTIPLIER_PARAM_KEY,
key: multiplier.target,
from,
to,
kind,
source,
});
}
}
for (const multiplier of incomingDamageMultipliers ?? []) {
for (const { patchVersion, from, to } of changesFromHistory(
multiplier.history,
multiplier.current,
versions,
versionIndex,
)) {
// A higher incoming damage rate means the object takes more damage, i.e. a nerf to the
// sub or special weapon being defended (the inverse of an outgoing damage multiplier).
const kind = from === to ? "neutral" : to > from ? "nerf" : "buff";
push(patchVersion, {
category: INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
key: multiplier.target,
from,
to,
kind,
source,
attackers: multiplier.attackers,
});
}
}
for (const changes of byVersion.values()) {
changes.sort((a, b) => {
// Special points first (ordered by kit), then outgoing damage multipliers, then
// incoming damage multipliers, then regular params by category and key.
const rank = (change: PatchChange) =>
change.category === SPECIAL_POINTS_PARAM_KEY
? 0
: change.category === DAMAGE_MULTIPLIER_PARAM_KEY
? 1
: change.category === INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY
? 2
: 3;
const aRank = rank(a);
const bRank = rank(b);
if (aRank !== bRank) return aRank - bRank;
if (aRank === 0) return (a.weaponId ?? 0) - (b.weaponId ?? 0);
if (aRank === 2) {
const order =
(DAMAGE_RECEIVER_ORDER.get(a.key) ?? Number.MAX_SAFE_INTEGER) -
(DAMAGE_RECEIVER_ORDER.get(b.key) ?? Number.MAX_SAFE_INTEGER);
if (order !== 0) return order;
return attackerGroupKey(a.attackers ?? EMPTY_ATTACKERS).localeCompare(
attackerGroupKey(b.attackers ?? EMPTY_ATTACKERS),
);
}
if (a.category !== b.category) {
return a.category.localeCompare(b.category);
}
return a.key.localeCompare(b.key);
});
}
return byVersion;
}
/**
* Assembles per-version change maps into the descending-by-version patch history, attaching each
* tracked game version's release date and skipping versions with no changes. When several maps are
* given (e.g. a kit's main, sub and special weapon changes) their changes are concatenated in the
* order the maps are passed, keeping each map's own within-version ordering.
*/
function changeMapsToPatches(
maps: Array<Map<string, PatchChange[]>>,
versions: string[],
): WeaponPatch[] {
const patchDateByVersion = new Map(PATCHES.map((p) => [p.patch, p.date]));
return versions
.map((version) => ({
version,
date: patchDateByVersion.get(version) ?? null,
changes: maps.flatMap((map) => map.get(version) ?? []),
}))
.filter((patch) => patch.changes.length > 0)
.reverse();
}
/**
* Builds the descending-by-version patch history of a single weapon, attaching each tracked
* game version's release date and skipping versions with no tracked balance changes. Special
* points changes are only folded in for main weapons (pass their history as `specialPoints`).
*/
export function patchHistory(
parsed: ParsedWeaponParams | undefined,
versions: string[],
specialPoints: SpecialPointWithHistory[] = [],
damageMultipliers: DamageMultiplierWithHistory[] = [],
incomingDamageMultipliers: IncomingDamageMultiplierWithHistory[] = [],
): WeaponPatch[] {
if (!parsed) return [];
return changeMapsToPatches(
[
computeWeaponPatchChanges(
parsed,
versions,
specialPoints,
damageMultipliers,
undefined,
incomingDamageMultipliers,
),
],
versions,
);
}
/**
* Builds a patch history per kit of a main weapon, folding the (shared) main weapon changes
* together with the kit's own special points, sub weapon and special weapon changes. Every change
* is tagged with its `source` so the patch history can group a column under a divider per weapon.
*/
export function kitPatchHistories({
mainParsed,
versions,
kits,
specialPointsByKit,
mainDamageMultipliers,
subParams,
subDamageMultipliers,
subIncomingDamageMultipliers,
specialParams,
specialDamageMultipliers,
specialIncomingDamageMultipliers,
}: {
mainParsed: ParsedWeaponParams | undefined;
versions: string[];
kits: WeaponKitInfo[];
specialPointsByKit: Record<string, SpecialPointWithHistory>;
mainDamageMultipliers: DamageMultiplierWithHistory[];
subParams: Record<string, ParsedWeaponParams | undefined>;
subDamageMultipliers: Record<string, DamageMultiplierWithHistory[]>;
subIncomingDamageMultipliers: Record<
string,
IncomingDamageMultiplierWithHistory[]
>;
specialParams: Record<string, ParsedWeaponParams | undefined>;
specialDamageMultipliers: Record<string, DamageMultiplierWithHistory[]>;
specialIncomingDamageMultipliers: Record<
string,
IncomingDamageMultiplierWithHistory[]
>;
}): KitPatchHistory[] {
if (!mainParsed) return [];
return kits.map((kit) => {
const kitSpecialPoints = specialPointsByKit[String(kit.weaponId)];
const maps = [
computeWeaponPatchChanges(
mainParsed,
versions,
kitSpecialPoints ? [kitSpecialPoints] : [],
mainDamageMultipliers,
"main",
),
];
const subIncoming =
subIncomingDamageMultipliers[String(kit.subWeaponId)] ?? [];
const subParsed = subParams[String(kit.subWeaponId)];
if (subParsed || subIncoming.length > 0) {
maps.push(
computeWeaponPatchChanges(
subParsed ?? { weaponId: kit.subWeaponId, categories: {} },
versions,
[],
subDamageMultipliers[String(kit.subWeaponId)] ?? [],
"sub",
subIncoming,
),
);
}
const specialIncoming =
specialIncomingDamageMultipliers[String(kit.specialWeaponId)] ?? [];
const specialParsed = specialParams[String(kit.specialWeaponId)];
if (specialParsed || specialIncoming.length > 0) {
maps.push(
computeWeaponPatchChanges(
specialParsed ?? { weaponId: kit.specialWeaponId, categories: {} },
versions,
[],
specialDamageMultipliers[String(kit.specialWeaponId)] ?? [],
"special",
specialIncoming,
),
);
}
return {
weaponId: kit.weaponId,
subWeaponId: kit.subWeaponId,
specialWeaponId: kit.specialWeaponId,
patches: changeMapsToPatches(maps, versions),
};
});
}
/** Formats a parameter value for display, trimming trailing zeroes from non-integer numbers. */
export function formatValue(value: number | string): string {
if (typeof value === "number") {
if (Number.isInteger(value)) {
return String(value);
}
return value.toFixed(4).replace(/\.?0+$/, "");
}
return String(value);
}

View File

@@ -0,0 +1,186 @@
/**
* Whether a higher value is better ("higher") or worse ("lower") for the player who owns
* the weapon. `null` means the direction is unknown / context-dependent.
*/
type ParamDirection = "higher" | "lower" | null;
/**
* How a value change between two patches affected the weapon: a `"buff"` made it stronger,
* a `"nerf"` made it weaker, and `"neutral"` is either an unclassified parameter or a change
* whose impact direction we don't track.
*/
export type ParamChangeKind = "buff" | "nerf" | "neutral";
/**
* Ordered substring rules describing whether a higher value of a parameter is good for its
* weapon. The first rule whose `match` is a substring of the full `${category}.${key}` wins,
* so narrower exceptions are listed before broader rules (e.g. `ReceiveDamage` before
* `Damage`). Parameters matching no rule are treated as having an unknown direction.
*/
const PARAM_DIRECTION_RULES: Array<{
match: string;
betterWhenHigher: boolean;
}> = [
// Taking less damage is good, so these override the broader "Damage" rule below.
{ match: "ReceiveDamage", betterWhenHigher: false },
{ match: "AttackedDamageRate", betterWhenHigher: false },
// Lower is better: less ink, faster recovery, tighter spread, shorter delays.
{ match: "InkConsume", betterWhenHigher: false },
{ match: "InkRecoverStop", betterWhenHigher: false },
{ match: "DegSwerve", betterWhenHigher: false },
{ match: "DegBias", betterWhenHigher: false },
{ match: "ChargeFrame", betterWhenHigher: false },
{ match: "RepeatFrame", betterWhenHigher: false },
{ match: "PostDelayFrame", betterWhenHigher: false },
{ match: "PreDelayFrame", betterWhenHigher: false },
{ match: "DashFrame", betterWhenHigher: false },
{ match: "NakedFrame", betterWhenHigher: false },
{ match: "Dash_ChargeCancelableFrame", betterWhenHigher: false },
// Higher is better: more damage, durability, mobility, paint, range, uptime.
{ match: "Damage", betterWhenHigher: true },
{ match: "CanopyHP", betterWhenHigher: true },
{ match: "ArmorHP", betterWhenHigher: true },
{ match: "MaxFieldHP", betterWhenHigher: true },
{ match: "MaxHP", betterWhenHigher: true },
{ match: "HitPoint", betterWhenHigher: true },
{ match: "MoveSpeed", betterWhenHigher: true },
{ match: "WidthHalf", betterWhenHigher: true },
{ match: "PaintRadius", betterWhenHigher: true },
{ match: "CrossPaint", betterWhenHigher: true },
{ match: "PaintHeight", betterWhenHigher: true },
{ match: "SpawnNum", betterWhenHigher: true },
{ match: "SplitNum", betterWhenHigher: true },
{ match: "SpawnSpeed", betterWhenHigher: true },
{ match: "GoStraightStateEndMaxSpeed", betterWhenHigher: true },
{ match: "MaxShootingFrame", betterWhenHigher: true },
{ match: "ServeAreaRadius", betterWhenHigher: true },
{ match: "PowerUpFrame", betterWhenHigher: true },
{ match: "KnockBackParam.Distance", betterWhenHigher: true },
// Longer-lasting effects and uptime are buffs.
{ match: "SpecialTotalFrame", betterWhenHigher: true },
{ match: "SpecialDurationFrame", betterWhenHigher: true },
{ match: "MarkingFrame", betterWhenHigher: true },
{ match: "RainyFrame", betterWhenHigher: true },
{ match: "LaserFrame", betterWhenHigher: true },
{ match: ".Low", betterWhenHigher: true },
{ match: ".Mid", betterWhenHigher: true },
{ match: ".High", betterWhenHigher: true },
];
/**
* Returns whether a higher value of the given parameter benefits the weapon's owner, using
* substring matching against `${category}.${key}`. Returns `null` when the parameter is not
* recognized as clearly directional.
*/
function getParamDirection(category: string, key: string): ParamDirection {
const fullKey = `${category}.${key}`;
for (const { match, betterWhenHigher } of PARAM_DIRECTION_RULES) {
if (fullKey.includes(match)) {
return betterWhenHigher ? "higher" : "lower";
}
}
return null;
}
/** Matches a single `"<damage> @ <distance>"` breakpoint of a serialized damage falloff curve. */
const DAMAGE_BREAKPOINT_PATTERN = /^\s*([\d.]+)\s*@\s*([\d.]+)\s*$/;
/**
* Parses a serialized damage falloff curve (see `formatDistanceDamageArray`) back into its
* breakpoints. Returns `null` for any other string (enums, primitive-array blobs), which are
* treated as non-directional.
*/
function parseDamageCurve(
value: number | string,
): Array<{ damage: number; distance: number }> | null {
if (typeof value !== "string") {
return null;
}
const breakpoints: Array<{ damage: number; distance: number }> = [];
for (const part of value.split(",")) {
const match = part.match(DAMAGE_BREAKPOINT_PATTERN);
if (!match) return null;
breakpoints.push({ damage: Number(match[1]), distance: Number(match[2]) });
}
return breakpoints.length > 0 ? breakpoints : null;
}
/**
* Classifies a change between two damage falloff curves by comparing them breakpoint by
* breakpoint. Both more damage and more reach (a higher distance at which a damage tier still
* applies) count as improvements, so a curve where every change improves is a buff, every change
* worsens is a nerf, and a mix (or curves of differing shape) is neutral. Returns `null` when the
* values are not both damage curves, so the caller falls back to scalar comparison.
*/
function classifyDamageCurveChange(
direction: ParamDirection,
from: number | string,
to: number | string,
): ParamChangeKind | null {
const fromCurve = parseDamageCurve(from);
const toCurve = parseDamageCurve(to);
if (!fromCurve || !toCurve || fromCurve.length !== toCurve.length) {
return null;
}
let improved = false;
let worsened = false;
for (let i = 0; i < fromCurve.length; i++) {
for (const field of ["damage", "distance"] as const) {
const before = fromCurve[i][field];
const after = toCurve[i][field];
if (before === after) continue;
const isImprovement =
direction === "lower" ? after < before : after > before;
if (isImprovement) {
improved = true;
} else {
worsened = true;
}
}
}
if (improved && !worsened) return "buff";
if (worsened && !improved) return "nerf";
return "neutral";
}
/**
* Classifies a parameter value change between two patches as a buff, a nerf, or neutral.
*
* Damage falloff curves are compared breakpoint by breakpoint (see
* {@link classifyDamageCurveChange}). Neutral is returned for other non-numeric values, unchanged
* values, or parameters whose impact direction is unknown (see {@link getParamDirection}).
*/
export function classifyParamChange(
category: string,
key: string,
from: number | string,
to: number | string,
): ParamChangeKind {
const direction = getParamDirection(category, key);
if (direction === null) {
return "neutral";
}
const curveChange = classifyDamageCurveChange(direction, from, to);
if (curveChange !== null) {
return curveChange;
}
if (typeof from !== "number" || typeof to !== "number" || from === to) {
return "neutral";
}
const increased = to > from;
const improved = direction === "higher" ? increased : !increased;
return improved ? "buff" : "nerf";
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,837 @@
{
"metadata": {
"versions": [
"0.9.9",
"1.0.0",
"1.1.0",
"1.1.1",
"1.2.0",
"2.0.0",
"2.1.0",
"3.0.0",
"3.1.0",
"4.0.0",
"4.1.0",
"5.0.0",
"5.1.0",
"5.2.0",
"6.0.0",
"6.1.0",
"7.0.0",
"7.1.0",
"7.2.0",
"8.0.0",
"8.1.0",
"9.0.0",
"9.1.0",
"9.2.0",
"9.3.0",
"10.0.0",
"10.1.0",
"11.0.0",
"11.0.1",
"11.1.0",
"11.2.0"
]
},
"rows": {
"Blaster_BlasterShort": {
"mainWeaponIds": [200, 201, 205],
"subWeaponIds": [],
"specialWeaponIds": [],
"targets": [
{
"target": "Wsb_Shield",
"current": 2.2,
"history": [
{
"version": "7.2.0",
"value": 2
}
]
}
]
},
"BlowerExhale_BombCore": {
"mainWeaponIds": [],
"subWeaponIds": [],
"specialWeaponIds": [8],
"targets": [
{
"target": "GreatBarrier_Barrier",
"current": 4.2,
"history": [
{
"version": "0.9.9",
"value": 5.6
},
{
"version": "1.0.0",
"value": 1
},
{
"version": "3.1.0",
"value": 5.6
}
]
},
{
"target": "GreatBarrier_WeakPoint",
"current": 2.1,
"history": [
{
"version": "0.9.9",
"value": 2.8
},
{
"version": "1.0.0",
"value": 1
},
{
"version": "3.1.0",
"value": 2.8
}
]
}
]
},
"Bomb_DirectHit": {
"mainWeaponIds": [],
"subWeaponIds": [2, 7, 0],
"specialWeaponIds": [],
"targets": [
{
"target": "GreatBarrier_WeakPoint",
"current": 1.5,
"history": [
{
"version": "0.9.9",
"value": 1.5
},
{
"version": "1.0.0",
"value": 0.5
}
]
}
]
},
"Bomb_TorpedoSplashBurst": {
"mainWeaponIds": [],
"subWeaponIds": [13],
"specialWeaponIds": [],
"targets": [
{
"target": "GreatBarrier_WeakPoint",
"current": 1,
"history": [
{
"version": "0.9.9",
"value": 1
},
{
"version": "1.0.0",
"value": 0.25
}
]
}
]
},
"BrushSplash": {
"mainWeaponIds": [1100, 1101, 1110, 1111, 1112, 1115],
"subWeaponIds": [],
"specialWeaponIds": [],
"targets": [
{
"target": "Wsb_Shield",
"current": 1.98,
"history": [
{
"version": "7.2.0",
"value": 1.8
}
]
}
]
},
"Castle": {
"mainWeaponIds": [],
"subWeaponIds": [],
"specialWeaponIds": [17],
"targets": [
{
"target": "GreatBarrier_Barrier",
"current": 2.5,
"history": [
{
"version": "3.1.0",
"value": 5
}
]
},
{
"target": "GreatBarrier_WeakPoint",
"current": 2.5,
"history": [
{
"version": "3.1.0",
"value": 5
}
]
}
]
},
"Jetpack_BombCore": {
"mainWeaponIds": [],
"subWeaponIds": [],
"specialWeaponIds": [10],
"targets": [
{
"target": "Chariot",
"current": 3.5,
"history": [
{
"version": "2.1.0",
"value": 1.4
}
]
},
{
"target": "GreatBarrier_Barrier",
"current": 1.82,
"history": [
{
"version": "3.1.0",
"value": 2.8
},
{
"version": "8.0.0",
"value": 2.1
},
{
"version": "11.1.0",
"value": 1.4
}
]
},
{
"target": "GreatBarrier_WeakPoint",
"current": 1.365,
"history": [
{
"version": "3.1.0",
"value": 2.1
},
{
"version": "8.0.0",
"value": 1.575
},
{
"version": "11.1.0",
"value": 1.05
}
]
}
]
},
"Jetpack_Bullet": {
"mainWeaponIds": [],
"subWeaponIds": [],
"specialWeaponIds": [10],
"targets": [
{
"target": "Chariot",
"current": 2.0835,
"history": [
{
"version": "2.1.0",
"value": 0.8334
}
]
},
{
"target": "GreatBarrier_Barrier",
"current": 1.82,
"history": [
{
"version": "3.1.0",
"value": 2.8
},
{
"version": "8.0.0",
"value": 2.1
},
{
"version": "11.1.0",
"value": 1.4
}
]
},
{
"target": "GreatBarrier_WeakPoint",
"current": 1.365,
"history": [
{
"version": "3.1.0",
"value": 2.1
},
{
"version": "8.0.0",
"value": 1.575
},
{
"version": "11.1.0",
"value": 1.05
}
]
}
]
},
"MultiMissile_BombCore": {
"mainWeaponIds": [],
"subWeaponIds": [],
"specialWeaponIds": [4],
"targets": [
{
"target": "GreatBarrier_Barrier",
"current": 0.5,
"history": [
{
"version": "3.1.0",
"value": 1
}
]
},
{
"target": "GreatBarrier_WeakPoint",
"current": 0.375,
"history": [
{
"version": "3.1.0",
"value": 0.75
}
]
}
]
},
"MultiMissile_Bullet": {
"mainWeaponIds": [],
"subWeaponIds": [],
"specialWeaponIds": [4],
"targets": [
{
"target": "GreatBarrier_Barrier",
"current": 0.5,
"history": [
{
"version": "3.1.0",
"value": 1
}
]
},
{
"target": "GreatBarrier_WeakPoint",
"current": 0.375,
"history": [
{
"version": "3.1.0",
"value": 0.75
}
]
}
]
},
"NiceBall": {
"mainWeaponIds": [],
"subWeaponIds": [],
"specialWeaponIds": [6],
"targets": [
{
"target": "Chariot",
"current": 4,
"history": [
{
"version": "9.3.0",
"value": 2
}
]
},
{
"target": "GreatBarrier_Barrier",
"current": 2,
"history": [
{
"version": "3.1.0",
"value": 3
},
{
"version": "9.1.0",
"value": 1.5
}
]
},
{
"target": "GreatBarrier_WeakPoint",
"current": 2,
"history": [
{
"version": "3.1.0",
"value": 3
},
{
"version": "9.1.0",
"value": 1.5
}
]
}
]
},
"RollerSplash_Compact": {
"mainWeaponIds": [1000, 1001, 1002],
"subWeaponIds": [],
"specialWeaponIds": [],
"targets": [
{
"target": "Wsb_Shield",
"current": 2.64,
"history": [
{
"version": "7.2.0",
"value": 2.4
}
]
}
]
},
"RollerSplash": {
"mainWeaponIds": [1010, 1011, 1015],
"subWeaponIds": [],
"specialWeaponIds": [],
"targets": [
{
"target": "Wsb_Shield",
"current": 2.64,
"history": [
{
"version": "7.2.0",
"value": 2.4
}
]
}
]
},
"Saber_ChargeShot": {
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
"subWeaponIds": [],
"specialWeaponIds": [],
"targets": [
{
"target": "NiceBall_Armor",
"current": 1.82,
"history": [
{
"version": "11.1.0",
"value": 2.6
}
]
},
{
"target": "ShockSonar",
"current": 2.4,
"history": [
{
"version": "9.3.0",
"value": 4.8
}
]
}
]
},
"Saber_ChargeSlash": {
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
"subWeaponIds": [],
"specialWeaponIds": [],
"targets": [
{
"target": "NiceBall_Armor",
"current": 0.455,
"history": [
{
"version": "11.1.0",
"value": 0.65
}
]
}
]
},
"Saber_Shot": {
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
"subWeaponIds": [],
"specialWeaponIds": [],
"targets": [
{
"target": "NiceBall_Armor",
"current": 1.82,
"history": [
{
"version": "11.1.0",
"value": 2.6
}
]
},
{
"target": "ShockSonar",
"current": 2.4,
"history": [
{
"version": "9.3.0",
"value": 4.8
}
]
}
]
},
"Saber_Slash": {
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
"subWeaponIds": [],
"specialWeaponIds": [],
"targets": [
{
"target": "NiceBall_Armor",
"current": 0.91,
"history": [
{
"version": "11.1.0",
"value": 1.3
}
]
}
]
},
"TripleTornado": {
"mainWeaponIds": [],
"subWeaponIds": [],
"specialWeaponIds": [14],
"targets": [
{
"target": "Chariot",
"current": 3,
"history": [
{
"version": "9.3.0",
"value": 2
}
]
},
{
"target": "GreatBarrier_Barrier",
"current": 1,
"history": [
{
"version": "3.1.0",
"value": 2
}
]
},
{
"target": "GreatBarrier_WeakPoint",
"current": 1,
"history": [
{
"version": "3.1.0",
"value": 2
}
]
}
]
},
"UltraShot": {
"mainWeaponIds": [],
"subWeaponIds": [],
"specialWeaponIds": [1],
"targets": [
{
"target": "Chariot",
"current": 1.8,
"history": [
{
"version": "2.1.0",
"value": 1.5
}
]
},
{
"target": "GreatBarrier_Barrier",
"current": 1.3,
"history": [
{
"version": "3.1.0",
"value": 2
},
{
"version": "8.0.0",
"value": 1.5
},
{
"version": "11.1.0",
"value": 1
}
]
},
{
"target": "GreatBarrier_WeakPoint",
"current": 0.975,
"history": [
{
"version": "3.1.0",
"value": 1.5
},
{
"version": "8.0.0",
"value": 1.125
},
{
"version": "11.1.0",
"value": 0.75
}
]
}
]
},
"UltraStamp_Swing": {
"mainWeaponIds": [],
"subWeaponIds": [],
"specialWeaponIds": [11],
"targets": [
{
"target": "Chariot",
"current": 4.5,
"history": [
{
"version": "2.1.0",
"value": 1.5
}
]
},
{
"target": "GreatBarrier_Barrier",
"current": 1.5,
"history": [
{
"version": "3.1.0",
"value": 3
}
]
},
{
"target": "GreatBarrier_WeakPoint",
"current": 1.5,
"history": [
{
"version": "3.1.0",
"value": 3
}
]
}
]
},
"UltraStamp_Throw_BombCore": {
"mainWeaponIds": [],
"subWeaponIds": [],
"specialWeaponIds": [11],
"targets": [
{
"target": "Bomb_TorpedoBullet",
"current": 0.545,
"history": [
{
"version": "9.2.0",
"value": 1
}
]
},
{
"target": "BulletUmbrellaCanopyCompact",
"current": 4.091,
"history": [
{
"version": "9.2.0",
"value": 5
}
]
},
{
"target": "BulletUmbrellaCanopyNormal",
"current": 4.091,
"history": [
{
"version": "9.2.0",
"value": 5
}
]
},
{
"target": "BulletUmbrellaCanopyNormal_Launched",
"current": 4.091,
"history": [
{
"version": "9.2.0",
"value": 5
}
]
},
{
"target": "BulletShelterCanopyFocus",
"current": 4.091,
"history": [
{
"version": "9.2.0",
"value": 5
}
]
},
{
"target": "BulletShelterCanopyFocus_Launched",
"current": 4.091,
"history": [
{
"version": "9.2.0",
"value": 5
}
]
},
{
"target": "BulletUmbrellaCanopyWide",
"current": 4.091,
"history": [
{
"version": "9.2.0",
"value": 5
}
]
},
{
"target": "BulletUmbrellaCanopyWide_Launched",
"current": 4.091,
"history": [
{
"version": "9.2.0",
"value": 5
}
]
},
{
"target": "Chariot",
"current": 3.273,
"history": [
{
"version": "2.1.0",
"value": 2
},
{
"version": "9.2.0",
"value": 6
}
]
},
{
"target": "Gachihoko_Barrier",
"current": 1.909,
"history": [
{
"version": "9.2.0",
"value": 3.5
}
]
},
{
"target": "GreatBarrier_Barrier",
"current": 1.909,
"history": [
{
"version": "9.2.0",
"value": 3.5
}
]
},
{
"target": "GreatBarrier_WeakPoint",
"current": 1.4318,
"history": [
{
"version": "9.2.0",
"value": 2.625
}
]
},
{
"target": "NiceBall_Armor",
"current": 1.364,
"history": [
{
"version": "9.2.0",
"value": 2.5
}
]
},
{
"target": "ShockSonar",
"current": 2.182,
"history": [
{
"version": "9.2.0",
"value": 4
}
]
},
{
"target": "Wsb_Flag",
"current": 1.636,
"history": [
{
"version": "9.2.0",
"value": 3
}
]
},
{
"target": "Wsb_Shield",
"current": 2.182,
"history": [
{
"version": "9.2.0",
"value": 4
}
]
},
{
"target": "Wsb_Sprinkler",
"current": 1.636,
"history": [
{
"version": "9.2.0",
"value": 3
}
]
}
]
},
"UltraStamp_Throw": {
"mainWeaponIds": [],
"subWeaponIds": [],
"specialWeaponIds": [11],
"targets": [
{
"target": "Chariot",
"current": 3.273,
"history": [
{
"version": "2.1.0",
"value": 1.091
}
]
}
]
}
}
}

View File

@@ -0,0 +1,304 @@
import type { LoaderFunctionArgs } from "react-router";
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
import { getFixedTForLanguage } from "~/modules/i18n/i18next.server";
import type {
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import {
specialWeaponIds,
subWeaponIds,
weaponIdToBaseWeaponId,
weaponIdToType,
} from "~/modules/in-game-lists/weapon-ids";
import {
specialWeaponNameSlugToId,
subWeaponNameSlugToId,
weaponNameSlugToId,
} from "~/utils/unslugify.server";
import { mySlugify } from "~/utils/urls";
import * as WeaponParams from "../core/WeaponParams";
import specialWeaponParamsData from "../data/all-version-special-params.json";
import subWeaponParamsData from "../data/all-version-sub-params.json";
import weaponParamsData from "../data/all-version-weapon-params.json";
import damageRateHistoryData from "../data/damage-rate-history.json";
import type {
DamageMultiplierWithHistory,
IncomingDamageMultiplierWithHistory,
SpecialPointWithHistory,
WeaponParamKind,
} from "../weapon-params-types";
const mainParamsData = weaponParamsData as WeaponParams.AllVersionParams;
const subParamsData = subWeaponParamsData as WeaponParams.AllVersionParams;
const specialParamsData =
specialWeaponParamsData as WeaponParams.AllVersionParams;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const t = await getFixedTForLanguage("en", ["weapons"]);
const mainWeaponId = weaponNameSlugToId(params.slug);
if (typeof mainWeaponId === "number") {
if (weaponIdToType(mainWeaponId) === "ALT_SKIN") {
throw new Response(null, { status: 404 });
}
return mainWeaponData(
mainWeaponId,
t(`weapons:MAIN_${mainWeaponId}`),
mySlugify(t(`weapons:MAIN_${mainWeaponId}`, { lng: "en" })),
);
}
const subWeaponId = subWeaponNameSlugToId(params.slug);
if (typeof subWeaponId === "number") {
return subWeaponData(
subWeaponId,
t(`weapons:SUB_${subWeaponId}`),
mySlugify(t(`weapons:SUB_${subWeaponId}`, { lng: "en" })),
);
}
const specialWeaponId = specialWeaponNameSlugToId(params.slug);
if (typeof specialWeaponId === "number") {
return specialWeaponData(
specialWeaponId,
t(`weapons:SPECIAL_${specialWeaponId}`),
mySlugify(t(`weapons:SPECIAL_${specialWeaponId}`, { lng: "en" })),
);
}
throw new Response(null, { status: 404 });
};
function damageMultipliersByWeapon(
weaponIds: number[],
kind: WeaponParamKind,
): Record<string, DamageMultiplierWithHistory[]> {
const rows = damageRateHistoryData.rows as Parameters<
typeof WeaponParams.damageMultipliersForWeapon
>[0];
const result: Record<string, DamageMultiplierWithHistory[]> = {};
for (const id of weaponIds) {
const multipliers = WeaponParams.damageMultipliersForWeapon(rows, id, kind);
if (multipliers.length > 0) {
result[String(id)] = multipliers;
}
}
return result;
}
function incomingDamageMultipliersByWeapon(
weaponIds: number[],
kind: "sub" | "special",
): Record<string, IncomingDamageMultiplierWithHistory[]> {
const rows = damageRateHistoryData.rows as Parameters<
typeof WeaponParams.incomingDamageMultipliersForWeapon
>[0];
const result: Record<string, IncomingDamageMultiplierWithHistory[]> = {};
for (const id of weaponIds) {
const multipliers = WeaponParams.incomingDamageMultipliersForWeapon(
rows,
id,
kind,
);
if (multipliers.length > 0) {
result[String(id)] = multipliers;
}
}
return result;
}
function mainWeaponData(
weaponId: MainWeaponId,
weaponName: string,
slug: string,
) {
const categoryWeaponIds = WeaponParams.categoryWeaponIds(weaponId);
const kits = WeaponParams.kitSiblingIds(weaponId).map((id) => {
const { subWeaponId, specialWeaponId } = mainWeaponParams(id);
return { weaponId: id, subWeaponId, specialWeaponId };
});
const versions = mainParamsData.metadata.versions;
const weaponParams = WeaponParams.parseMany(
categoryWeaponIds,
mainParamsData,
weaponIdToBaseWeaponId,
);
const allSpecialPoints = mainParamsData.specialPoints;
const specialPoints: Record<string, SpecialPointWithHistory[]> = {};
for (const id of categoryWeaponIds) {
specialPoints[String(id)] = WeaponParams.kitSiblingIds(id).map((kitId) => ({
weaponId: kitId,
current: mainWeaponParams(kitId).SpecialPoint,
history: allSpecialPoints?.[String(kitId)]?.history ?? [],
}));
}
const damageMultipliers = damageMultipliersByWeapon(
categoryWeaponIds,
"main",
);
const patchHistory = WeaponParams.patchHistory(
weaponParams[String(weaponId)],
versions,
specialPoints[String(weaponId)] ?? [],
damageMultipliers[String(weaponId)] ?? [],
);
const subParams = WeaponParams.parseMany(
kits.map((kit) => kit.subWeaponId),
subParamsData,
);
const specialParams = WeaponParams.parseMany(
kits.map((kit) => kit.specialWeaponId),
specialParamsData,
);
const specialPointsByKit: Record<string, SpecialPointWithHistory> = {};
for (const { weaponId: kitId } of kits) {
specialPointsByKit[String(kitId)] = {
weaponId: kitId,
current: mainWeaponParams(kitId).SpecialPoint,
history: allSpecialPoints?.[String(kitId)]?.history ?? [],
};
}
const kitPatchHistories = WeaponParams.kitPatchHistories({
mainParsed: weaponParams[String(weaponId)],
versions,
kits,
specialPointsByKit,
mainDamageMultipliers: damageMultipliers[String(weaponId)] ?? [],
subParams,
subDamageMultipliers: damageMultipliersByWeapon(
kits.map((kit) => kit.subWeaponId),
"sub",
),
subIncomingDamageMultipliers: incomingDamageMultipliersByWeapon(
kits.map((kit) => kit.subWeaponId),
"sub",
),
specialParams,
specialDamageMultipliers: damageMultipliersByWeapon(
kits.map((kit) => kit.specialWeaponId),
"special",
),
specialIncomingDamageMultipliers: incomingDamageMultipliersByWeapon(
kits.map((kit) => kit.specialWeaponId),
"special",
),
});
return {
kind: "main" as WeaponParamKind,
weaponId,
weaponName,
slug,
categoryWeaponIds,
kits,
weaponParams,
specialPoints,
damageMultipliers,
patchHistory,
kitPatchHistories,
versions,
};
}
function subWeaponData(
weaponId: SubWeaponId,
weaponName: string,
slug: string,
) {
const versions = subParamsData.metadata.versions;
const weaponParams = WeaponParams.parseMany(subWeaponIds, subParamsData);
const damageMultipliers = damageMultipliersByWeapon([...subWeaponIds], "sub");
const incomingDamageMultipliers = incomingDamageMultipliersByWeapon(
[...subWeaponIds],
"sub",
);
const patchHistory = WeaponParams.patchHistory(
weaponParams[String(weaponId)],
versions,
[],
damageMultipliers[String(weaponId)] ?? [],
incomingDamageMultipliers[String(weaponId)] ?? [],
);
return {
kind: "sub" as WeaponParamKind,
weaponId,
weaponName,
slug,
categoryWeaponIds: [...subWeaponIds],
kits: undefined,
weaponParams,
specialPoints: undefined,
damageMultipliers,
patchHistory,
kitPatchHistories: undefined,
versions,
};
}
function specialWeaponData(
weaponId: SpecialWeaponId,
weaponName: string,
slug: string,
) {
const versions = specialParamsData.metadata.versions;
const weaponParams = WeaponParams.parseMany(
specialWeaponIds,
specialParamsData,
);
const damageMultipliers = damageMultipliersByWeapon(
[...specialWeaponIds],
"special",
);
const incomingDamageMultipliers = incomingDamageMultipliersByWeapon(
[...specialWeaponIds],
"special",
);
const patchHistory = WeaponParams.patchHistory(
weaponParams[String(weaponId)],
versions,
[],
damageMultipliers[String(weaponId)] ?? [],
incomingDamageMultipliers[String(weaponId)] ?? [],
);
return {
kind: "special" as WeaponParamKind,
weaponId,
weaponName,
slug,
categoryWeaponIds: [...specialWeaponIds],
kits: undefined,
weaponParams,
specialPoints: undefined,
damageMultipliers,
patchHistory,
kitPatchHistories: undefined,
versions,
};
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import * as SearchParams from "~/modules/search-params/search-params";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import { weaponParamsSearchParams } from "./params-search-params";
describe("weaponParamsSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(weaponParamsSearchParams, {
tab: ["params", "patches"],
hidden: [[], [10], [0, 50, 1000]],
kit: [null, 0, 40, 8010],
kitExtras: [true, false],
});
});
test("accepts legacy comma-joined hidden ids", () => {
expect(
SearchParams.decodeParam(weaponParamsSearchParams.shape.hidden, [
"10,20,30",
]),
).toEqual([10, 20, 30]);
});
test("malformed values decode to defaults", () => {
assertDecodesToDefault(weaponParamsSearchParams, "tab", [["bogus"]]);
assertDecodesToDefault(weaponParamsSearchParams, "hidden", [
["abc"],
["1.5"],
]);
assertDecodesToDefault(weaponParamsSearchParams, "kit", [
["9999"],
["foo"],
]);
assertDecodesToDefault(weaponParamsSearchParams, "kitExtras", [["1"]]);
});
});

View File

@@ -0,0 +1,18 @@
import { z } from "zod";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
import { numericEnum } from "~/utils/zod";
export const weaponParamsSearchParams = SearchParams.define({
tab: SP.param(z.enum(["params", "patches"]), {
default: "params",
loader: false,
}),
hidden: SP.param(z.array(z.number().int()), {
default: [],
loader: false,
}),
kit: SP.param(numericEnum(mainWeaponIds).nullable(), { loader: false }),
kitExtras: SP.param(z.boolean(), { default: true, loader: false }),
});

View File

@@ -0,0 +1,78 @@
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import type {
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import type { SerializeFrom } from "~/utils/remix";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
outlinedMainWeaponImageUrl,
specialWeaponImageUrl,
subWeaponImageUrl,
weaponParamsPage,
} from "~/utils/urls";
import { WeaponParamsView } from "../components/WeaponParamsView";
import { loader } from "../loaders/params.$slug.server";
import type { WeaponParamKind } from "../weapon-params-types";
export { loader };
export const handle: SendouRouteHandle = {
i18n: ["weapons", "common", "analyzer", "params"],
breadcrumb: ({ match }) => {
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
return [
{
imgPath: weaponImageUrl(data.kind, data.weaponId),
href: weaponParamsPage(data.slug),
type: "IMAGE",
},
];
},
};
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.loaderData) return [];
return metaTags({
title: `${args.loaderData.weaponName} parameters`,
description: `${args.loaderData.weaponName} parameters with version history compared across ${comparedAcross(args.loaderData.kind)}.`,
location: args.location,
});
};
export default function WeaponParamsPage() {
const data = useLoaderData<typeof loader>();
return (
<WeaponParamsView
kind={data.kind}
weaponId={data.weaponId}
categoryWeaponIds={data.categoryWeaponIds}
weaponParams={data.weaponParams}
specialPoints={data.specialPoints}
damageMultipliers={data.damageMultipliers}
versions={data.versions}
patchHistory={data.patchHistory}
kitPatchHistories={data.kitPatchHistories}
kits={data.kits}
/>
);
}
function weaponImageUrl(kind: WeaponParamKind, weaponId: number) {
if (kind === "sub") return subWeaponImageUrl(weaponId as SubWeaponId);
if (kind === "special") {
return specialWeaponImageUrl(weaponId as SpecialWeaponId);
}
return outlinedMainWeaponImageUrl(weaponId as MainWeaponId);
}
function comparedAcross(kind: WeaponParamKind) {
if (kind === "sub") return "all sub weapons";
if (kind === "special") return "all special weapons";
return "the weapon's category";
}

View File

@@ -0,0 +1,48 @@
import type { DamageReceiver } from "~/features/object-damage-calculator/calculator-types";
/**
* Sentinel `category` used for the special points entry in patch change data, since special
* points are not a regular weapon parameter. The matching `key` is also this value.
*/
export const SPECIAL_POINTS_PARAM_KEY = "__specialPoints__";
/**
* Sentinel `category` used for damage multiplier (damage rate vs objects) entries in patch
* change data. The `key` of such a change holds the damage receiver target instead.
*/
export const DAMAGE_MULTIPLIER_PARAM_KEY = "__damageMultiplier__";
/**
* Sentinel `category` used for incoming damage multiplier entries in patch change data: a change
* to some *other* weapon's damage rate against the page's sub or special weapon (which is itself a
* damageable object). The `key` holds the damage receiver target, and `attackers` holds the
* weapons whose rate changed.
*/
export const INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY =
"__incomingDamageMultiplier__";
/**
* Maps a sub or special weapon to the object {@link DAMAGE_RECEIVERS} target(s) that represent it,
* so the patch history can surface changes to other weapons' damage rates *against* the kit's sub
* or special. Only weapons that exist as a damageable object are listed.
*/
export const INCOMING_DAMAGE_RECEIVERS: Record<
"sub" | "special",
Record<number, readonly DamageReceiver[]>
> = {
special: {
2: ["GreatBarrier_Barrier", "GreatBarrier_WeakPoint"], // Big Bubbler
6: ["NiceBall_Armor"], // Booyah Bomb
7: ["ShockSonar"], // Wave Breaker
8: ["BlowerInhale"], // Ink Vac
12: ["Chariot"], // Crab Tank
16: ["Decoy"], // Super Chump
18: ["BulletPogo"], // Triple Splashdown
},
sub: {
3: ["Wsb_Sprinkler"], // Sprinkler
4: ["Wsb_Shield"], // Splash Wall
8: ["Wsb_Flag"], // Squid Beakon
13: ["Bomb_TorpedoBullet"], // Torpedo
},
};

View File

@@ -0,0 +1,136 @@
import type {
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import type { ParamChangeKind } from "./core/param-directions";
export interface WeaponKitInfo {
weaponId: MainWeaponId;
subWeaponId: SubWeaponId;
specialWeaponId: SpecialWeaponId;
}
export interface ParamValueWithHistory {
current: number | string;
history: Array<{ version: string; value: number | string }>;
}
/** Which set of weapons a params page compares: main weapons, sub weapons or special weapons. */
export type WeaponParamKind = "main" | "sub" | "special";
const WEAPON_PARAM_KIND_KEY_PREFIX: Record<WeaponParamKind, string> = {
main: "MAIN",
sub: "SUB",
special: "SPECIAL",
};
/** The i18next `weapons` namespace key for a weapon of the given {@link WeaponParamKind}. */
export function weaponTranslationKey(kind: WeaponParamKind, id: number) {
return `weapons:${WEAPON_PARAM_KIND_KEY_PREFIX[kind]}_${id}`;
}
export interface ParsedWeaponParams {
weaponId: number;
categories: Record<string, Record<string, ParamValueWithHistory>>;
}
export interface ParamDefinition {
category: string;
key: string;
fullKey: string;
}
/** A single weapon's numeric value for one parameter, used by the cross-weapon comparison chart. */
export interface ParamComparisonEntry {
weaponId: number;
value: number;
name: string;
}
export interface SpecialPointWithHistory {
weaponId: MainWeaponId;
current: number;
history: Array<{ version: string; value: number }>;
}
/**
* History of a weapon's damage multiplier against a single object (a {@link DAMAGE_RECEIVERS}
* target), surfaced only in the patch history. `target` is the receiver key used by the object
* damage calculator.
*/
export interface DamageMultiplierWithHistory {
target: string;
current: number;
history: Array<{ version: string; value: number }>;
}
/**
* The set of weapons whose damage rate against an object changed together. Carried by an incoming
* damage multiplier {@link PatchChange} so the badge can show the attacking weapons' icons.
*/
export interface IncomingDamageAttackers {
mainWeaponIds: MainWeaponId[];
subWeaponIds: SubWeaponId[];
specialWeaponIds: SpecialWeaponId[];
}
/**
* History of some attacking weapons' shared damage multiplier against a single object
* ({@link DamageMultiplierWithHistory} from the defender's perspective): the page's sub or special
* weapon is the object being damaged. `target` is the receiver key used by the object damage
* calculator.
*/
export interface IncomingDamageMultiplierWithHistory {
target: string;
attackers: IncomingDamageAttackers;
current: number;
history: Array<{ version: string; value: number }>;
}
export interface PatchChange {
category: string;
key: string;
from: number | string;
to: number | string;
kind: ParamChangeKind;
/** The specific kit a special points change belongs to. Only set for special points. */
weaponId?: MainWeaponId;
/**
* Which weapon of a kit the change belongs to. Used by the per-kit patch history to group a
* column's changes under a divider per weapon. Only set for kit patch histories.
*/
source?: WeaponParamKind;
/** The weapons whose rate changed. Only set for incoming damage multiplier changes. */
attackers?: IncomingDamageAttackers;
}
export interface WeaponPatch {
version: string;
date: string | null;
changes: PatchChange[];
}
/**
* Patch history of a single main weapon kit, folding the main weapon's changes together with its
* sub and special weapon's changes. Each {@link PatchChange} carries a `source` so the changes can
* be grouped per weapon within a patch.
*/
export interface KitPatchHistory {
weaponId: MainWeaponId;
subWeaponId: SubWeaponId;
specialWeaponId: SpecialWeaponId;
patches: WeaponPatch[];
}
export interface WeaponParamsTableProps {
kind: WeaponParamKind;
currentWeaponId: number;
categoryWeaponIds: number[];
weaponParams: Record<string, ParsedWeaponParams>;
/** Special points are only tracked for main weapons. */
specialPoints?: Record<string, SpecialPointWithHistory[]>;
/** Damage multipliers (damage rate vs objects), keyed by weapon id. */
damageMultipliers?: Record<string, DamageMultiplierWithHistory[]>;
versions: string[];
}