export type SkillStatValues = Record; export type SkillCalculationStage = | 'flat_damage' | 'skill_damage' | 'final_damage' | 'critical_chance' | 'critical_damage' | 'multishot_chance' | 'multishot_damage' | 'cooldown_recovery' | 'duration'; export type SkillAttributeBinding = { attributeId: string; stage: SkillCalculationStage; operator: 'add' | 'multiply'; weight?: number; }; export type SkillCalculationRules = { revisionId: string; bindings: SkillAttributeBinding[]; stageOrder?: SkillCalculationStage[]; }; export type SkillDelivery = | { type: 'direct'; hits: number } | { type: 'periodic'; ticks: number; durationSeconds?: number; hitsPerTick?: number; intervalSeconds: number; firstTickDelaySeconds?: number; }; export type SkillChargePolicy = | { type: 'none' } | { type: 'independent'; charges: number } | { type: 'full_set'; charges: number }; export type SkillActivation = | { mode: 'off' } | { mode: 'on_cooldown' } | { mode: 'custom_per_minute'; usesPerMinute: number }; export type SkillAbilityInput = { id: string; baseDamage?: number; damageType?: string; delivery: SkillDelivery; castSeconds?: number; cooldownSeconds?: number; targetCap?: number; targetCount?: number; chargePolicy?: SkillChargePolicy; activation: SkillActivation; }; export type SkillModifierWindow = { startSeconds: number; endSeconds: number; attributes: SkillStatValues; abilityIds?: string[]; }; export type SkillCalculationInput = { rules?: SkillCalculationRules; abilities: SkillAbilityInput[]; attributes?: SkillStatValues; attributeRules?: Record; modifierWindows?: SkillModifierWindow[]; assumptions: { encounterSeconds?: number; targetCount?: number; }; }; export type SkillDamageSummary = { unmodifiedBaseDamage: number; averageExpectedDamagePerHit: number; singleTargetDamagePerActivation: number; aggregateDamagePerActivation: number; singleTargetEncounterDamage: number; aggregateEncounterDamage: number; singleTargetDps: number; aggregateDps: number; }; export type SkillAbilityCalculation = { abilityId: string; damageType: string; enabled: boolean; activationCount: number; damageEventCount: number; effectiveTargets: number; damage: SkillDamageSummary; }; export type SkillCombinedCalculation = { singleTargetEncounterDamage: number; aggregateEncounterDamage: number; singleTargetDps: number; aggregateDps: number; }; export type SkillCalculation = | { status: 'INSUFFICIENT_DATA'; revisionId: string | null; missing: string[]; abilities: []; combined: null; } | { status: 'OK'; revisionId: string; missing: []; abilities: SkillAbilityCalculation[]; combined: SkillCombinedCalculation; }; type DamageEvent = { activationIndex: number; time: number; }; const EPSILON = 0.000_001; const MAX_EVENTS = 100_000; const isFiniteNumber = (value: unknown) => Number.isFinite(Number(value)); const isNonNegative = (value: unknown) => isFiniteNumber(value) && Number(value) >= 0; const isPositive = (value: unknown) => isFiniteNumber(value) && Number(value) > 0; const isPositiveInteger = (value: unknown) => Number.isInteger(Number(value)) && Number(value) > 0; const clamp = (value: number, minimum: number, maximum: number) => Math.min(maximum, Math.max(minimum, value)); function requiredData(input: SkillCalculationInput) { const missing: string[] = []; const enabled = input.abilities.filter((ability) => ability.activation.mode !== 'off'); if (enabled.length) { if (!input.rules?.revisionId?.trim()) missing.push('rules.revisionId'); if (!Array.isArray(input.rules?.bindings)) missing.push('rules.bindings'); if (!isPositive(input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds'); if (!isPositiveInteger(input.assumptions.targetCount)) missing.push('assumptions.targetCount'); } const seenIds = new Set(); for (const ability of enabled) { const prefix = `ability:${ability.id || 'unknown'}`; if (!ability.id.trim()) missing.push(`${prefix}:id`); if (seenIds.has(ability.id)) missing.push(`${prefix}:duplicate`); seenIds.add(ability.id); if (!isPositive(ability.baseDamage)) missing.push(`${prefix}:baseDamage`); if (!ability.damageType?.trim()) missing.push(`${prefix}:damageType`); if (!isNonNegative(ability.castSeconds)) missing.push(`${prefix}:castSeconds`); if (!isNonNegative(ability.cooldownSeconds)) missing.push(`${prefix}:cooldownSeconds`); if (!isPositiveInteger(ability.targetCap)) missing.push(`${prefix}:targetCap`); if (ability.targetCount !== undefined && !isPositiveInteger(ability.targetCount)) { missing.push(`${prefix}:targetCount`); } if (!ability.chargePolicy) missing.push(`${prefix}:chargePolicy`); if (ability.chargePolicy?.type !== 'none' && !isPositiveInteger(ability.chargePolicy?.charges)) { missing.push(`${prefix}:chargePolicy.charges`); } if (ability.delivery.type === 'direct') { if (!isPositiveInteger(ability.delivery.hits)) missing.push(`${prefix}:delivery.hits`); } else { if (!isPositiveInteger(ability.delivery.ticks)) missing.push(`${prefix}:delivery.ticks`); if ( ability.delivery.durationSeconds !== undefined && !isPositive(ability.delivery.durationSeconds) ) { missing.push(`${prefix}:delivery.durationSeconds`); } if (!isPositiveInteger(ability.delivery.hitsPerTick ?? 1)) { missing.push(`${prefix}:delivery.hitsPerTick`); } if (!isNonNegative(ability.delivery.intervalSeconds)) { missing.push(`${prefix}:delivery.intervalSeconds`); } if (!isNonNegative(ability.delivery.firstTickDelaySeconds ?? 0)) { missing.push(`${prefix}:delivery.firstTickDelaySeconds`); } } if ( ability.activation.mode === 'custom_per_minute' && !isPositive(ability.activation.usesPerMinute) ) { missing.push(`${prefix}:activation.usesPerMinute`); } if ( ability.activation.mode === 'on_cooldown' && !isPositive(ability.cooldownSeconds) && !isPositive(ability.castSeconds) ) { missing.push(`${prefix}:activation.cadence`); } } for (const [index, window] of (input.modifierWindows ?? []).entries()) { if (!isNonNegative(window.startSeconds)) { missing.push(`modifierWindow:${index}:startSeconds`); } if (!isPositive(window.endSeconds) || window.endSeconds <= window.startSeconds) { missing.push(`modifierWindow:${index}:endSeconds`); } for (const [attributeId, value] of Object.entries(window.attributes)) { if (!isFiniteNumber(value)) missing.push(`modifierWindow:${index}:attribute:${attributeId}`); } } for (const [attributeId, value] of Object.entries(input.attributes ?? {})) { if (!isFiniteNumber(value)) missing.push(`attribute:${attributeId}`); } const boundIds = new Set(input.rules?.bindings.map((binding) => binding.attributeId) ?? []); const suppliedIds = new Set([ ...Object.keys(input.attributes ?? {}), ...(input.modifierWindows ?? []).flatMap((window) => Object.keys(window.attributes)) ]); for (const attributeId of suppliedIds) { if (!boundIds.has(attributeId)) missing.push(`binding:${attributeId}`); } return [...new Set(missing)]; } function activationInterval(ability: SkillAbilityInput) { return ability.activation.mode === 'custom_per_minute' ? 60 / ability.activation.usesPerMinute : 0; } function stageFactorAt( input: SkillCalculationInput, rules: SkillCalculationRules, abilityId: string, time: number, stage: 'cooldown_recovery' | 'duration' ) { const values = stageValues(attributesAt(input, abilityId, time), rules.bindings, stage); return Math.max(EPSILON, (1 + values.additive / 100) * values.multiplicative); } export function skillActivationTimes( ability: SkillAbilityInput, encounterSeconds: number, input: SkillCalculationInput, rules: SkillCalculationRules ) { if (ability.activation.mode === 'off') return []; const castSeconds = Number(ability.castSeconds); const cooldownAt = (time: number) => Number(ability.cooldownSeconds) / stageFactorAt(input, rules, ability.id, time, 'cooldown_recovery'); const requestedInterval = activationInterval(ability); const policy = ability.chargePolicy!; const times: number[] = []; let lastActivation = -Infinity; let nextRequested = 0; if (policy.type === 'none') { for ( let time = 0; time < encounterSeconds - EPSILON && times.length < MAX_EVENTS; time += Math.max(castSeconds, cooldownAt(time), requestedInterval) ) { times.push(time); } return times; } if (policy.type === 'independent') { const readyAt = Array.from({ length: policy.charges }, () => 0); while (times.length < MAX_EVENTS) { const chargeIndex = readyAt.reduce( (earliest, ready, index) => ready < readyAt[earliest] ? index : earliest, 0 ); const time = Math.max( lastActivation + castSeconds, readyAt[chargeIndex], ability.activation.mode === 'custom_per_minute' ? nextRequested : 0 ); if (time >= encounterSeconds - EPSILON) break; times.push(time); lastActivation = time; readyAt[chargeIndex] = time + cooldownAt(time); if (ability.activation.mode === 'custom_per_minute') nextRequested = time + requestedInterval; } return times; } let charges = policy.charges; let fullRechargeAt = Infinity; while (times.length < MAX_EVENTS) { let time = Math.max( lastActivation + castSeconds, ability.activation.mode === 'custom_per_minute' ? nextRequested : 0 ); if (charges === 0) { time = Math.max(time, fullRechargeAt); charges = policy.charges; fullRechargeAt = Infinity; } if (time >= encounterSeconds - EPSILON) break; times.push(time); lastActivation = time; charges -= 1; if (charges === 0) fullRechargeAt = time + cooldownAt(time); if (ability.activation.mode === 'custom_per_minute') nextRequested = time + requestedInterval; } return times; } function damageEvents( ability: SkillAbilityInput, activations: number[], encounterSeconds: number, input: SkillCalculationInput, rules: SkillCalculationRules ) { const events: DamageEvent[] = []; for (const [activationIndex, activationTime] of activations.entries()) { const deliveryTime = activationTime + Number(ability.castSeconds); if (ability.delivery.type === 'direct') { for (let hit = 0; hit < ability.delivery.hits; hit += 1) { if (deliveryTime < encounterSeconds - EPSILON) { events.push({ activationIndex, time: deliveryTime }); } } continue; } const firstTick = deliveryTime + Number(ability.delivery.firstTickDelaySeconds ?? 0); const durationFactor = stageFactorAt(input, rules, ability.id, activationTime, 'duration'); const baseDuration = Number( ability.delivery.durationSeconds ?? ability.delivery.ticks * ability.delivery.intervalSeconds ); const ticks = Math.max( 1, Math.floor(baseDuration * durationFactor / ability.delivery.intervalSeconds) ); for (let tick = 0; tick < ticks; tick += 1) { const time = firstTick + tick * ability.delivery.intervalSeconds; if (time < encounterSeconds - EPSILON) { for (let hit = 0; hit < Number(ability.delivery.hitsPerTick ?? 1); hit += 1) { events.push({ activationIndex, time }); } } } } return events; } function attributesAt( input: SkillCalculationInput, abilityId: string, time: number ) { const attributes = { ...(input.attributes ?? {}) }; for (const window of input.modifierWindows ?? []) { const appliesToAbility = !window.abilityIds?.length || window.abilityIds.includes(abilityId); if (!appliesToAbility || time < window.startSeconds || time >= window.endSeconds) continue; for (const [attributeId, value] of Object.entries(window.attributes)) { const current = attributes[attributeId] ?? 0; const rule = input.attributeRules?.[attributeId]; const combined = rule?.combination === 'multiplicative_percent' ? ((1 + current / 100) * (1 + value / 100) - 1) * 100 : current + value; attributes[attributeId] = rule?.cap === null || rule?.cap === undefined ? combined : Math.min(combined, rule.cap); } } return attributes; } function stageValues( attributes: SkillStatValues, bindings: SkillAttributeBinding[], stage: SkillCalculationStage ) { let additive = 0; let multiplicative = 1; for (const binding of bindings) { if (binding.stage !== stage) continue; const raw = Number(attributes[binding.attributeId] ?? 0); const value = raw * (Number.isFinite(binding.weight) ? Number(binding.weight) : 1); if (binding.operator === 'add') additive += value; else multiplicative *= value; } return { additive, multiplicative }; } function expectedDamage( baseDamage: number, attributes: SkillStatValues, rules: SkillCalculationRules ) { let damage = baseDamage; const stageOrder = rules.stageOrder?.length ? rules.stageOrder : ['flat_damage', 'skill_damage', 'final_damage'] satisfies SkillCalculationStage[]; for (const stage of stageOrder) { const values = stageValues(attributes, rules.bindings, stage); if (stage === 'flat_damage') { damage = (damage + values.additive) * values.multiplicative; } else if (stage === 'skill_damage' || stage === 'final_damage') { damage *= (1 + values.additive / 100) * values.multiplicative; } } const criticalChance = stageValues(attributes, rules.bindings, 'critical_chance'); const criticalDamage = stageValues(attributes, rules.bindings, 'critical_damage'); const multishotChance = stageValues(attributes, rules.bindings, 'multishot_chance'); const multishotDamage = stageValues(attributes, rules.bindings, 'multishot_damage'); const criticalChanceRate = clamp( criticalChance.additive * criticalChance.multiplicative / 100, 0, 1 ); const criticalDamageBonus = criticalDamage.additive * criticalDamage.multiplicative / 100; const multishotChanceRate = clamp( multishotChance.additive * multishotChance.multiplicative / 100, 0, 1 ); const multishotDamageFactor = multishotDamage.additive * multishotDamage.multiplicative / 100; return damage * (1 + criticalChanceRate * criticalDamageBonus) * (1 + multishotChanceRate * multishotDamageFactor); } function zeroDamage(baseDamage = 0): SkillDamageSummary { return { unmodifiedBaseDamage: baseDamage, averageExpectedDamagePerHit: 0, singleTargetDamagePerActivation: 0, aggregateDamagePerActivation: 0, singleTargetEncounterDamage: 0, aggregateEncounterDamage: 0, singleTargetDps: 0, aggregateDps: 0 }; } export function calculateSkillDps(input: SkillCalculationInput): SkillCalculation { const missing = requiredData(input); if (missing.length) { return { status: 'INSUFFICIENT_DATA', revisionId: input.rules?.revisionId ?? null, missing, abilities: [], combined: null }; } const rules = input.rules!; const encounterSeconds = Number(input.assumptions.encounterSeconds); const targetCount = Number(input.assumptions.targetCount); const abilities: SkillAbilityCalculation[] = input.abilities.map((ability) => { if (ability.activation.mode === 'off') { return { abilityId: ability.id, damageType: ability.damageType ?? '', enabled: false, activationCount: 0, damageEventCount: 0, effectiveTargets: 0, damage: zeroDamage(Number(ability.baseDamage ?? 0)) }; } const activations = skillActivationTimes(ability, encounterSeconds, input, rules); const events = damageEvents(ability, activations, encounterSeconds, input, rules); const damageByEvent = events.map((event) => expectedDamage( Number(ability.baseDamage), attributesAt(input, ability.id, event.time), rules ) ); const singleTargetEncounterDamage = damageByEvent.reduce((total, damage) => total + damage, 0); const effectiveTargets = Math.min( Number(ability.targetCount ?? targetCount), Number(ability.targetCap) ); const aggregateEncounterDamage = singleTargetEncounterDamage * effectiveTargets; const deliveredActivations = new Set(events.map((event) => event.activationIndex)).size; const averageExpectedDamagePerHit = damageByEvent.length ? singleTargetEncounterDamage / damageByEvent.length : 0; const singleTargetDamagePerActivation = deliveredActivations ? singleTargetEncounterDamage / deliveredActivations : 0; return { abilityId: ability.id, damageType: ability.damageType!, enabled: true, activationCount: activations.length, damageEventCount: events.length, effectiveTargets, damage: { unmodifiedBaseDamage: Number(ability.baseDamage), averageExpectedDamagePerHit, singleTargetDamagePerActivation, aggregateDamagePerActivation: singleTargetDamagePerActivation * effectiveTargets, singleTargetEncounterDamage, aggregateEncounterDamage, singleTargetDps: singleTargetEncounterDamage / encounterSeconds, aggregateDps: aggregateEncounterDamage / encounterSeconds } }; }); const combined = abilities.reduce( (total, ability) => ({ singleTargetEncounterDamage: total.singleTargetEncounterDamage + ability.damage.singleTargetEncounterDamage, aggregateEncounterDamage: total.aggregateEncounterDamage + ability.damage.aggregateEncounterDamage, singleTargetDps: total.singleTargetDps + ability.damage.singleTargetDps, aggregateDps: total.aggregateDps + ability.damage.aggregateDps }), { singleTargetEncounterDamage: 0, aggregateEncounterDamage: 0, singleTargetDps: 0, aggregateDps: 0 } ); return { status: 'OK', revisionId: rules.revisionId, missing: [], abilities, combined }; }