207 lines
10 KiB
TypeScript
207 lines
10 KiB
TypeScript
export type Roll = { statId: string; value: number };
|
|
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: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
|
|
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number };
|
|
export type CombatProfile = { encounterSeconds: number; hitRate: number; headshotRate: number; firingActivity: number };
|
|
export type EffectMode = 'off' | 'peak' | 'encounter';
|
|
export type ConditionalEffectSelection = { mode: EffectMode; overrides?: Partial<CombatProfile> };
|
|
export type SpecializationSelection = {
|
|
focusId: string;
|
|
nodeRanks: Record<string, number>;
|
|
effectStates: Record<string, ConditionalEffectSelection>;
|
|
combatProfile: CombatProfile;
|
|
};
|
|
export type LoadoutDraft = {
|
|
version: 2;
|
|
buildName: string;
|
|
gear: Record<string, GearSelection>;
|
|
weapons: [WeaponSelection, WeaponSelection];
|
|
chips: [ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection];
|
|
protocol: ProtocolSelection;
|
|
activeTalents: string[];
|
|
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: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
|
|
export const defaultCombatProfile = (): CombatProfile => ({ encounterSeconds: 60, hitRate: 75, headshotRate: 20, firingActivity: 80 });
|
|
export const createDraft = (): LoadoutDraft => ({
|
|
version: 2,
|
|
buildName: 'Untitled loadout',
|
|
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 },
|
|
activeTalents: [],
|
|
specialization: {
|
|
focusId: '',
|
|
nodeRanks: {},
|
|
effectStates: {},
|
|
combatProfile: defaultCombatProfile()
|
|
}
|
|
});
|
|
|
|
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'
|
|
&& (value.quality === 'Superior' || value.quality === 'High-end')
|
|
&& isRoll(value.primary)
|
|
&& isRollPair(value.secondary);
|
|
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', 'firingActivity'])
|
|
&& ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].every((key) => isFiniteNumber(value[key]));
|
|
const isEffectStates = (value: unknown): value is Record<string, ConditionalEffectSelection> =>
|
|
isRecord(value) && Object.values(value).every((entry) => {
|
|
if (!isRecord(entry)
|
|
|| !hasExactKeys(entry, entry.overrides === undefined ? ['mode'] : ['mode', 'overrides'])
|
|
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
|
|
return entry.overrides === undefined || (
|
|
isRecord(entry.overrides)
|
|
&& Object.entries(entry.overrides).every(([key, override]) =>
|
|
['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].includes(key) && isFiniteNumber(override)
|
|
)
|
|
);
|
|
});
|
|
|
|
export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
|
|
if (!isRecord(value)
|
|
|| !hasExactKeys(value, ['version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', 'activeTalents', 'specialization'])
|
|
|| value.version !== 2
|
|
|| typeof value.buildName !== 'string') 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) || !Array.isArray(value.activeTalents)
|
|
|| !value.activeTalents.every((talent) => typeof talent === 'string')) return null;
|
|
if (!isRecord(value.specialization)
|
|
|| !hasExactKeys(value.specialization, ['focusId', 'nodeRanks', 'effectStates', 'combatProfile'])
|
|
|| typeof value.specialization.focusId !== 'string'
|
|
|| !isNumberRecord(value.specialization.nodeRanks)
|
|
|| !isEffectStates(value.specialization.effectStates)
|
|
|| !isCombatProfile(value.specialization.combatProfile)) 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);
|
|
}
|