feat: add source-owned combat mechanic programs

This commit is contained in:
JB
2026-07-27 13:35:00 -04:00
parent 5a67b252aa
commit 4eb754e7c8
62 changed files with 14815 additions and 905 deletions
+790 -60
View File
@@ -1,8 +1,26 @@
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';
@@ -16,7 +34,7 @@ type Catalog = {
chipSets: any[];
protocols: any[];
specializations: any[];
calculation: { revision: any | null; stages: any[]; profiles: any[] };
calculation: { revision: any | null; stages: any[]; profiles: any[]; skillProfiles?: any[] };
};
export type LoadoutSummary = {
@@ -47,6 +65,16 @@ 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+$/, '');
@@ -118,6 +146,7 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout
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))
@@ -152,6 +181,7 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout
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)
@@ -188,7 +218,9 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout
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];
@@ -223,6 +255,7 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout
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) => {
@@ -239,19 +272,6 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout
}
}
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 }))
@@ -265,7 +285,18 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout
}
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)) {
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);
@@ -275,6 +306,9 @@ function calculationStage(catalog: Catalog, stageKey: string): CalculationStage
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;
}
@@ -298,6 +332,516 @@ function calculationBindings(catalog: Catalog, weapon: any): AttributeBinding[]
});
}
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,
@@ -307,6 +851,24 @@ export function evaluateLoadoutCombat(
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');
@@ -337,29 +899,79 @@ export function evaluateLoadoutCombat(
};
}
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 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,
@@ -368,16 +980,15 @@ export function evaluateLoadoutCombat(
reloadSeconds: Number(weapon?.reload_speed),
headshotMultiplier: 2
},
attributes: Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])),
attributes: modeledAttributes,
bindings,
stageOrder: catalog.calculation.stages
.map((stage) => calculationStage(catalog, String(stage.stage_key)))
.filter((stage): stage is CalculationStage => stage !== null),
stackingEffects: effects,
stageOrder: calculationStages,
assumptions: {
mode: 'off',
...draft.specialization.combatProfile,
perEffect
hitRate: draft.combatProfile.hitRate,
headshotRate: draft.combatProfile.headshotRate,
encounterSeconds: draft.combatProfile.encounterSeconds,
firingActivity: 100,
}
});
if (result.status === 'INSUFFICIENT_DATA') {
@@ -393,6 +1004,40 @@ export function evaluateLoadoutCombat(
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',
@@ -403,22 +1048,107 @@ export function evaluateLoadoutCombat(
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
};
}),
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)))
}
});
}