feat: add community publishing and news syndication
This commit is contained in:
+462
-227
@@ -1,279 +1,514 @@
|
||||
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 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;
|
||||
shotsPerBurst: number;
|
||||
burstPauseSeconds: number;
|
||||
sameTargetSeconds: number;
|
||||
distinctTargetsPerMinute: number;
|
||||
killsPerMinute: number;
|
||||
headshotKillRate: number;
|
||||
damageTakenPerMinute: number;
|
||||
swapsPerMinute: number;
|
||||
targetDistanceMeters: number;
|
||||
healthPercent: number;
|
||||
extraHealthActive: boolean;
|
||||
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>;
|
||||
mode: EffectMode;
|
||||
overrides?: Partial<CombatProfile>;
|
||||
inputs?: Record<string, number | boolean>;
|
||||
};
|
||||
export type SkillActivationSelection = {
|
||||
mode: 'off' | 'on_cooldown' | 'custom_per_minute';
|
||||
usesPerMinute?: number;
|
||||
targets: number;
|
||||
mode: 'off' | 'on_cooldown' | 'custom_per_minute';
|
||||
usesPerMinute?: number;
|
||||
targets: number;
|
||||
};
|
||||
export type SpecializationSelection = {
|
||||
focusId: string;
|
||||
nodeRanks: Record<string, number>;
|
||||
focusId: string;
|
||||
nodeRanks: Record<string, number>;
|
||||
};
|
||||
export type LoadoutDraft = {
|
||||
version: 4;
|
||||
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;
|
||||
version: 4;
|
||||
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 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 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,
|
||||
shotsPerBurst: 30,
|
||||
burstPauseSeconds: 0,
|
||||
sameTargetSeconds: 5,
|
||||
distinctTargetsPerMinute: 12,
|
||||
killsPerMinute: 4,
|
||||
headshotKillRate: 20,
|
||||
damageTakenPerMinute: 6,
|
||||
swapsPerMinute: 2,
|
||||
targetDistanceMeters: 15,
|
||||
healthPercent: 100,
|
||||
extraHealthActive: false
|
||||
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: 4,
|
||||
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: {}
|
||||
}
|
||||
version: 4,
|
||||
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);
|
||||
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);
|
||||
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));
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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))
|
||||
);
|
||||
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 !== 4
|
||||
|| 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;
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasExactKeys(value, [
|
||||
'version',
|
||||
'buildName',
|
||||
'calculationMode',
|
||||
'gear',
|
||||
'weapons',
|
||||
'chips',
|
||||
'protocol',
|
||||
'conditionalEffects',
|
||||
'combatProfile',
|
||||
'skillActivations',
|
||||
'specialization',
|
||||
]) ||
|
||||
value.version !== 4 ||
|
||||
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));
|
||||
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 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;
|
||||
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 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 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 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'
|
||||
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
|
||||
};
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user