PageSourceSearch

https://aoe2.up.railway.app/v3-runtime/src/combat/matchup-summary.js

js aoe2.up.railway.app collected 2026-10-03 12:11:43 UTC 18,564 bytes, 484 lines download raw bytes

1import {
2  calculateDamage,
3  chargeCanTarget,
4  chargeProjectileDamage,
5  chargeSpec,
6  meleeChargeSpec,
7  rangedSpec,
8  trampleSpec,
9} from "./attacks.js";
10
11
12const TICKS_PER_SECOND = 60;
13const MAX_TTK_TICKS = 10 * 60 * TICKS_PER_SECOND;
14
15const ARMOR_CLASS_NAMES = Object.freeze({
16  1: "infantry",
17  3: "pierce",
18  4: "melee",
19  5: "war elephants",
20  8: "cavalry",
21  11: "buildings",
22  15: "archers",
23  17: "rams",
24  19: "unique units",
25  20: "siege",
26  21: "buildings",
27  23: "gunpowder units",
28  25: "monks",
29  27: "spearmen",
30  28: "cavalry archers",
31  29: "eagle warriors",
32  30: "camels",
33  32: "condottieri",
34  34: "mamelukes",
35  36: "Hussite Wagons",
36  38: "skirmishers",
37  39: "mounted archers",
38});
39
40
41function finite(value, fallback = 0) {
42  return Number.isFinite(value) ? value : fallback;
43}
44
45
46function percent(value) {
47  return `${Math.round(finite(value) * 100)}%`;
48}
49
50
51function seconds(value) {
52  const rounded = Math.round(finite(value) * 10) / 10;
53  return `${rounded.toFixed(Number.isInteger(rounded) ? 0 : 1)}s`;
54}
55
56
57function syntheticUnit(mechanics, hp = mechanics.hp) {
58  return {
59    mechanics,
60    hp,
61    maxHp: mechanics.hp,
62    specialState: {
63      baseMaxHp: mechanics.hp,
64      transformed: false,
65      armorStripped: 0,
66      killAttackBonus: 0,
67      nearbyAttackBonus: 0,
68    },
69  };
70}
71
72
73function attackCategory(mechanics) {
74  const declared = mechanics?.combat_categories;
75  if (Array.isArray(declared) && declared.includes("mounted")) return "mounted";
76  return finite(mechanics?.attack_classes?.["39"]) < 0 ? "mounted" : null;
77}
78
79
80function matchingBonusDamage(attacker, target) {
81  const rows = [];
82  for (const [classId, rawAttack] of Object.entries(attacker.attack_classes ?? {})) {
83    if (classId === "3" || classId === "4" || rawAttack <= 0) continue;
84    const rawArmor = target.armor_classes?.[classId];
85    if (!Number.isFinite(rawArmor)) continue;
86    const damage = Math.max(0, rawAttack - rawArmor);
87    if (damage <= 0) continue;
88    rows.push({
89      classId,
90      label: ARMOR_CLASS_NAMES[classId] ?? `class ${classId}`,
91      attack: rawAttack,
92      armor: rawArmor,
93      damage,
94    });
95  }
96  return rows;
97}
98
99
100function recurringProjectilePackets(attacker, target) {
101  const actor = syntheticUnit(attacker);
102  const victim = syntheticUnit(target);
103  const ranged = rangedSpec(attacker);
104  // DAT accuracy 0 is used by ground/scatter weapons (Organ Gun, Grenadier,
105  // War Chariot barrage), not as a literal “can never deal damage” chance.
106  // Their physical projectile spread is an army-geometry concern; the card's
107  // one-target clock treats a projectile that connects as a hit.
108  const accuracy = ranged && ranged.accuracyPercent > 0
109    ? ranged.accuracyPercent / 100
110    : 1;
111  const packets = [{
112    amount: calculateDamage(actor, victim) * accuracy,
113    hitWeight: accuracy,
114  }];
115  if (ranged?.extraProjectileCount > 0) {
116    for (let index = 0; index < ranged.extraProjectileCount; index += 1) {
117      packets.push({
118        amount: calculateDamage(actor, victim, {
119          attackClasses: ranged.extraProjectileAttacks ?? undefined,
120        }) * accuracy,
121        hitWeight: accuracy,
122      });
123    }
124  }
125  return packets;
126}
127
128
129function recurringCycleSeconds(mechanics) {
130  const ranged = rangedSpec(mechanics);
131  const extras = ranged?.extraProjectileCount ?? 0;
132  const burstTail = ranged?.reloadAfterFinalProjectile
133    ? extras * ranged.volleyIntervalTicks / TICKS_PER_SECOND
134    : 0;
135  return Math.max(1 / TICKS_PER_SECOND, mechanics.reload_seconds + burstTail);
136}
137
138
139function recurringDamagePerSecond(attacker, target) {
140  const damage = recurringProjectilePackets(attacker, target)
141    .reduce((sum, packet) => sum + packet.amount, 0);
142  return damage / recurringCycleSeconds(attacker);
143}
144
145
146function transformTargetIfNeeded(target) {
147  const effects = target.mechanics.effects ?? {};
148  const threshold = finite(effects.hp_transform_threshold);
149  if (target.specialState.transformed || threshold <= 0) return;
150  if (target.hp >= target.specialState.baseMaxHp * threshold - 1e-12) return;
151  target.specialState.transformed = true;
152  if (finite(effects.transform_hp) > 0) {
153    target.maxHp = effects.transform_hp;
154    target.hp = Math.min(target.hp, target.maxHp);
155  }
156}
157
158
159function shieldChargesFor(target, attacker) {
160  const effects = target.effects ?? {};
161  const ranged = attacker.ranged !== null && attacker.ranged !== undefined;
162  const universal = finite(
163    effects.dodge_shield_max
164      ?? effects.block_first_attacks
165      ?? effects.ignore_first_attacks,
166  );
167  if (universal > 0) return Math.floor(universal);
168  if (!ranged && effects.block_first_melee) return 1;
169  if (ranged && effects.block_first_ranged) return 1;
170  return 0;
171}
172
173
174function attackReloadTicks(attacker, hitTicks, transformed = false) {
175  const effects = attacker.effects ?? {};
176  let reload = transformed && finite(effects.transform_attack_speed) > 0
177    ? 1 / effects.transform_attack_speed
178    : attacker.reload_seconds;
179  const ramp = finite(effects.attack_speed_ramp);
180  if (ramp > 0) {
181    const cutoff = (hitTicks.at(-1) ?? 0) - 5 * TICKS_PER_SECOND;
182    const recent = hitTicks.filter((tick) => tick > cutoff).length;
183    reload = Math.max(finite(effects.attack_speed_min, 1), reload - ramp * recent);
184  }
185  const ranged = rangedSpec(attacker);
186  const burstTail = ranged?.reloadAfterFinalProjectile
187    ? (ranged.extraProjectileCount ?? 0) * ranged.volleyIntervalTicks
188    : 0;
189  return Math.max(1, Math.ceil(reload * TICKS_PER_SECOND) + burstTail);
190}
191
192
193function packetDamage(attacker, target, attackClasses = undefined) {
194  return calculateDamage(syntheticUnit(attacker), target, { attackClasses });
195}
196
197
198// A compact one-on-one combat clock. It intentionally excludes walking,
199// projectile travel and retaliation: the card answers “once this unit can
200// attack, how long does it take to defeat one of that unit?” The damage path
201// is shared with the V3 engine, while the small timeline below accounts for
202// charge recharge, burst cadence, armor stripping, bleed, regeneration,
203// transformations, dismounts and any first-hit shield exported by the profile.
204export function estimateTimeToKill(attackerMechanics, targetMechanics) {
205  const attacker = attackerMechanics;
206  let target = syntheticUnit(targetMechanics);
207  let shieldCharges = shieldChargesFor(targetMechanics, attackerMechanics);
208  const openingCharge = chargeSpec(attacker);
209  const openingProjectileCharge = chargeCanTarget(openingCharge, target) ? openingCharge : null;
210  const openingMeleeCharge = meleeChargeSpec(attacker);
211  const openingDelaySeconds = openingProjectileCharge
212    ? openingProjectileCharge.windupTicks / TICKS_PER_SECOND
213    : openingMeleeCharge
214      ? openingMeleeCharge.windupTicks / TICKS_PER_SECOND
215      : attacker.attack_delay_seconds;
216  let nextAttackTick = Math.max(0, Math.round(
217    openingDelaySeconds * TICKS_PER_SECOND,
218  ));
219  let nextChargeTick = 0;
220  let bleedStacks = [];
221  let delayedDamage = [];
222  let activeHazards = [];
223  const hitTicks = [];
224  let dismounted = false;
225
226  const damageTarget = (amount, hitWeight = 1) => {
227    if (amount <= 0) return;
228    if (shieldCharges > 0) {
229      shieldCharges -= 1;
230      return;
231    }
232    target.hp = Math.max(0, target.hp - amount);
233    const effects = attacker.effects ?? {};
234    if (target.hp > 0 && finite(effects.armor_strip_per_hit) > 0) {
235      target.specialState.armorStripped += effects.armor_strip_per_hit * hitWeight;
236    }
237    if (target.hp > 0 && finite(effects.bleed_dps) > 0
238        && finite(effects.bleed_duration) > 0) {
239      bleedStacks.push({
240        dps: effects.bleed_dps * hitWeight,
241        until: currentTick + Math.ceil(effects.bleed_duration * TICKS_PER_SECOND),
242      });
243    }
244    transformTargetIfNeeded(target);
245  };
246
247  let currentTick = 0;
248  while (currentTick <= MAX_TTK_TICKS) {
249    if (target.hp <= 1e-9) {
250      if (!dismounted && target.mechanics.dismount_form) {
251        target = syntheticUnit(target.mechanics.dismount_form);
252        shieldCharges = shieldChargesFor(target.mechanics, attacker);
253        bleedStacks = [];
254        activeHazards = [];
255        dismounted = true;
256      } else {
257        return currentTick / TICKS_PER_SECOND;
258      }
259    }
260    const targetEffects = target.mechanics.effects ?? {};
261    const regenPerMinute = finite(targetEffects.hp_regen);
262    if (regenPerMinute > 0 && target.hp < target.maxHp) {
263      target.hp = Math.min(target.maxHp,
264        target.hp + regenPerMinute / 60 / TICKS_PER_SECOND);
265    }
266    bleedStacks = bleedStacks.filter(({ until }) => until > currentTick);
267    const bleedDamage = bleedStacks.reduce((sum, stack) => sum + stack.dps, 0)
268      / TICKS_PER_SECOND;
269    if (bleedDamage > 0) damageTarget(bleedDamage, 0);
270
271    for (const pending of delayedDamage.filter(({ tick }) => tick === currentTick)) {
272      damageTarget(pending.amount, 0);
273    }
274    delayedDamage = delayedDamage.filter(({ tick }) => tick > currentTick);
275    activeHazards = activeHazards.filter(({ until }) => until > currentTick);
276    if (activeHazards.length > 0) {
277      const stacks = (attacker.effects ?? {}).impact_hazard_stacks === true;
278      const dps = stacks
279        ? activeHazards.reduce((sum, hazard) => sum + hazard.dps, 0)
280        : Math.max(...activeHazards.map(({ dps: value }) => value));
281      damageTarget(dps / TICKS_PER_SECOND, 0);
282    }
283
284    if (currentTick === nextAttackTick) {
285      const ranged = rangedSpec(attacker);
286      const candidateCharge = chargeSpec(attacker);
287      const projectileCharge = chargeCanTarget(candidateCharge, target) ? candidateCharge : null;
288      const meleeCharge = meleeChargeSpec(attacker);
289      const legacyCharge = finite(attacker.effects?.charge_attack_melee);
290      const chargeReady = currentTick >= nextChargeTick;
291      let releasedRegularAttack = true;
292
293      if (projectileCharge && chargeReady) {
294        const hitWeight = (ranged?.baseAccuracyPercent ?? 100) / 100;
295        for (let index = 0; index < projectileCharge.projectileCount; index += 1) {
296          damageTarget(chargeProjectileDamage(projectileCharge, target, syntheticUnit(attacker)) * hitWeight,
297            hitWeight);
298        }
299        releasedRegularAttack = projectileCharge.addsToNormalAttack;
300        const rechargeSeconds = finite(attacker.charge?.recharge_seconds)
301          || projectileCharge.maxCharge / projectileCharge.rechargeRate;
302        nextChargeTick = currentTick + Math.ceil(rechargeSeconds * TICKS_PER_SECOND);
303      }
304
305      if (!projectileCharge && chargeReady && (meleeCharge || legacyCharge > 0)) {
306        const base = packetDamage(attacker, target);
307        damageTarget(base + (meleeCharge?.attackBonus ?? legacyCharge));
308        releasedRegularAttack = false;
309        const recharge = meleeCharge?.rechargeSeconds
310          ?? finite(attacker.effects?.charge_recharge_time);
311        nextChargeTick = currentTick + Math.ceil(recharge * TICKS_PER_SECOND);
312      }
313
314      if (releasedRegularAttack) {
315        const accuracy = ranged && ranged.accuracyPercent > 0
316          ? ranged.accuracyPercent / 100
317          : 1;
318        damageTarget(packetDamage(attacker, target) * accuracy, accuracy);
319        for (let index = 0; index < (ranged?.extraProjectileCount ?? 0); index += 1) {
320          damageTarget(packetDamage(
321            attacker,
322            target,
323            ranged.extraProjectileAttacks ?? undefined,
324          ) * accuracy, accuracy);
325        }
326        const effects = attacker.effects ?? {};
327        const delayedAttack = finite(effects.delayed_impact_melee_attack);
328        const repeats = Math.max(0, Math.floor(finite(effects.delayed_impact_repeat_count)));
329        const delaySeconds = finite(effects.delayed_impact_delay_seconds);
330        const intervalSeconds = finite(
331          effects.delayed_impact_repeat_interval_seconds,
332          delaySeconds,
333        );
334        if (delayedAttack > 0 && repeats > 0 && delaySeconds > 0) {
335          const amount = packetDamage(attacker, target, { 4: delayedAttack });
336          for (let repeat = 0; repeat < repeats; repeat += 1) {
337            delayedDamage.push({
338              tick: currentTick + Math.ceil(
339                (delaySeconds + repeat * intervalSeconds) * TICKS_PER_SECOND,
340              ),
341              amount,
342            });
343          }
344        }
345        const hazardDps = finite(effects.impact_hazard_damage_per_second);
346        const hazardDuration = finite(effects.impact_hazard_duration_seconds);
347        if (hazardDps > 0 && hazardDuration > 0) {
348          activeHazards.push({
349            dps: hazardDps,
350            until: currentTick + Math.ceil(hazardDuration * TICKS_PER_SECOND),
351          });
352        }
353      }
354
355      hitTicks.push(currentTick);
356      nextAttackTick = currentTick + attackReloadTicks(
357        attacker,
358        hitTicks,
359        false,
360      );
361    }
362    currentTick += 1;
363  }
364  return null;
365}
366
367
368function mechanicCallouts(attacker, target, bonusRows) {
369  const effects = attacker.effects ?? {};
370  const targetEffects = target.effects ?? {};
371  const ranged = rangedSpec(attacker);
372  const callouts = [];
373  const add = (text) => {
374    if (text && !callouts.includes(text)) callouts.push(text);
375  };
376
377  for (const bonus of bonusRows) {
378    add(`+${bonus.damage} bonus damage vs ${bonus.label}`);
379  }
380  const areaCharge = meleeChargeSpec(attacker);
381  const projectileCharge = chargeSpec(attacker);
382  const legacyCharge = finite(effects.charge_attack_melee);
383  if (areaCharge || legacyCharge > 0) {
384    const amount = areaCharge?.attackBonus ?? legacyCharge;
385    const recharge = areaCharge?.rechargeSeconds ?? effects.charge_recharge_time;
386    add(`Charge: +${amount} damage${recharge ? ` every ${seconds(recharge)}` : ""}`);
387  }
388  if (projectileCharge) {
389    add(`Charge volley: ${projectileCharge.projectileCount} projectile${projectileCharge.projectileCount === 1 ? "" : "s"}`);
390  }
391  if (effects.ignores_melee_armor) add("Ignores melee armor");
392  if (effects.ignores_pierce_armor) add("Ignores pierce armor");
393  if (ranged && ranged.accuracyPercent === 0) {
394    add("Projectiles use physical spread");
395  } else if (ranged && ranged.accuracyPercent < 100) {
396    add(`${Math.round(ranged.accuracyPercent)}% accuracy; missed shots can strike other units`);
397  }
398  if ((ranged?.extraProjectileCount ?? 0) > 0) {
399    add(`${ranged.extraProjectileCount + 1}-projectile attack`);
400  }
401  if (ranged?.reloadAfterFinalProjectile && ranged.volleyIntervalTicks > 0) {
402    add(`Burst fire: ${seconds(ranged.volleyIntervalTicks / TICKS_PER_SECOND)} between arrows`);
403  }
404  if (ranged?.minRangeTiles > 0) add(`${ranged.minRangeTiles}-tile minimum range`);
405  if (ranged?.passThrough) {
406    add(`Pass-through: ${percent(ranged.passThroughDamageFraction)} damage to units behind`);
407  }
408  const trample = trampleSpec(attacker);
409  if (trample) {
410    const shape = trample.shape === "forward-cone" ? "Forward splash" : "Area damage";
411    const amount = trample.flatDamage !== undefined
412      ? `${trample.flatDamage} damage` : percent(trample.damageFraction);
413    add(`${shape}: ${amount} within ${trample.widthTiles.toFixed(2).replace(/0+$/, "").replace(/\.$/, "")} tiles`);
414  }
415  if (finite(effects.armor_strip_per_hit) > 0) {
416    add(`Strips ${effects.armor_strip_per_hit} armor per hit`);
417  }
418  if (finite(effects.bleed_dps) > 0) {
419    add(`Bleed: ${effects.bleed_dps} DPS for ${seconds(effects.bleed_duration)}`);
420  }
421  if (finite(effects.execute_damage_per_step) > 0) {
422    const step = effects.execute_hp_step > 1
423      ? effects.execute_hp_step / 100
424      : effects.execute_hp_step;
425    add(`Deals +${effects.execute_damage_per_step} damage per ${percent(step)} target HP lost`);
426  }
427  if (finite(effects.on_hit_slow_percent) > 0) {
428    add(`Slows movement ${percent(effects.on_hit_slow_percent)} for ${seconds(effects.on_hit_slow_duration_seconds)}`);
429  }
430  if (finite(effects.delayed_impact_repeat_count) > 0) {
431    add(`${effects.delayed_impact_repeat_count} delayed impact explosions`);
432  }
433  if (finite(effects.impact_hazard_damage_per_second) > 0) {
434    add(`Burning ground: ${effects.impact_hazard_damage_per_second} DPS for ${seconds(effects.impact_hazard_duration_seconds)}`);
435  }
436  if (finite(effects.attack_speed_ramp) > 0) add("Attacks faster with consecutive hits");
437  if (finite(effects.attack_bonus_per_kill) > 0) {
438    add(`Gains up to +${effects.attack_bonus_per_kill} attack from kills`);
439  }
440  if (finite(effects.hp_per_kill) > 0) add(`Heals ${effects.hp_per_kill} HP per kill`);
441  if (finite(effects.hp_regen) > 0) add(`Regenerates ${effects.hp_regen} HP per minute`);
442  if (finite(effects.hp_nearby_percent_per_unit) > 0) add("Gains HP near allied units");
443  if (finite(effects.hp_transform_threshold) > 0) {
444    add(`Changes form below ${percent(effects.hp_transform_threshold)} HP`);
445  }
446  if (attacker.dismount_form) add("Continues fighting dismounted after defeat");
447  if (finite(effects.death_explosion_melee_attack) > 0) add("Explodes on death");
448  if (finite(effects.damage_reflect_percent) > 0) {
449    add(`Reflects ${percent(effects.damage_reflect_percent)}
449 melee damage`);
450  }
451
452  const reduction = finite(
453    target.damage_reduction_by_attacker_category?.[attackCategory(attacker)],
454  );
455  if (reduction > 0) add(`Target reduces each hit by ${reduction}`);
456  const shield = shieldChargesFor(target, attacker);
457  if (shield > 0) add(`Target blocks the first ${shield} hit${shield === 1 ? "" : "s"}`);
458  if (finite(targetEffects.hp_regen) > 0) {
459    add(`Target regenerates ${targetEffects.hp_regen} HP per minute`);
460  }
461  if (target.dismount_form) add("Time to kill includes the target's dismounted form");
462  return callouts;
463}
464
465
466export function summarizeMatchup(attackerMechanics, targetMechanics) {
467  const attacker = syntheticUnit(attackerMechanics);
468  const target = syntheticUnit(targetMechanics);
469  const damagePerHit = calculateDamage(attacker, target);
470  const bonusDamage = matchingBonusDamage(attackerMechanics, targetMechanics);
471  const timeToKillSeconds = estimateTimeToKill(attackerMechanics, targetMechanics);
472  return Object.freeze({
473    damagePerHit,
474    damagePerSecond: recurringDamagePerSecond(attackerMechanics, targetMechanics),
475    timeToKillSeconds,
476    bonusDamage: Object.freeze(bonusDamage),
477    callouts: Object.freeze(mechanicCallouts(
478      attackerMechanics,
479      targetMechanics,
480      bonusDamage,
481    )),
482    timeToKillHelp: "One unit defeating one opposing unit from attack-ready range. Includes reload, accuracy, bursts, charge, armor changes, regeneration, damage-over-time and dismounting; excludes walking, projectile travel and retaliation.",
483  });
484}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.