531 lines
14 KiB
TypeScript
531 lines
14 KiB
TypeScript
export type Roll = { statId: string; value: number };
|
|
export const skillChipQualities = [
|
|
'Standard',
|
|
'Specialized',
|
|
'Superior',
|
|
'High-end',
|
|
] as const;
|
|
export type SkillChipQuality = (typeof skillChipQualities)[number];
|
|
export const skillChipSecondaryCapacity: Record<SkillChipQuality, number> = {
|
|
Standard: 0,
|
|
Specialized: 1,
|
|
Superior: 2,
|
|
'High-end': 2,
|
|
};
|
|
export type GearSelection = {
|
|
itemId: string;
|
|
level: number;
|
|
brandId: string;
|
|
talentId: string;
|
|
health: number;
|
|
armor: number;
|
|
firepower: number;
|
|
toughness: number;
|
|
engineering: number;
|
|
rolls: [Roll, Roll];
|
|
augmentSlots: number[];
|
|
};
|
|
export type WeaponSelection = {
|
|
itemId: string;
|
|
level: number;
|
|
tier: number;
|
|
damage: number;
|
|
firepower: number;
|
|
toughness: number;
|
|
engineering: number;
|
|
talent1Id: string;
|
|
talent2Id: string;
|
|
rolls: [Roll, Roll];
|
|
};
|
|
export type ChipSelection = {
|
|
setId: string;
|
|
quality: SkillChipQuality;
|
|
primary: Roll;
|
|
secondary: Roll[];
|
|
};
|
|
export type ProtocolSelection = {
|
|
itemId: string;
|
|
level: number;
|
|
talentUpgrades: number;
|
|
};
|
|
export type CombatProfile = {
|
|
encounterSeconds: number;
|
|
hitRate: number;
|
|
headshotRate: number;
|
|
shotsPerBurst: number;
|
|
burstPauseSeconds: number;
|
|
sameTargetSeconds: number;
|
|
distinctTargetsPerMinute: number;
|
|
killsPerMinute: number;
|
|
headshotKillRate: number;
|
|
damageTakenPerMinute: number;
|
|
swapsPerMinute: number;
|
|
targetDistanceMeters: number;
|
|
healthPercent: number;
|
|
extraHealthActive: boolean;
|
|
};
|
|
export type CalculationMode = 'maximum' | 'custom';
|
|
export type EffectMode = 'off' | 'peak' | 'encounter';
|
|
export type ConditionalEffectSelection = {
|
|
mode: EffectMode;
|
|
overrides?: Partial<CombatProfile>;
|
|
inputs?: Record<string, number | boolean>;
|
|
};
|
|
export type SkillActivationSelection = {
|
|
mode: 'off' | 'on_cooldown' | 'custom_per_minute';
|
|
usesPerMinute?: number;
|
|
targets: number;
|
|
};
|
|
export type SpecializationSelection = {
|
|
focusId: string;
|
|
nodeRanks: Record<string, number>;
|
|
};
|
|
export type LoadoutDraft = {
|
|
version: 5;
|
|
buildName: string;
|
|
calculationMode: CalculationMode;
|
|
gear: Record<string, GearSelection>;
|
|
weapons: [WeaponSelection, WeaponSelection];
|
|
chips: [
|
|
ChipSelection,
|
|
ChipSelection,
|
|
ChipSelection,
|
|
ChipSelection,
|
|
ChipSelection,
|
|
ChipSelection,
|
|
ChipSelection,
|
|
ChipSelection,
|
|
ChipSelection,
|
|
];
|
|
protocol: ProtocolSelection;
|
|
conditionalEffects: Record<string, ConditionalEffectSelection>;
|
|
combatProfile: CombatProfile;
|
|
skillActivations: Record<string, SkillActivationSelection>;
|
|
specialization: SpecializationSelection;
|
|
};
|
|
|
|
export const gearSlots = [
|
|
'Holster',
|
|
'Gloves',
|
|
'Backpack',
|
|
'Knee Pads',
|
|
'Body Armor',
|
|
'Mask',
|
|
] as const;
|
|
export const blankRoll = (): Roll => ({ statId: '', value: 0 });
|
|
export const blankGear = (): GearSelection => ({
|
|
itemId: '',
|
|
level: 50,
|
|
brandId: '',
|
|
talentId: '',
|
|
health: 0,
|
|
armor: 0,
|
|
firepower: 0,
|
|
toughness: 0,
|
|
engineering: 0,
|
|
rolls: [blankRoll(), blankRoll()],
|
|
augmentSlots: [],
|
|
});
|
|
export const blankWeapon = (): WeaponSelection => ({
|
|
itemId: '',
|
|
level: 50,
|
|
tier: 0,
|
|
damage: 0,
|
|
firepower: 0,
|
|
toughness: 0,
|
|
engineering: 0,
|
|
talent1Id: '',
|
|
talent2Id: '',
|
|
rolls: [blankRoll(), blankRoll()],
|
|
});
|
|
export const blankChip = (): ChipSelection => ({
|
|
setId: '',
|
|
quality: 'Standard',
|
|
primary: blankRoll(),
|
|
secondary: [],
|
|
});
|
|
export const defaultCombatProfile = (): CombatProfile => ({
|
|
encounterSeconds: 60,
|
|
hitRate: 75,
|
|
headshotRate: 20,
|
|
shotsPerBurst: 30,
|
|
burstPauseSeconds: 0,
|
|
sameTargetSeconds: 5,
|
|
distinctTargetsPerMinute: 12,
|
|
killsPerMinute: 4,
|
|
headshotKillRate: 20,
|
|
damageTakenPerMinute: 6,
|
|
swapsPerMinute: 2,
|
|
targetDistanceMeters: 15,
|
|
healthPercent: 100,
|
|
extraHealthActive: false,
|
|
});
|
|
export const createDraft = (): LoadoutDraft => ({
|
|
version: 5,
|
|
buildName: 'Untitled loadout',
|
|
calculationMode: 'maximum',
|
|
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
|
weapons: [blankWeapon(), blankWeapon()],
|
|
chips: [
|
|
blankChip(),
|
|
blankChip(),
|
|
blankChip(),
|
|
blankChip(),
|
|
blankChip(),
|
|
blankChip(),
|
|
blankChip(),
|
|
blankChip(),
|
|
blankChip(),
|
|
],
|
|
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
|
|
conditionalEffects: {},
|
|
combatProfile: defaultCombatProfile(),
|
|
skillActivations: {},
|
|
specialization: {
|
|
focusId: '',
|
|
nodeRanks: {},
|
|
},
|
|
});
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
|
|
Object.keys(value).length === keys.length &&
|
|
keys.every((key) => key in value);
|
|
const isFiniteNumber = (value: unknown): value is number =>
|
|
typeof value === 'number' && Number.isFinite(value);
|
|
const isNumberRecord = (value: unknown) =>
|
|
isRecord(value) &&
|
|
Object.values(value).every(
|
|
(entry) => typeof entry === 'number' && Number.isFinite(entry),
|
|
);
|
|
const isRoll = (value: unknown): value is Roll =>
|
|
isRecord(value) &&
|
|
hasExactKeys(value, ['statId', 'value']) &&
|
|
typeof value.statId === 'string' &&
|
|
isFiniteNumber(value.value);
|
|
const isRollPair = (value: unknown): value is [Roll, Roll] =>
|
|
Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
|
const isGearSelection = (value: unknown): value is GearSelection =>
|
|
isRecord(value) &&
|
|
hasExactKeys(value, [
|
|
'itemId',
|
|
'level',
|
|
'brandId',
|
|
'talentId',
|
|
'health',
|
|
'armor',
|
|
'firepower',
|
|
'toughness',
|
|
'engineering',
|
|
'rolls',
|
|
'augmentSlots',
|
|
]) &&
|
|
['itemId', 'brandId', 'talentId'].every(
|
|
(key) => typeof value[key] === 'string',
|
|
) &&
|
|
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
|
|
(key) => isFiniteNumber(value[key]),
|
|
) &&
|
|
isRollPair(value.rolls) &&
|
|
Array.isArray(value.augmentSlots) &&
|
|
value.augmentSlots.every(isFiniteNumber);
|
|
const isWeaponSelection = (value: unknown): value is WeaponSelection =>
|
|
isRecord(value) &&
|
|
hasExactKeys(value, [
|
|
'itemId',
|
|
'level',
|
|
'tier',
|
|
'damage',
|
|
'firepower',
|
|
'toughness',
|
|
'engineering',
|
|
'talent1Id',
|
|
'talent2Id',
|
|
'rolls',
|
|
]) &&
|
|
['itemId', 'talent1Id', 'talent2Id'].every(
|
|
(key) => typeof value[key] === 'string',
|
|
) &&
|
|
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
|
|
(key) => isFiniteNumber(value[key]),
|
|
) &&
|
|
isRollPair(value.rolls);
|
|
const isChipSelection = (value: unknown): value is ChipSelection =>
|
|
isRecord(value) &&
|
|
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
|
typeof value.setId === 'string' &&
|
|
skillChipQualities.some((quality) => value.quality === quality) &&
|
|
isRoll(value.primary) &&
|
|
Array.isArray(value.secondary) &&
|
|
value.secondary.length ===
|
|
skillChipSecondaryCapacity[value.quality as SkillChipQuality] &&
|
|
value.secondary.every(isRoll);
|
|
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
|
|
isRecord(value) &&
|
|
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
|
typeof value.itemId === 'string' &&
|
|
isFiniteNumber(value.level) &&
|
|
isFiniteNumber(value.talentUpgrades);
|
|
const isCombatProfile = (value: unknown): value is CombatProfile =>
|
|
isRecord(value) &&
|
|
hasExactKeys(value, [
|
|
'encounterSeconds',
|
|
'hitRate',
|
|
'headshotRate',
|
|
'shotsPerBurst',
|
|
'burstPauseSeconds',
|
|
'sameTargetSeconds',
|
|
'distinctTargetsPerMinute',
|
|
'killsPerMinute',
|
|
'headshotKillRate',
|
|
'damageTakenPerMinute',
|
|
'swapsPerMinute',
|
|
'targetDistanceMeters',
|
|
'healthPercent',
|
|
'extraHealthActive',
|
|
]) &&
|
|
[
|
|
'encounterSeconds',
|
|
'hitRate',
|
|
'headshotRate',
|
|
'shotsPerBurst',
|
|
'burstPauseSeconds',
|
|
'sameTargetSeconds',
|
|
'distinctTargetsPerMinute',
|
|
'killsPerMinute',
|
|
'headshotKillRate',
|
|
'damageTakenPerMinute',
|
|
'swapsPerMinute',
|
|
'targetDistanceMeters',
|
|
'healthPercent',
|
|
].every((key) => isFiniteNumber(value[key])) &&
|
|
typeof value.extraHealthActive === 'boolean';
|
|
const isEffectStates = (
|
|
value: unknown,
|
|
): value is Record<string, ConditionalEffectSelection> =>
|
|
isRecord(value) &&
|
|
Object.values(value).every((entry) => {
|
|
if (
|
|
!isRecord(entry) ||
|
|
!['off', 'peak', 'encounter'].includes(String(entry.mode))
|
|
)
|
|
return false;
|
|
const allowed = new Set(['mode', 'overrides', 'inputs']);
|
|
if (
|
|
Object.keys(entry).some((key) => !allowed.has(key)) ||
|
|
!['off', 'peak', 'encounter'].includes(String(entry.mode))
|
|
)
|
|
return false;
|
|
if (
|
|
entry.inputs !== undefined &&
|
|
(!isRecord(entry.inputs) ||
|
|
Object.values(entry.inputs).some(
|
|
(input) => typeof input !== 'boolean' && !isFiniteNumber(input),
|
|
))
|
|
)
|
|
return false;
|
|
return (
|
|
entry.overrides === undefined ||
|
|
(isRecord(entry.overrides) &&
|
|
Object.entries(entry.overrides).every(([key, override]) =>
|
|
key === 'extraHealthActive'
|
|
? typeof override === 'boolean'
|
|
: key in defaultCombatProfile() && isFiniteNumber(override),
|
|
))
|
|
);
|
|
});
|
|
const isSkillActivations = (
|
|
value: unknown,
|
|
): value is Record<string, SkillActivationSelection> =>
|
|
isRecord(value) &&
|
|
Object.values(value).every(
|
|
(entry) =>
|
|
isRecord(entry) &&
|
|
['off', 'on_cooldown', 'custom_per_minute'].includes(
|
|
String(entry.mode),
|
|
) &&
|
|
isFiniteNumber(entry.targets) &&
|
|
(entry.usesPerMinute === undefined ||
|
|
isFiniteNumber(entry.usesPerMinute)) &&
|
|
Object.keys(entry).every((key) =>
|
|
['mode', 'usesPerMinute', 'targets'].includes(key),
|
|
),
|
|
);
|
|
|
|
export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
|
|
if (
|
|
!isRecord(value) ||
|
|
!hasExactKeys(value, [
|
|
'version',
|
|
'buildName',
|
|
'calculationMode',
|
|
'gear',
|
|
'weapons',
|
|
'chips',
|
|
'protocol',
|
|
'conditionalEffects',
|
|
'combatProfile',
|
|
'skillActivations',
|
|
'specialization',
|
|
]) ||
|
|
value.version !== 5 ||
|
|
typeof value.buildName !== 'string' ||
|
|
!['maximum', 'custom'].includes(String(value.calculationMode))
|
|
)
|
|
return null;
|
|
const gear = value.gear;
|
|
if (
|
|
!isRecord(gear) ||
|
|
Object.keys(gear).length !== gearSlots.length ||
|
|
!gearSlots.every((slot) => isGearSelection(gear[slot]))
|
|
)
|
|
return null;
|
|
if (
|
|
!Array.isArray(value.weapons) ||
|
|
value.weapons.length !== 2 ||
|
|
!value.weapons.every(isWeaponSelection)
|
|
)
|
|
return null;
|
|
if (
|
|
!Array.isArray(value.chips) ||
|
|
value.chips.length !== 9 ||
|
|
!value.chips.every(isChipSelection)
|
|
)
|
|
return null;
|
|
if (
|
|
!isProtocolSelection(value.protocol) ||
|
|
!isEffectStates(value.conditionalEffects) ||
|
|
!isCombatProfile(value.combatProfile) ||
|
|
!isSkillActivations(value.skillActivations)
|
|
)
|
|
return null;
|
|
if (
|
|
!isRecord(value.specialization) ||
|
|
!hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) ||
|
|
typeof value.specialization.focusId !== 'string' ||
|
|
!isNumberRecord(value.specialization.nodeRanks)
|
|
)
|
|
return null;
|
|
return structuredClone(value) as LoadoutDraft;
|
|
}
|
|
|
|
export const clamp = (value: unknown, min: unknown, max: unknown) => {
|
|
const low = Number(min),
|
|
high = Number(max),
|
|
numeric = Number(value);
|
|
if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0;
|
|
return Math.min(
|
|
Number.isFinite(high) ? high : numeric,
|
|
Math.max(Number.isFinite(low) ? low : numeric, numeric),
|
|
);
|
|
};
|
|
|
|
export const scaled = (
|
|
min: unknown,
|
|
max: unknown,
|
|
progress: number,
|
|
steps: number,
|
|
) =>
|
|
Number(min) +
|
|
((Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress))) /
|
|
steps;
|
|
|
|
export type LevelScaledRange = {
|
|
rawMin: number;
|
|
rawMax: number;
|
|
min: number;
|
|
max: number;
|
|
provisional: boolean;
|
|
};
|
|
|
|
const itemLevel = (value: unknown) => Math.round(clamp(value, 1, 50));
|
|
export const gearMultiplier = (level: unknown) =>
|
|
0.91 + 0.05 * itemLevel(level);
|
|
export const weaponMultiplier = (level: unknown) =>
|
|
1.14 + 0.05 * itemLevel(level);
|
|
|
|
export function gearBaseStatRange(
|
|
minimum: unknown,
|
|
maximum: unknown,
|
|
targetLevel: unknown,
|
|
): LevelScaledRange {
|
|
const target = itemLevel(targetLevel);
|
|
const configuredMin = Number(minimum);
|
|
const configuredMax = Number(maximum);
|
|
const rawMin =
|
|
(Number.isFinite(configuredMin) ? configuredMin : 0) *
|
|
gearMultiplier(target);
|
|
const rawMax =
|
|
(Number.isFinite(configuredMax) ? configuredMax : 0) *
|
|
gearMultiplier(target);
|
|
return {
|
|
rawMin,
|
|
rawMax,
|
|
min: Math.floor(rawMin),
|
|
max: Math.floor(rawMax),
|
|
provisional: target < 10,
|
|
};
|
|
}
|
|
|
|
export function weaponBaseRange(
|
|
minimum: unknown,
|
|
maximum: unknown,
|
|
targetLevel: unknown,
|
|
): LevelScaledRange {
|
|
const target = itemLevel(targetLevel);
|
|
const configuredMin = Number(minimum);
|
|
const configuredMax = Number(maximum);
|
|
const rawMin =
|
|
(Number.isFinite(configuredMin) ? configuredMin : 0) *
|
|
weaponMultiplier(target);
|
|
const rawMax =
|
|
(Number.isFinite(configuredMax) ? configuredMax : 0) *
|
|
weaponMultiplier(target);
|
|
return {
|
|
rawMin,
|
|
rawMax,
|
|
min: Math.floor(rawMin),
|
|
max: Math.floor(rawMax),
|
|
provisional: target < 40,
|
|
};
|
|
}
|
|
|
|
export function levelScaledRange(
|
|
minimum: unknown,
|
|
maximum: unknown,
|
|
targetLevel: unknown,
|
|
referenceLevel: unknown,
|
|
source: 'gear' | 'weapon',
|
|
): LevelScaledRange {
|
|
const target = itemLevel(targetLevel);
|
|
const reference = itemLevel(referenceLevel);
|
|
const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier;
|
|
const ratio = multiplier(target) / multiplier(reference);
|
|
const configuredMin = Number(minimum);
|
|
const configuredMax = Number(maximum);
|
|
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio;
|
|
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio;
|
|
return {
|
|
rawMin,
|
|
rawMax,
|
|
min: Math.floor(rawMin),
|
|
max: Math.floor(rawMax),
|
|
provisional:
|
|
source === 'gear'
|
|
? target < 10 || reference < 10
|
|
: target < 40 || reference < 40,
|
|
};
|
|
}
|
|
|
|
export function remapRangeValue(
|
|
value: unknown,
|
|
from: LevelScaledRange,
|
|
to: LevelScaledRange,
|
|
) {
|
|
const span = from.max - from.min;
|
|
const position =
|
|
span > 0 ? (clamp(value, from.min, from.max) - from.min) / span : 0;
|
|
return Math.round(to.min + (to.max - to.min) * position);
|
|
}
|