Files
shd.build/src/lib/calculations/skill.ts
T

554 lines
17 KiB
TypeScript

export type SkillStatValues = Record<string, number>;
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<string, {
combination: 'additive' | 'multiplicative_percent';
cap?: number | null;
}>;
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<string>();
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<SkillCombinedCalculation>(
(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
};
}