1155 lines
43 KiB
TypeScript
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)))
|
|
}
|
|
});
|
|
}
|