535 lines
16 KiB
TypeScript
535 lines
16 KiB
TypeScript
import {
|
|
gearSlots,
|
|
scaled,
|
|
type LoadoutDraft,
|
|
type TalentTier,
|
|
} from '$lib/builder/loadout';
|
|
|
|
export type LoadoutCatalog = {
|
|
attributes: any[];
|
|
gear: any[];
|
|
gearAugments: any[];
|
|
brands: any[];
|
|
weapons: any[];
|
|
talents: any[];
|
|
skillChipProfile?: {
|
|
primaryOptions: any[];
|
|
secondaryOptions: any[];
|
|
};
|
|
chipSets: any[];
|
|
protocols: any[];
|
|
specializations: any[];
|
|
};
|
|
|
|
export type LoadoutSummary = {
|
|
stats: Array<{ id: string; value: number; sourceCount: number }>;
|
|
abilityStats: Array<{
|
|
abilityId: string;
|
|
stats: Array<{
|
|
id: string;
|
|
value: number;
|
|
sourceCount: number;
|
|
sources: Array<{
|
|
source: string;
|
|
chipIndex: number;
|
|
chipSetId: string;
|
|
value: number;
|
|
}>;
|
|
}>;
|
|
}>;
|
|
activeBonuses: any[];
|
|
equippedTalents: any[];
|
|
};
|
|
|
|
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
|
|
|
|
export function selectedFocus(draft: LoadoutDraft, catalog: LoadoutCatalog) {
|
|
return catalog.specializations
|
|
.flatMap((specialization) =>
|
|
specialization.focuses.map((focus: any) => ({
|
|
...focus,
|
|
specialization,
|
|
})),
|
|
)
|
|
.find((focus: any) => focus.id === draft.specialization.focusId);
|
|
}
|
|
|
|
export function selectedNodeRanks(
|
|
draft: LoadoutDraft,
|
|
catalog: LoadoutCatalog,
|
|
) {
|
|
const focus = selectedFocus(draft, catalog);
|
|
return (
|
|
focus?.nodes.flatMap((node: any) => {
|
|
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
|
|
return selectedRank
|
|
? [
|
|
{
|
|
node,
|
|
rank: node.ranks.find(
|
|
(entry: any) => Number(entry.rank) === selectedRank,
|
|
),
|
|
},
|
|
]
|
|
: [];
|
|
}) ?? []
|
|
);
|
|
}
|
|
|
|
export function catalogAttribute(catalog: LoadoutCatalog, id: string) {
|
|
return catalog.attributes.find((item) => item.id === id);
|
|
}
|
|
|
|
export function applyAttributeValue(
|
|
catalog: LoadoutCatalog,
|
|
current: number,
|
|
statId: string,
|
|
incoming: number,
|
|
) {
|
|
const stat = catalogAttribute(catalog, statId);
|
|
const combined =
|
|
stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
|
|
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
|
|
: current + incoming;
|
|
return stat?.cap_value === null || stat?.cap_value === undefined
|
|
? combined
|
|
: Math.min(combined, Number(stat.cap_value));
|
|
}
|
|
|
|
function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) {
|
|
const numeric = Number(value);
|
|
const rendered = Number.isInteger(numeric)
|
|
? String(numeric)
|
|
: numeric.toFixed(2).replace(/\.?0+$/, '');
|
|
const unit = catalogAttribute(catalog, statId)?.unit ?? '';
|
|
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
|
}
|
|
|
|
function talentTier(
|
|
catalog: LoadoutCatalog,
|
|
talentId: string,
|
|
tier: TalentTier,
|
|
) {
|
|
const item = catalog.talents.find((entry) => entry.id === talentId);
|
|
return item?.tiers.find(
|
|
(entry: any) => entry.tier === (tier === 2 ? 'II' : 'I'),
|
|
);
|
|
}
|
|
|
|
function talentDescription(
|
|
catalog: LoadoutCatalog,
|
|
talentId: string,
|
|
tier: TalentTier,
|
|
) {
|
|
const item = catalog.talents.find((entry) => entry.id === talentId);
|
|
const configured = talentTier(catalog, talentId, tier);
|
|
if (!item || !configured) return '';
|
|
const template = String(item.template || configured.description || item.name);
|
|
return template.replace(/\{(\d+)\}/g, (placeholder, raw) => {
|
|
const effect = configured.effects[Number(raw) - 1];
|
|
return effect
|
|
? unitValue(catalog, effect.value, effect.stat_attribute_id)
|
|
: placeholder;
|
|
});
|
|
}
|
|
|
|
export function summarizeLoadout(
|
|
draft: LoadoutDraft,
|
|
catalog: LoadoutCatalog,
|
|
): LoadoutSummary {
|
|
const values = new Map<string, number>();
|
|
const sources = new Map<string, Set<string>>();
|
|
const add = (statId: string, value: unknown, source: string) => {
|
|
if (
|
|
!statId ||
|
|
!catalogAttribute(catalog, statId) ||
|
|
!Number.isFinite(Number(value))
|
|
)
|
|
return;
|
|
values.set(
|
|
statId,
|
|
applyAttributeValue(
|
|
catalog,
|
|
values.get(statId) ?? 0,
|
|
statId,
|
|
Number(value),
|
|
),
|
|
);
|
|
if (!sources.has(statId)) sources.set(statId, new Set());
|
|
sources.get(statId)!.add(source);
|
|
};
|
|
const byName = (name: string) =>
|
|
catalog.attributes.find(
|
|
(item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase(),
|
|
)?.id ?? '';
|
|
const brandCounts = new Map<string, number>();
|
|
const activeBonuses: any[] = [];
|
|
const equippedTalents: any[] = [];
|
|
const abilityValues = new Map<string, Map<string, number>>();
|
|
const abilitySources = new Map<
|
|
string,
|
|
Map<
|
|
string,
|
|
Array<{
|
|
source: string;
|
|
chipIndex: number;
|
|
chipSetId: string;
|
|
value: number;
|
|
}>
|
|
>
|
|
>();
|
|
const addAbilityStat = (
|
|
abilityId: string,
|
|
statId: string,
|
|
value: unknown,
|
|
chipIndex: number,
|
|
chipSetId: string,
|
|
) => {
|
|
if (
|
|
!abilityId ||
|
|
!statId ||
|
|
!catalogAttribute(catalog, statId) ||
|
|
!Number.isFinite(Number(value))
|
|
)
|
|
return;
|
|
if (!abilityValues.has(abilityId)) abilityValues.set(abilityId, new Map());
|
|
const valuesForAbility = abilityValues.get(abilityId)!;
|
|
valuesForAbility.set(
|
|
statId,
|
|
applyAttributeValue(
|
|
catalog,
|
|
valuesForAbility.get(statId) ?? 0,
|
|
statId,
|
|
Number(value),
|
|
),
|
|
);
|
|
if (!abilitySources.has(abilityId))
|
|
abilitySources.set(abilityId, new Map());
|
|
const sourcesForAbility = abilitySources.get(abilityId)!;
|
|
if (!sourcesForAbility.has(statId)) sourcesForAbility.set(statId, []);
|
|
sourcesForAbility.get(statId)!.push({
|
|
source: `Skill Chip ${chipIndex + 1} primary`,
|
|
chipIndex,
|
|
chipSetId,
|
|
value: Number(value),
|
|
});
|
|
};
|
|
|
|
for (const slot of gearSlots) {
|
|
const selection = draft.gear[slot];
|
|
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
|
|
if (!item) continue;
|
|
const enabledAugments = catalog.gearAugments.filter(
|
|
(entry) =>
|
|
entry.gear_slot === slot &&
|
|
selection.augmentSlots.includes(Number(entry.augment_slot)),
|
|
);
|
|
const basePercentage = enabledAugments
|
|
.filter((entry) => entry.effect_type === 'Base Percentage')
|
|
.reduce((total, entry) => total + Number(entry.value), 0);
|
|
add(
|
|
byName('Health'),
|
|
selection.health * (1 + basePercentage / 100),
|
|
`${slot} base`,
|
|
);
|
|
if (item.base_armor_min !== null)
|
|
add(
|
|
byName('Armor'),
|
|
selection.armor * (1 + basePercentage / 100),
|
|
`${slot} base`,
|
|
);
|
|
for (const roll of selection.rolls)
|
|
add(roll.statId, roll.value, `${slot} roll`);
|
|
for (const key of coreKeys)
|
|
add(byName(key), selection[key], `${slot} core`);
|
|
if (selection.brandId)
|
|
brandCounts.set(
|
|
selection.brandId,
|
|
(brandCounts.get(selection.brandId) ?? 0) + 1,
|
|
);
|
|
for (const augment of enabledAugments.filter(
|
|
(entry) => entry.effect_type === 'Custom',
|
|
)) {
|
|
add(
|
|
augment.stat_attribute_id,
|
|
augment.value,
|
|
`${slot} augment ${augment.augment_slot}`,
|
|
);
|
|
}
|
|
if (selection.talentId) {
|
|
equippedTalents.push({
|
|
key: `gear:${slot}:${selection.talentId}`,
|
|
id: selection.talentId,
|
|
rule:
|
|
catalog.talents.find((entry) => entry.id === selection.talentId)
|
|
?.rule ?? null,
|
|
tier: selection.talentTier,
|
|
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
|
|
isExotic: false,
|
|
source: slot,
|
|
description: talentDescription(
|
|
catalog,
|
|
selection.talentId,
|
|
selection.talentTier,
|
|
),
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const [brandId, count] of brandCounts) {
|
|
const set = catalog.brands.find((entry) => entry.id === brandId);
|
|
for (const effect of set?.effects.filter(
|
|
(entry: any) => Number(entry.pieces_required) <= count,
|
|
) ?? []) {
|
|
add(
|
|
effect.stat_attribute_id,
|
|
effect.value,
|
|
`${set.name} ${effect.pieces_required}-piece`,
|
|
);
|
|
activeBonuses.push({
|
|
name: set.name,
|
|
pieces: effect.pieces_required,
|
|
count,
|
|
statId: effect.stat_attribute_id,
|
|
value: effect.value,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (let index = 0; index < 2; index += 1) {
|
|
const selection = draft.weapons[index];
|
|
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
|
|
if (!item) continue;
|
|
for (const name of ['Firepower', 'Toughness', 'Engineering'] as const) {
|
|
add(
|
|
byName(name),
|
|
selection[name.toLowerCase() as 'firepower'],
|
|
`${index ? 'Secondary' : 'Primary'} weapon`,
|
|
);
|
|
}
|
|
for (const bonus of item.baseBonuses ?? [])
|
|
add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`);
|
|
for (const roll of selection.rolls)
|
|
add(
|
|
roll.statId,
|
|
roll.value,
|
|
`${index ? 'Secondary' : 'Primary'} weapon roll`,
|
|
);
|
|
const equippedWeaponTalents = [
|
|
{
|
|
id: selection.talent1Id,
|
|
tier: selection.talent1Tier,
|
|
isExotic: false,
|
|
},
|
|
{
|
|
id: selection.talent2Id,
|
|
tier: selection.talent2Tier,
|
|
isExotic: false,
|
|
},
|
|
{
|
|
id: item.exotic_talent_id,
|
|
tier: selection.exoticTalentTier,
|
|
isExotic: true,
|
|
},
|
|
].filter((entry) => entry.id);
|
|
for (const equippedTalent of equippedWeaponTalents) {
|
|
equippedTalents.push({
|
|
key: `weapon:${index}:${equippedTalent.id}`,
|
|
id: equippedTalent.id,
|
|
rule:
|
|
catalog.talents.find((entry) => entry.id === equippedTalent.id)
|
|
?.rule ?? null,
|
|
tier: equippedTalent.tier,
|
|
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
|
|
isExotic: equippedTalent.isExotic,
|
|
source: index ? 'Secondary weapon' : 'Primary weapon',
|
|
description: talentDescription(
|
|
catalog,
|
|
equippedTalent.id,
|
|
equippedTalent.tier,
|
|
),
|
|
});
|
|
}
|
|
}
|
|
|
|
const chipGroups = new Map<
|
|
string,
|
|
{ count: number; highEnd: number; item: any }
|
|
>();
|
|
const validChipRoll = (
|
|
kind: 'primaryOptions' | 'secondaryOptions',
|
|
quality: string,
|
|
roll: { statId: string; value: number },
|
|
) => {
|
|
if (!catalog.skillChipProfile) return true;
|
|
const range = catalog.skillChipProfile[kind].find(
|
|
(option) => option.statId === roll.statId,
|
|
)?.ranges?.[quality];
|
|
return (
|
|
range &&
|
|
roll.value >= Number(range.min) &&
|
|
roll.value <= Number(range.max)
|
|
);
|
|
};
|
|
for (let index = 0; index < 9; index += 1) {
|
|
const selection = draft.chips[index];
|
|
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
|
if (!item) continue;
|
|
if (validChipRoll('primaryOptions', selection.quality, selection.primary))
|
|
addAbilityStat(
|
|
String(item.specialization_ability_id ?? ''),
|
|
selection.primary.statId,
|
|
selection.primary.value,
|
|
index,
|
|
String(item.id),
|
|
);
|
|
for (const roll of selection.secondary)
|
|
if (validChipRoll('secondaryOptions', selection.quality, roll))
|
|
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
|
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
|
|
group.count += 1;
|
|
if (selection.quality === 'High-end') group.highEnd += 1;
|
|
chipGroups.set(item.id, group);
|
|
}
|
|
for (const group of chipGroups.values()) {
|
|
if (group.count >= 2 && group.item.twoPiece) {
|
|
const value = scaled(
|
|
group.item.twoPiece.min_value,
|
|
group.item.twoPiece.max_value,
|
|
group.highEnd,
|
|
3,
|
|
);
|
|
add(
|
|
group.item.twoPiece.stat_attribute_id,
|
|
value,
|
|
`${group.item.name} 2-piece`,
|
|
);
|
|
activeBonuses.push({
|
|
name: group.item.name,
|
|
pieces: 2,
|
|
count: group.count,
|
|
statId: group.item.twoPiece.stat_attribute_id,
|
|
value,
|
|
});
|
|
}
|
|
if (group.count === 3 && group.item.talent) {
|
|
equippedTalents.push({
|
|
key: `chips:${group.item.id}`,
|
|
id: '',
|
|
custom: group.item.talent,
|
|
rule: group.item.talent.rule ?? null,
|
|
tier: group.highEnd,
|
|
abilityId: group.item.specialization_ability_id ?? null,
|
|
source: '3 Skill Chips',
|
|
description: group.item.talent.description_template.replace(
|
|
/\{(\d+)\}/g,
|
|
(placeholder: string, raw: string) => {
|
|
const effect = group.item.talent.effects[Number(raw) - 1];
|
|
return effect
|
|
? unitValue(
|
|
catalog,
|
|
scaled(effect.min_value, effect.max_value, group.highEnd, 3),
|
|
effect.stat_attribute_id,
|
|
)
|
|
: placeholder;
|
|
},
|
|
),
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const { node, rank } of selectedNodeRanks(draft, catalog)) {
|
|
for (const effect of rank?.effects ?? []) {
|
|
if (effect.effect_type === 'Attribute')
|
|
add(
|
|
effect.stat_attribute_id,
|
|
effect.value,
|
|
`${node.name} rank ${rank.rank}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const protocol = catalog.protocols.find(
|
|
(entry) => entry.id === draft.protocol.itemId,
|
|
);
|
|
if (protocol) {
|
|
if (protocol.primaryEffect) {
|
|
add(
|
|
protocol.primaryEffect.stat_attribute_id,
|
|
Number(protocol.primaryEffect.base_value) *
|
|
(1 + (draft.protocol.level - 1) / 49),
|
|
`${protocol.name} primary`,
|
|
);
|
|
}
|
|
for (const effect of protocol.secondaryEffects ?? []) {
|
|
add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`);
|
|
}
|
|
if (protocol.talent) {
|
|
equippedTalents.push({
|
|
key: `protocol:${protocol.id}`,
|
|
id: '',
|
|
custom: protocol.talent,
|
|
rule: protocol.talent.rule ?? null,
|
|
tier: draft.protocol.talentUpgrades,
|
|
source: 'OS Protocol',
|
|
description: protocol.talent.description_template.replace(
|
|
/\{(\d+)\}/g,
|
|
(placeholder: string, raw: string) => {
|
|
const effect = protocol.talent.effects[Number(raw) - 1];
|
|
return effect
|
|
? unitValue(
|
|
catalog,
|
|
Number(effect.base_value) *
|
|
(1 + draft.protocol.talentUpgrades / 5),
|
|
effect.stat_attribute_id,
|
|
)
|
|
: placeholder;
|
|
},
|
|
),
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
stats: [...values]
|
|
.map(([id, value]) => ({
|
|
id,
|
|
value,
|
|
sourceCount: sources.get(id)?.size ?? 0,
|
|
}))
|
|
.sort(
|
|
(left, right) =>
|
|
(catalogAttribute(catalog, left.id)?.category ?? '').localeCompare(
|
|
catalogAttribute(catalog, right.id)?.category ?? '',
|
|
) ||
|
|
(catalogAttribute(catalog, left.id)?.name ?? '').localeCompare(
|
|
catalogAttribute(catalog, right.id)?.name ?? '',
|
|
),
|
|
),
|
|
abilityStats: [...abilityValues].map(([abilityId, abilityStatValues]) => ({
|
|
abilityId,
|
|
stats: [...abilityStatValues]
|
|
.map(([id, value]) => {
|
|
const statSources = abilitySources.get(abilityId)?.get(id) ?? [];
|
|
return {
|
|
id,
|
|
value,
|
|
sourceCount: statSources.length,
|
|
sources: statSources,
|
|
};
|
|
})
|
|
.sort(
|
|
(left, right) =>
|
|
(catalogAttribute(catalog, left.id)?.category ?? '').localeCompare(
|
|
catalogAttribute(catalog, right.id)?.category ?? '',
|
|
) ||
|
|
(catalogAttribute(catalog, left.id)?.name ?? '').localeCompare(
|
|
catalogAttribute(catalog, right.id)?.name ?? '',
|
|
),
|
|
),
|
|
})),
|
|
activeBonuses,
|
|
equippedTalents,
|
|
};
|
|
}
|