feat: add source-owned combat mechanic programs
This commit is contained in:
@@ -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)))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user