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.