Files
shd.build/src/lib/builder/loadout-summary.ts
T
jb 7ca6cb8458
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 33s
Validate and deploy staging / deploy (push) Successful in 1m9s
Updating mechanics for OS protocols
2026-08-03 16:12:20 -04:00

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