554 lines
17 KiB
TypeScript
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
|
|
};
|
|
}
|