mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 08:07:40 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
473
apps/web-react/app/features/params/core/WeaponParams.test.ts
Normal file
473
apps/web-react/app/features/params/core/WeaponParams.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
774
apps/web-react/app/features/params/core/WeaponParams.ts
Normal file
774
apps/web-react/app/features/params/core/WeaponParams.ts
Normal 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);
|
||||
}
|
||||
186
apps/web-react/app/features/params/core/param-directions.ts
Normal file
186
apps/web-react/app/features/params/core/param-directions.ts
Normal 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";
|
||||
}
|
||||
2115
apps/web-react/app/features/params/core/param-explanations.ts
Normal file
2115
apps/web-react/app/features/params/core/param-explanations.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user