Files
shd.build/src/lib/builder/loadout.ts
T
jb 57068b61cd
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s
feat: expand ISAC content modeling
2026-08-03 01:23:20 -04:00

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);
}