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

1155 lines
43 KiB
TypeScript

import {
calculateCombat,
expectedWeaponShotDamage,
resolveWeaponFiringCycle,
type AttributeBinding,
type CalculationStage
} from '$lib/calculations/combat';
import {
evaluateMechanicProgram,
type MechanicCombatContext,
type MechanicEvaluation,
type MechanicEvent,
type MechanicOutcome,
type MechanicProgram,
type NumericExpression
} from '$lib/calculations/mechanic';
import { adaptConditionalEffectProgram } from '$lib/calculations/mechanic-persistence';
import {
calculateSkillDps,
type SkillAttributeBinding,
type SkillCalculation,
type SkillModifierWindow
} from '$lib/calculations/skill';
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[]; skillProfiles?: 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 applyAttributeValue(catalog: Catalog, current: number, statId: string, incoming: number) {
const stat = attribute(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: 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,
rule: catalog.talents.find((entry) => entry.id === selection.talentId)?.rule ?? null,
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,
rule: catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
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,
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) =>
(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',
'rate_of_fire',
'magazine_size',
'reload_speed',
'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';
if (stage.operator === 'rate_of_fire') return 'rate_of_fire';
if (stage.operator === 'magazine_size') return 'magazine_size';
if (stage.operator === 'reload_speed') return 'reload_speed';
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
}]
: [];
});
}
function transformedBindingValue(raw: number, binding: AttributeBinding) {
const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1;
const sign = raw < 0 ? -1 : 1;
let value = Math.abs(raw * weight);
const softCap = Number.isFinite(Number(binding.softCap)) && Number(binding.softCap) >= 0
? Number(binding.softCap)
: null;
const hardCap = Number.isFinite(Number(binding.hardCap)) && Number(binding.hardCap) >= 0
? Number(binding.hardCap)
: null;
if (softCap !== null && value > softCap && binding.curveType && binding.curveType !== 'none') {
const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1));
const overflow = value - softCap;
if (binding.curveType === 'power') {
value = softCap + overflow * Math.pow(softCap / Math.max(softCap + overflow, 0.000_001), parameter);
} else {
const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1);
const ratio = Math.min(1, Math.max(0, overflow / Math.max(ceiling - softCap, 0.000_001)));
value = softCap + (ceiling - softCap) * (ratio * ratio * (3 - 2 * ratio));
}
}
if (hardCap !== null) value = Math.min(value, hardCap);
return value * sign;
}
export function weaponCriticalChance(
attributes: Record<string, number>,
bindings: AttributeBinding[]
) {
return Math.min(100, Math.max(0, bindings
.filter((binding) => binding.stage === 'critical_chance' && binding.operator === 'add')
.reduce((total, binding) =>
total + transformedBindingValue(Number(attributes[binding.attributeId] ?? 0), binding), 0)));
}
function conditionalProgram(
rule: any,
variantKey: string,
valueScale = 1
): MechanicProgram | null {
if (!rule?.rule) return null;
const adapted = adaptConditionalEffectProgram(rule, { variantKey });
if (!adapted.program || adapted.status === 'INVALID' || adapted.status === 'CAPTURED_ONLY') return null;
if (valueScale === 1) return adapted.program;
const scale = (value: NumericExpression): NumericExpression =>
typeof value === 'number' ? value * valueScale : value;
return {
...adapted.program,
clauses: adapted.program.clauses.map((clause) => ({
...clause,
outcomes: clause.outcomes?.map((outcome) => ({ ...outcome, value: scale(outcome.value) }))
}))
};
}
function interpolatedConditionalProgram(
rule: any,
minimumVariant: string,
maximumVariant: string,
step: number,
totalSteps: number
): MechanicProgram | null {
const minimum = conditionalProgram(rule, minimumVariant);
const maximum = conditionalProgram(rule, maximumVariant);
if (!minimum || !maximum) return minimum ?? maximum;
const ratio = Math.max(0, Math.min(1, Number(step) / Math.max(1, Number(totalSteps))));
const maximumByKey = new Map(maximum.clauses.flatMap((clause) =>
(clause.outcomes ?? []).map((outcome) => [`${clause.id}:${outcome.id}`, outcome] as const)
));
return {
...minimum,
clauses: minimum.clauses.map((clause) => ({
...clause,
outcomes: clause.outcomes?.map((outcome) => {
const upper = maximumByKey.get(`${clause.id}:${outcome.id}`);
return upper && typeof outcome.value === 'number' && typeof upper.value === 'number'
? { ...outcome, value: outcome.value + (upper.value - outcome.value) * ratio }
: outcome;
})
}))
};
}
function equippedConditionalProgram(equipped: any): MechanicProgram | null {
if (String(equipped.key).startsWith('chips:')) {
return interpolatedConditionalProgram(equipped.rule, 'minimum', 'maximum', Number(equipped.tier), 3);
}
if (String(equipped.key).startsWith('protocol:')) {
return conditionalProgram(equipped.rule, 'base');
}
return conditionalProgram(
equipped.rule,
String(equipped.key).startsWith('weapon:') || String(equipped.key).startsWith('gear:')
? (Number(equipped.tier) >= 2 ? 'II' : 'I')
: 'base'
);
}
type ActiveMechanicEvaluation = {
status: 'OK';
result: MechanicEvaluation;
program: MechanicProgram;
encounterSeconds: number;
};
function mechanicScenarioInputs(program: MechanicProgram, configured: unknown) {
const values = configured && typeof configured === 'object' && !Array.isArray(configured)
? configured as Record<string, number | boolean>
: {};
return Object.fromEntries((program.scenarioInputs ?? []).map((input) => [
input.key,
values[input.key] ?? input.defaultValue ?? (input.kind === 'toggle' ? false : 0)
]));
}
function evaluateConfiguredMechanic(
program: MechanicProgram,
events: MechanicEvent[],
state: any,
profile: LoadoutDraft['combatProfile'],
weaponBaseDamage: number,
maxHealth: number,
calculationMode: LoadoutDraft['calculationMode'],
equipmentContext = { protocolTalentUpgrades: 0 }
): ActiveMechanicEvaluation | null {
if (calculationMode !== 'maximum' && state.mode === 'off') return null;
const encounterSeconds = Math.max(1, Number(profile.encounterSeconds));
const maximum = calculationMode === 'maximum' || state.mode === 'peak';
const result = evaluateMechanicProgram(
program,
events,
maximum
? { mode: 'maximum', encounterSeconds }
: { mode: 'custom', encounterSeconds, inputs: mechanicScenarioInputs(program, state.inputs) },
{
maxHealth: Math.max(1, maxHealth),
currentHealth: Math.max(0, maxHealth * Number(profile.healthPercent) / 100),
weaponBaseDamage,
values: { extraHealth: profile.extraHealthActive ? 1 : 0 }
},
equipmentContext
);
return result.status === 'OK' ? { status: 'OK', result, program, encounterSeconds } : null;
}
type ModifierInterval = {
start: number;
end: number;
value: number;
stateValue: number;
attributeId: string;
scope: string;
outcomeId: string;
phase: MechanicOutcome['phase'];
activationEvent: MechanicEvent['kind'];
endAtNextActivation: boolean;
};
function modifierIntervals(source: ActiveMechanicEvaluation): ModifierInterval[] {
const emissions = source.result.outcomes
.filter((emission) => emission.kind === 'attribute_modifier' && emission.attributeId)
.sort((left, right) => left.at - right.at);
return emissions.flatMap((emission, index) => {
const clause = source.program.clauses.find((candidate) =>
candidate.outcomes?.some((outcome) => outcome.id === emission.outcomeId));
const definition = clause?.outcomes?.find((candidate) => candidate.id === emission.outcomeId);
if (!clause || !definition) return [];
const next = emissions.slice(index + 1).find((candidate) => candidate.outcomeId === emission.outcomeId);
const pool = definition.statePool
? source.program.pools?.find((candidate) => candidate.key === definition.statePool)
: null;
const naturalEnd = definition.phase === 'while_active'
? emission.at + Number(pool?.expiresAfterSeconds ?? source.encounterSeconds)
: emission.at;
const end = Math.min(source.encounterSeconds, next?.at ?? naturalEnd, naturalEnd);
if (
end <= emission.at
&& definition.phase !== 'while_active'
&& definition.phase !== 'on_event'
) return [];
return [{
start: emission.at,
end: Math.max(emission.at, end),
value: emission.appliedValue,
stateValue: emission.stateValue,
attributeId: String(emission.attributeId),
scope: emission.scope,
outcomeId: emission.outcomeId,
phase: definition.phase,
activationEvent: clause.when,
endAtNextActivation: next !== undefined && next.at <= naturalEnd
}];
});
}
function averageModifierValues(source: ActiveMechanicEvaluation) {
const values = new Map<string, { value: number; scope: string }>();
for (const interval of modifierIntervals(source)) {
const key = `${interval.attributeId}:${interval.scope}`;
const current = values.get(key) ?? { value: 0, scope: interval.scope };
current.value += interval.value * (interval.end - interval.start) / source.encounterSeconds;
values.set(key, current);
}
return [...values].map(([key, value]) => ({
attributeId: key.slice(0, key.lastIndexOf(':')),
scope: value.scope,
value: value.value
}));
}
const TIMELINE_EVENT_ORDER: Readonly<Record<MechanicEvent['kind'], number>> = {
firing_started: 0,
shot_fired: 1,
weapon_hit: 2,
damage_dealt: 3,
firing_stopped: 4,
reload_started: 5,
reload_completed: 6,
weapon_swapped: 7,
enemy_killed: 8,
assist: 9,
skill_deployed: 10,
skill_hit: 11,
agent_hit: 12,
health_damage_taken: 13,
healing_applied: 14,
extra_health_granted: 15,
tick: 16,
proximity_snapshot: 17,
movement_snapshot: 18
};
function intervalAppliesAt(
interval: ModifierInterval,
at: number,
eventKind: MechanicEvent['kind']
) {
const epsilon = 0.000_001;
const queryOrder = TIMELINE_EVENT_ORDER[eventKind];
const activationOrder = TIMELINE_EVENT_ORDER[interval.activationEvent];
if (interval.phase === 'on_event') {
return Math.abs(at - interval.start) <= epsilon && activationOrder <= queryOrder;
}
if (interval.phase !== 'while_active') return false;
if (at < interval.start - epsilon || at > interval.end + epsilon) return false;
if (Math.abs(at - interval.start) <= epsilon && activationOrder >= queryOrder) return false;
if (
Math.abs(at - interval.end) <= epsilon
&& !interval.endAtNextActivation
) {
return false;
}
return true;
}
export function mechanicModifiersAt(
source: ActiveMechanicEvaluation,
at: number,
eventKind: MechanicEvent['kind'] = 'weapon_hit'
) {
const values = new Map<string, { value: number; scope: string }>();
for (const interval of modifierIntervals(source)) {
if (!intervalAppliesAt(interval, at, eventKind)) continue;
const key = `${interval.attributeId}:${interval.scope}`;
const current = values.get(key) ?? { value: 0, scope: interval.scope };
current.value += interval.value;
values.set(key, current);
}
return [...values].map(([key, value]) => ({
attributeId: key.slice(0, key.lastIndexOf(':')),
scope: value.scope,
value: value.value
}));
}
function mechanicSummary(source: ActiveMechanicEvaluation) {
const intervals = modifierIntervals(source);
const maxStacks = Math.max(1, ...(source.program.pools ?? []).map((pool) => Number(pool.max ?? 1)));
const maxStacksReached = Math.max(0, ...intervals.map((interval) => interval.stateValue));
const covered = intervals.reduce((total, interval) => total + Math.max(0, interval.end - interval.start), 0);
const uptimePercent = Math.min(100, covered / source.encounterSeconds * 100);
const buildUp = source.result.outcomes.find((emission) => emission.stateValue >= maxStacks)?.at ?? null;
return {
averageStacks: intervals.reduce(
(total, interval) => total + interval.stateValue * (interval.end - interval.start) / source.encounterSeconds,
0
),
maxStacksReached,
maxStacks,
stacksAtEnd: Math.max(0, ...source.result.finalState.map((state) => state.value)),
buildUpSeconds: buildUp,
maintained: uptimePercent >= 99.9 && maxStacksReached >= maxStacks,
uptimePercent,
activationCount: source.result.estimatedActivationCount,
instantTotal: source.result.outcomes
.filter((outcome) => outcome.kind !== 'direct_damage' && outcome.kind !== 'attribute_modifier')
.reduce((total, outcome) => total + outcome.appliedValue, 0)
};
}
export function buildWeaponMechanicTimeline(
weapon: any,
profile: LoadoutDraft['combatProfile'],
criticalChancePercent = 0
): MechanicEvent[] {
const encounterSeconds = Math.max(0, Number(profile.encounterSeconds));
const rpm = Math.max(1, Number(weapon?.rpm));
const magazine = Math.max(1, Math.round(Number(weapon?.magazine)));
const reloadSeconds = Math.max(0, Number(weapon?.reload_speed));
const shotsPerBurst = Math.max(1, Math.round(Number(profile.shotsPerBurst)));
const pauseSeconds = Math.max(0, Number(profile.burstPauseSeconds));
const targetInterval = Number(profile.distinctTargetsPerMinute) > 0
? Math.min(
Math.max(0.1, Number(profile.sameTargetSeconds)),
60 / Number(profile.distinctTargetsPerMinute)
)
: Math.max(0.1, Number(profile.sameTargetSeconds));
const events: MechanicEvent[] = [{ kind: 'firing_started', at: 0 }];
let time = 0;
let magazineRemaining = magazine;
let burstRemaining = shotsPerBurst;
let hitAccumulator = 0;
let headshotAccumulator = 0;
let criticalAccumulator = 0;
const criticalChance = Math.max(0, Math.min(100, Number(criticalChancePercent))) / 100;
const shotInterval = 60 / rpm;
while (time <= encounterSeconds && events.length < 100_000) {
if (magazineRemaining <= 0) {
events.push({ kind: 'reload_started', at: time });
time += reloadSeconds;
if (time <= encounterSeconds) {
events.push({ kind: 'reload_completed', at: time });
events.push({ kind: 'firing_started', at: time });
}
magazineRemaining = magazine;
burstRemaining = shotsPerBurst;
continue;
}
if (burstRemaining <= 0) {
if (pauseSeconds > 0) {
events.push({ kind: 'firing_stopped', at: time });
time += pauseSeconds;
if (time <= encounterSeconds) events.push({ kind: 'firing_started', at: time });
}
burstRemaining = shotsPerBurst;
continue;
}
hitAccumulator += Math.max(0, Math.min(100, Number(profile.hitRate))) / 100;
const hit = hitAccumulator >= 1;
if (hit) hitAccumulator -= 1;
if (hit) headshotAccumulator += Math.max(0, Math.min(100, Number(profile.headshotRate))) / 100;
const headshot = hit && headshotAccumulator >= 1;
if (headshot) headshotAccumulator -= 1;
if (hit) criticalAccumulator += criticalChance;
const critical = hit && criticalAccumulator >= 1;
if (critical) criticalAccumulator -= 1;
const targetId = `target-${Math.floor(time / targetInterval)}`;
events.push({
kind: 'shot_fired',
at: time,
targetId,
values: {
magazineRemaining,
magazinePercent: magazineRemaining / magazine * 100,
distanceMeters: Number(profile.targetDistanceMeters)
}
});
if (hit) {
const values = {
headshot,
critical,
distanceMeters: Number(profile.targetDistanceMeters)
};
events.push({ kind: 'weapon_hit', at: time, targetId, values });
events.push({ kind: 'damage_dealt', at: time, targetId, values: { ...values, source: 'weapon' } });
}
magazineRemaining -= 1;
burstRemaining -= 1;
time += shotInterval;
}
events.push({ kind: 'firing_stopped', at: encounterSeconds });
const intervalEvents = (
kind: 'enemy_killed' | 'health_damage_taken' | 'weapon_swapped',
perMinute: number
) => {
if (!(perMinute > 0)) return;
const interval = 60 / perMinute;
let headshotKillAccumulator = 0;
for (let at = interval; at <= encounterSeconds; at += interval) {
if (kind === 'enemy_killed') {
headshotKillAccumulator += Math.max(0, Math.min(100, Number(profile.headshotKillRate))) / 100;
const headshot = headshotKillAccumulator >= 1;
if (headshot) headshotKillAccumulator -= 1;
events.push({
kind,
at,
targetId: `target-${Math.floor(at / targetInterval)}`,
values: { headshot, source: 'weapon' }
});
}
else events.push({ kind, at });
}
};
intervalEvents('enemy_killed', Number(profile.killsPerMinute));
intervalEvents('health_damage_taken', Number(profile.damageTakenPerMinute));
intervalEvents('weapon_swapped', Number(profile.swapsPerMinute));
for (let at = 0; at <= encounterSeconds; at += 1) {
events.push({ kind: 'tick', at });
events.push({
kind: 'proximity_snapshot',
at,
values: {
distanceMeters: Number(profile.targetDistanceMeters),
targetDistanceMeters: Number(profile.targetDistanceMeters),
enemyCount: 1,
allyCount: 0
}
});
events.push({ kind: 'movement_snapshot', at, values: { moving: false, sprinting: false } });
}
return events.sort((left, right) => left.at - right.at);
}
function effectAppliesToWeapon(scope: string, sourceKey: string, weaponIndex: number) {
if (scope === 'allies' || scope === 'assigned_ability' || scope === 'all_skills') return false;
if (scope !== 'equipped_weapon') return true;
const match = sourceKey.match(/^weapon:(\d+):/);
return !match || Number(match[1]) === weaponIndex;
}
function effectAppliesToSkills(scope: string) {
return !['allies', 'equipped_weapon'].includes(scope);
}
function skillModifierWindows(
draft: LoadoutDraft,
catalog: Catalog,
summary: LoadoutSummary,
allowedAttributeIds: Set<string>
): SkillModifierWindow[] {
const focus = selectedFocus(draft, catalog);
const combatEffects = selectedNodeRanks(draft, catalog).flatMap(({ node, rank }: any) =>
(rank?.combatEffects ?? []).map((effect: any) => ({
...effect,
nodeName: node.name,
abilityId: null
}))
);
const sources = [
...summary.equippedTalents.map((equipped: any) => ({
key: String(equipped.key),
abilityId: equipped.abilityId ? String(equipped.abilityId) : null,
equipmentContext: {
protocolTalentUpgrades: String(equipped.key).startsWith('protocol:')
? Number(equipped.tier)
: 0
},
program: equippedConditionalProgram(equipped)
})),
...combatEffects.map((effect: any) => ({
key: String(effect.id),
abilityId: effect.abilityId,
equipmentContext: { protocolTalentUpgrades: 0 },
program: conditionalProgram(effect.rule, 'base')
}))
].filter((source) => source.program);
const primarySelection = draft.weapons[0];
const primaryWeapon = catalog.weapons.find((weapon) => weapon.id === primarySelection.itemId);
const primaryBindings = calculationBindings(catalog, primaryWeapon);
const baseAttributes = Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value]));
const primaryCriticalChance = weaponCriticalChance(baseAttributes, primaryBindings);
const focusAbilityIds = new Set((focus?.abilities ?? []).map((ability: any) => String(ability.id)));
const windows: SkillModifierWindow[] = [];
const maxHealthId = catalog.attributes.find((item) => item.name === 'Health')?.id;
const maxHealth = summary.stats.find((stat) => stat.id === maxHealthId)?.value ?? 1;
const evaluatedSources = sources.flatMap((source) => {
const state = draft.conditionalEffects[source.key] ?? { mode: 'off' };
const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) };
const evaluation = evaluateConfiguredMechanic(
source.program!,
primaryWeapon ? buildWeaponMechanicTimeline(primaryWeapon, profile, primaryCriticalChance) : [],
state,
profile,
Number(primarySelection.damage ?? 0),
maxHealth,
draft.calculationMode,
source.equipmentContext
);
return evaluation ? [{ ...source, evaluation }] : [];
});
for (const source of evaluatedSources) {
for (const interval of modifierIntervals(source.evaluation)) {
if (!effectAppliesToSkills(interval.scope) || !allowedAttributeIds.has(interval.attributeId)) continue;
const abilityIds = interval.scope === 'assigned_ability'
? (source.abilityId && focusAbilityIds.has(source.abilityId) ? [source.abilityId] : [])
: undefined;
if (interval.scope === 'assigned_ability' && !abilityIds?.length) continue;
windows.push({
startSeconds: interval.start,
endSeconds: interval.end,
attributes: { [interval.attributeId]: interval.value },
abilityIds
});
}
}
return windows;
}
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 conditionalSources = [
...summary.equippedTalents.map((equipped: any) => ({
key: equipped.key,
name: equipped.custom?.name ?? catalog.talents.find((talent) => talent.id === equipped.id)?.name ?? 'Talent',
equipmentContext: {
protocolTalentUpgrades: String(equipped.key).startsWith('protocol:')
? Number(equipped.tier)
: 0
},
program: equippedConditionalProgram(equipped)
})),
...combatEffects.map((effect: any) => ({
key: String(effect.id),
name: String(effect.definition?.name ?? effect.nodeName ?? 'Specialization effect'),
equipmentContext: { protocolTalentUpgrades: 0 },
program: conditionalProgram(effect.rule, 'base')
}))
].filter((source) => source.program);
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 baseAttributes = Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value]));
const maxHealthId = catalog.attributes.find((item) => item.name === 'Health')?.id;
const maxHealth = Number(baseAttributes[maxHealthId] ?? 1);
const evaluateSources = (
timelineWeapon: any,
criticalAttributes: Record<string, number>
) => conditionalSources.flatMap((source) => {
const state = draft.conditionalEffects[source.key] ?? { mode: 'off' };
const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) };
const evaluation = evaluateConfiguredMechanic(
source.program!,
buildWeaponMechanicTimeline(
timelineWeapon,
profile,
weaponCriticalChance(criticalAttributes, bindings)
),
state,
profile,
Number(selection.damage),
maxHealth,
draft.calculationMode,
source.equipmentContext
);
return evaluation ? [{ ...source, state, evaluation }] : [];
});
const attributesFrom = (evaluations: ReturnType<typeof evaluateSources>) => {
const values = { ...baseAttributes };
for (const source of evaluations) {
for (const modifier of averageModifierValues(source.evaluation)) {
if (!effectAppliesToWeapon(modifier.scope, source.key, weaponIndex)) continue;
values[modifier.attributeId] = applyAttributeValue(
catalog,
values[modifier.attributeId] ?? 0,
modifier.attributeId,
modifier.value
);
}
}
return values;
};
let timelineWeapon = { ...weapon };
let evaluations = evaluateSources(timelineWeapon, baseAttributes);
for (let pass = 0; pass < 3; pass += 1) {
const currentAttributes = attributesFrom(evaluations);
const cycle = resolveWeaponFiringCycle(
{
rpm: Number(weapon?.rpm),
magazine: Number(weapon?.magazine),
reloadSeconds: Number(weapon?.reload_speed)
},
currentAttributes,
bindings
);
timelineWeapon = {
...weapon,
rpm: cycle.rpm,
magazine: cycle.magazine,
reload_speed: cycle.reloadSeconds
};
evaluations = evaluateSources(timelineWeapon, currentAttributes);
}
const modeledAttributes = attributesFrom(evaluations);
let directDamageTotal = 0;
for (const source of evaluations) {
for (const outcome of source.evaluation.result.outcomes) {
if (outcome.kind !== 'direct_damage'
|| !effectAppliesToWeapon(outcome.scope, source.key, weaponIndex)) continue;
directDamageTotal += outcome.appliedValue;
}
}
const calculationStages = catalog.calculation.stages
.map((stage) => calculationStage(catalog, String(stage.stage_key)))
.filter((stage): stage is CalculationStage => stage !== null);
const result = calculateCombat({
weapon: {
baseDamage: selection.damage,
rpm: Number(weapon?.rpm),
magazine: Number(weapon?.magazine),
reloadSeconds: Number(weapon?.reload_speed),
headshotMultiplier: 2
},
attributes: modeledAttributes,
bindings,
stageOrder: calculationStages,
assumptions: {
mode: 'off',
hitRate: draft.combatProfile.hitRate,
headshotRate: draft.combatProfile.headshotRate,
encounterSeconds: draft.combatProfile.encounterSeconds,
firingActivity: 100,
}
});
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}`
};
}
const timeline = buildWeaponMechanicTimeline(
timelineWeapon,
draft.combatProfile,
weaponCriticalChance(modeledAttributes, bindings)
);
let encounterWeaponDamage = 0;
for (const event of timeline) {
if (event.kind !== 'weapon_hit') continue;
const attributesAtShot = { ...baseAttributes };
for (const source of evaluations) {
for (const modifier of mechanicModifiersAt(source.evaluation, event.at, event.kind)) {
if (!effectAppliesToWeapon(modifier.scope, source.key, weaponIndex)) continue;
attributesAtShot[modifier.attributeId] = applyAttributeValue(
catalog,
attributesAtShot[modifier.attributeId] ?? 0,
modifier.attributeId,
modifier.value
);
}
}
encounterWeaponDamage += expectedWeaponShotDamage(
{
baseDamage: selection.damage,
rpm: Number(weapon?.rpm),
magazine: Number(weapon?.magazine),
reloadSeconds: Number(weapon?.reload_speed),
headshotMultiplier: 2
},
attributesAtShot,
bindings,
event.values?.headshot === true,
calculationStages
);
}
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: (encounterWeaponDamage + directDamageTotal)
/ Math.max(1, draft.combatProfile.encounterSeconds),
effectSummaries: evaluations.map((source) => ({
id: source.key,
name: source.name,
mode: source.state.mode,
...mechanicSummary(source.evaluation)
})),
revisionLabel: `RULES R${catalog.calculation.revision.version}`
};
});
}
export function evaluateLoadoutSkillCombat(
draft: LoadoutDraft,
catalog: Catalog,
summary = summarizeLoadout(draft, catalog)
): SkillCalculation {
const focus = selectedFocus(draft, catalog);
const revisionId = String(catalog.calculation.revision?.id ?? '');
const supportedStages = new Set([
'flat_damage',
'skill_damage',
'final_damage',
'critical_chance',
'critical_damage',
'multishot_chance',
'multishot_damage',
'cooldown_recovery',
'duration'
]);
const bindings: SkillAttributeBinding[] = (catalog.calculation.skillProfiles ?? [])
.flatMap((profile: any) => {
const stage = profile.stage_key === 'engineering' ? 'skill_damage' : String(profile.stage_key);
return supportedStages.has(stage)
? [{
attributeId: String(profile.stat_attribute_id),
stage: stage as SkillAttributeBinding['stage'],
operator: profile.operator === 'multiply' ? 'multiply' as const : 'add' as const,
weight: Number(profile.weight ?? 1)
}]
: [];
});
const boundIds = new Set(bindings.map((binding) => binding.attributeId));
const attributes = Object.fromEntries(
summary.stats.filter((stat) => boundIds.has(stat.id)).map((stat) => [stat.id, stat.value])
);
const abilities = (focus?.abilities ?? []).map((ability: any) => {
const attack = ability.attack;
const selected = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 };
const activation = selected.mode === 'custom_per_minute'
? { mode: 'custom_per_minute' as const, usesPerMinute: Number(selected.usesPerMinute ?? 1) }
: selected.mode === 'on_cooldown'
? { mode: 'on_cooldown' as const }
: { mode: 'off' as const };
const chargePolicy = attack?.charge_policy === 'independent'
? { type: 'independent' as const, charges: Number(attack.charge_count) }
: attack?.charge_policy === 'full_set'
? { type: 'full_set' as const, charges: Number(attack.charge_count) }
: { type: 'none' as const };
return {
id: String(ability.id),
baseDamage: attack?.base_damage === undefined ? undefined : Number(attack.base_damage),
damageType: attack?.damage_type,
delivery: attack?.delivery === 'periodic'
? {
type: 'periodic' as const,
ticks: Math.max(1, Math.floor(Number(attack.damage_duration_seconds) / Number(attack.tick_interval_seconds))),
durationSeconds: Number(attack.damage_duration_seconds),
hitsPerTick: Number(attack.hits_per_activation ?? 1),
intervalSeconds: Number(attack.tick_interval_seconds),
firstTickDelaySeconds: Number(attack.tick_interval_seconds)
}
: { type: 'direct' as const, hits: Number(attack?.hits_per_activation ?? 1) },
castSeconds: Number(attack?.cast_seconds ?? 0),
cooldownSeconds: Number(ability.cooldown_seconds ?? 0),
targetCap: Number(attack?.target_cap ?? 1),
targetCount: Number(selected.targets ?? 1),
chargePolicy,
activation
};
});
return calculateSkillDps({
rules: { revisionId, bindings },
abilities,
attributes,
attributeRules: Object.fromEntries([...boundIds].map((id) => {
const stat = attribute(catalog, id);
return [id, {
combination: stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
? 'multiplicative_percent' as const
: 'additive' as const,
cap: stat?.cap_value === null || stat?.cap_value === undefined
? null
: Number(stat.cap_value)
}];
})),
modifierWindows: skillModifierWindows(draft, catalog, summary, boundIds),
assumptions: {
encounterSeconds: draft.combatProfile.encounterSeconds,
targetCount: Math.max(1, ...Object.values(draft.skillActivations).map((selection) => Number(selection.targets ?? 1)))
}
});
}