Files
shd.build/src/lib/builder/loadout-combat.ts
T

425 lines
17 KiB
TypeScript

import {
calculateCombat,
type AttributeBinding,
type CalculationStage
} from '$lib/calculations/combat';
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
import type { WeaponCalculationResult } from '$lib/components/builder/WeaponCalculationResult.svelte';
type Catalog = {
attributes: any[];
gear: any[];
gearAugments: any[];
brands: any[];
weapons: any[];
talents: any[];
chipSets: any[];
protocols: any[];
specializations: any[];
calculation: { revision: any | null; stages: any[]; profiles: any[] };
};
export type LoadoutSummary = {
stats: Array<{ id: string; value: number; sourceCount: number }>;
activeBonuses: any[];
equippedTalents: any[];
};
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
function selectedFocus(draft: LoadoutDraft, catalog: Catalog) {
return catalog.specializations
.flatMap((specialization) => specialization.focuses.map((focus: any) => ({ ...focus, specialization })))
.find((focus: any) => focus.id === draft.specialization.focusId);
}
function selectedNodeRanks(draft: LoadoutDraft, catalog: Catalog) {
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) }]
: [];
}) ?? [];
}
function attribute(catalog: Catalog, id: string) {
return catalog.attributes.find((item) => item.id === id);
}
function unitValue(catalog: Catalog, value: unknown, statId: string) {
const numeric = Number(value);
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
const unit = attribute(catalog, statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
}
function talentTier(catalog: Catalog, talentId: string, tier: number) {
const item = catalog.talents.find((entry) => entry.id === talentId);
return item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ?? item?.tiers[0];
}
function talentDescription(catalog: Catalog, talentId: string, tier: number) {
const item = catalog.talents.find((entry) => entry.id === talentId);
const configured = talentTier(catalog, talentId, tier);
if (!item || !configured) return '';
const template = String(item.template || 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: Catalog): LoadoutSummary {
const values = new Map<string, number>();
const sources = new Map<string, Set<string>>();
const add = (statId: string, value: unknown, source: string) => {
if (!statId || !Number.isFinite(Number(value))) return;
const stat = attribute(catalog, statId);
const current = values.get(statId) ?? 0;
const incoming = Number(value);
const combined = stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
: current + incoming;
values.set(
statId,
stat?.cap_value === null || stat?.cap_value === undefined
? combined
: Math.min(combined, Number(stat.cap_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[] = [];
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,
tier: Number(item.tier),
source: slot,
description: talentDescription(catalog, selection.talentId, Number(item.tier))
});
}
}
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`);
for (const talentId of [selection.talent1Id, selection.talent2Id, item.exotic_talent_id].filter(Boolean)) {
equippedTalents.push({
key: `weapon:${index}:${talentId}`,
id: talentId,
tier: selection.tier,
source: index ? 'Secondary weapon' : 'Primary weapon',
description: talentDescription(catalog, talentId, selection.tier)
});
}
}
const chipGroups = new Map<string, { count: number; highEnd: number; item: any }>();
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;
add(selection.primary.statId, selection.primary.value, `Skill Chip ${index + 1} primary`);
for (const roll of selection.secondary) 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,
tier: group.highEnd,
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,
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;
})
});
}
}
for (const equipped of equippedTalents) {
if (!draft.activeTalents.includes(equipped.key)) continue;
const effects = equipped.custom?.effects ?? talentTier(catalog, equipped.id, equipped.tier)?.effects ?? [];
for (const effect of effects) {
const value = equipped.key.startsWith('chips:')
? scaled(effect.min_value, effect.max_value, equipped.tier, 3)
: equipped.key.startsWith('protocol:')
? Number(effect.base_value) * (1 + equipped.tier / 5)
: effect.value;
add(effect.stat_attribute_id, value, `${equipped.source} talent`);
}
}
return {
stats: [...values]
.map(([id, value]) => ({ id, value, sourceCount: sources.get(id)?.size ?? 0 }))
.sort((left, right) =>
(attribute(catalog, left.id)?.category ?? '').localeCompare(attribute(catalog, right.id)?.category ?? '')
|| (attribute(catalog, left.id)?.name ?? '').localeCompare(attribute(catalog, right.id)?.name ?? '')
),
activeBonuses,
equippedTalents
};
}
function calculationStage(catalog: Catalog, stageKey: string): CalculationStage | null {
if (['flat_damage', 'weapon_damage', 'critical_chance', 'critical_damage', 'headshot_chance', 'headshot_damage', 'final_damage'].includes(stageKey)) {
return stageKey as CalculationStage;
}
const stage = catalog.calculation.stages.find((item) => item.stage_key === stageKey);
if (!stage) return null;
if (stage.operator === 'flat_add') return 'flat_damage';
if (stage.operator === 'additive_percent') return 'weapon_damage';
if (stage.operator === 'multiplicative_percent') return 'final_damage';
if (stage.operator === 'expected_critical') return stageKey.includes('chance') ? 'critical_chance' : 'critical_damage';
if (stage.operator === 'expected_headshot') return stageKey.includes('chance') ? 'headshot_chance' : 'headshot_damage';
return null;
}
function calculationBindings(catalog: Catalog, weapon: any): AttributeBinding[] {
return catalog.calculation.profiles
.filter((item) => item.weapon_category === '*' || item.weapon_category === weapon?.category)
.flatMap((profile) => {
const stage = calculationStage(catalog, String(profile.stage_key));
return stage
? [{
attributeId: String(profile.stat_attribute_id),
stage,
operator: 'add' as const,
weight: Number(profile.weight),
softCap: profile.soft_cap,
hardCap: profile.hard_cap,
curveType: profile.curve_type,
curveParameter: profile.curve_parameter
}]
: [];
});
}
export function evaluateLoadoutCombat(
draft: LoadoutDraft,
catalog: Catalog,
summary = summarizeLoadout(draft, catalog)
): WeaponCalculationResult[] {
const focus = selectedFocus(draft, catalog);
const combatEffects = selectedNodeRanks(draft, catalog).flatMap(({ node, rank }: any) =>
(rank?.combatEffects ?? []).map((effect: any) => ({ ...effect, nodeName: node.name }))
);
const required = (weaponIndex: number) => {
const missing: string[] = [];
if (!catalog.calculation.revision) missing.push('An active ISAC calculation revision');
else if (!catalog.calculation.profiles.length) missing.push('Published attribute calculation profiles');
if (!draft.weapons[weaponIndex].itemId) missing.push(`${weaponIndex ? 'Secondary' : 'Primary'} weapon`);
for (const slot of gearSlots) if (!draft.gear[slot].itemId) missing.push(`${slot} gear`);
if (!draft.protocol.itemId) missing.push('OS Protocol');
if (!draft.specialization.focusId) missing.push('Specialization focus');
else if ((focus?.abilities.length ?? 0) !== 3) missing.push('Three configured focus abilities');
const missingChips = draft.chips.filter((chip) => !chip.setId).length;
if (missingChips) missing.push(`${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`);
return missing;
};
return [0, 1].map((weaponIndex) => {
const selection = draft.weapons[weaponIndex];
const weapon = catalog.weapons.find((item) => item.id === selection.itemId);
const missing = required(weaponIndex);
if (missing.length) {
return {
weaponName: weapon?.name ?? `${weaponIndex ? 'Secondary' : 'Primary'} weapon`,
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
status: 'insufficient',
missing,
revisionLabel: catalog.calculation.revision
? `RULES R${catalog.calculation.revision.version}`
: undefined
};
}
const bindings = calculationBindings(catalog, weapon);
const effects = combatEffects.map((effect: any) => ({
id: String(effect.id),
trigger: {
hitsRequired: Number(effect.trigger_count),
windowSeconds: Number(effect.trigger_window_seconds)
},
lifecycle: {
maxStacks: Number(effect.max_stacks),
durationSeconds: Number(effect.duration_seconds),
stacksPerTrigger: Number(effect.stacks_per_trigger)
},
attributes: Object.fromEntries(effect.modifiers.map((modifier: any) => [
String(modifier.stat_attribute_id),
Number(modifier.value_per_stack)
]))
}));
const perEffect = Object.fromEntries(combatEffects.map((effect: any) => {
const state = draft.specialization.effectStates[effect.id] ?? { mode: 'off' };
return [effect.id, {
mode: state.mode,
...(state.overrides ?? {})
}];
}));
const result = calculateCombat({
weapon: {
baseDamage: selection.damage,
rpm: Number(weapon?.rpm),
magazine: Number(weapon?.magazine),
reloadSeconds: Number(weapon?.reload_speed),
headshotMultiplier: 2
},
attributes: Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])),
bindings,
stageOrder: catalog.calculation.stages
.map((stage) => calculationStage(catalog, String(stage.stage_key)))
.filter((stage): stage is CalculationStage => stage !== null),
stackingEffects: effects,
assumptions: {
mode: 'off',
...draft.specialization.combatProfile,
perEffect
}
});
if (result.status === 'INSUFFICIENT_DATA') {
return {
weaponName: weapon?.name ?? `${weaponIndex ? 'Secondary' : 'Primary'} weapon`,
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
status: 'insufficient',
missing: result.missing.map((item) =>
item.startsWith('binding:')
? `Calculation profile for ${attribute(catalog, item.slice(8))?.name ?? item.slice(8)}`
: item.replaceAll('.', ' ')
),
revisionLabel: `RULES R${catalog.calculation.revision.version}`
};
}
return {
weaponName: weapon.name,
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
status: 'complete',
bodyHit: result.hitValues?.normal,
criticalBodyHit: result.hitValues?.critical,
headshot: result.hitValues?.headshot,
criticalHeadshot: result.hitValues?.criticalHeadshot,
burstDps: result.burstDps ?? undefined,
sustainedDps: result.sustainedDps ?? undefined,
encounterDps: result.encounterDps ?? undefined,
effectSummaries: result.stackSummaries.map((stack) => {
const effect = combatEffects.find((item: any) => String(item.id) === stack.effectId);
return {
id: stack.effectId,
name: String(effect?.definition?.name ?? effect?.nodeName ?? 'Conditional effect'),
mode: stack.mode,
averageStacks: stack.averageStacks,
maxStacksReached: stack.maxStacksReached,
maxStacks: Number(effect?.max_stacks ?? 0),
stacksAtEnd: stack.stacksAtEnd,
buildUpSeconds: stack.buildUpSeconds,
maintained: stack.maintained
};
}),
revisionLabel: `RULES R${catalog.calculation.revision.version}`
};
});
}