diablo2-web/src/game/skills.ts

8307 lines
233 KiB
TypeScript

/**
* Skills: table-driven definitions, casting, and projectiles.
*
* Diablo II skills are rows in `Skills.txt`: a mana cost, a cooldown, a range,
* and damage that scales with the skill's level in bands. This module reads that
* shape and turns it into something castable, in three parts:
*
* - a **definition** read from a table row (never a constant in code, so a real
* `Skills.txt` changes the game without touching this file);
* - a **cast** step that checks mana and cooldown and produces either an instant
* effect or a projectile;
* - a **projectile** step that flies at the simulation rate, dies on walls,
* expires on its own timer, and reports what it hit.
*
* Damage progression is the one place where a simplification is visible in the
* code: the real table scales damage through five level bands
* (`MinLevDam1..5`), and this reads a single `PerLevel` slope instead. The band
* columns are a mapping-layer problem — they carry no new mechanics, only a
* different interpolation — so the formula is isolated in {@link skillDamageAt}
* where the bands can replace it.
*/
import { numberCell, resolveText, textCell } from './tables.ts'
import type { TextSource, DataTable } from './tables.ts'
import type { D2Table } from './acts.ts'
import type { Rng } from './rng.ts'
import type { CharacterClassCode } from './classes.ts'
import type { CombatPlayer } from './combat.ts'
import {
FROZEN_ORB_X_POSITIONS,
FROZEN_ORB_Y_POSITIONS,
calculateFrostNovaDamage,
calculateFrostNovaChillLength,
calculateFrostNovaSynergyMultiplier,
spawnFrostNovaRing,
calculateNovaDamage,
calculateStaticFieldDamage,
calculateStaticFieldRadiusYards,
calculateStaticFieldRadiusPx,
hasLineOfSight,
spawnNovaRing,
executeStaticField,
calculateLightningDamage,
calculateLightningSynergyMultiplier,
calculateChainLightningDamage,
calculateChainLightningSynergyMultiplier,
calculateChainLightningJumps,
type StaticFieldResult,
type StaticFieldTarget,
GAT_CHARGED_BOLT_OFFSETS,
calculateChargedBoltCount,
calculateChargedBoltSynergyMultiplier,
calculateChargedBoltDamage,
calculateEnergyShieldManaRatio,
calculateEnergyShieldDuration,
calculateEnergyShieldAbsorbPct,
calculateThunderStormDuration,
calculateThunderStormInterval,
calculateThunderStormDamage,
calculateLightningMasteryBonusPct,
calculateTelekinesisDamage,
isTelekinesisItemAllowed,
isTelekinesisObjectAllowed,
calculateFrozenArmorDefense,
calculateFrozenArmorDuration,
calculateFrozenArmorFreezeLength,
calculateShiverArmorDefense,
calculateShiverArmorDuration,
calculateShiverArmorDamage,
calculateShiverArmorChillLength,
calculateChillingArmorDefense,
calculateChillingArmorDuration,
calculateChillingArmorDamage,
calculateChillingArmorChillLength,
calculateColdArmorDuration,
calculateIceBlastDamage,
calculateIceBlastFreezeLength,
calculateGlacialSpikeDamage,
calculateGlacialSpikeFreezeLength,
calculateColdMasteryPierce,
calculateFireWallDamage,
calculateBlazeDamage,
calculateInfernoDamage,
calculateBlazeDuration,
calculateBlazePatchDuration,
calculateInfernoRange,
calculateInfernoRangePx,
calculateInfernoContinuousMana256,
calculateFireWallPatches,
calculateEnchantDamage,
calculateEnchantAttackRating,
calculateEnchantDuration,
calculateFireMasteryBonus,
calculateEnchantAttackDamage,
calculateTeethDamage,
calculateTeethSynergyMultiplier,
calculateTeethCount,
calculateBoneArmorCapacity,
calculateBoneArmorSynergyBonus,
calculatePoisonDaggerDamage,
calculatePoisonDaggerSynergyMultiplier,
calculatePoisonDaggerAttackRatingBonus,
calculateCorpseExplosionDamage,
calculateCorpseExplosionRadiusYards,
calculateCorpseExplosionRadiusPx,
calculateBoneWallStats,
calculatePoisonExplosionDamage,
calculatePoisonExplosionSynergyMultiplier,
calculateBoneSpearDamage,
calculateBoneSpearSynergyMultiplier,
calculateBonePrisonStats,
calculatePoisonNovaDamage,
calculatePoisonNovaSynergyMultiplier,
calculateBoneSpiritDamage,
calculateBoneSpiritSynergyMultiplier,
spawnPoisonNovaRing,
calculateCurseRadiusYards,
calculateCurseRadiusPx,
calculateCurseDurationFrames,
calculateAmplifyDamageDebuff,
calculateWeakenDebuff,
calculateIronMaidenReturnPct,
calculateTerrorDistance,
calculateDecrepifyDebuff,
calculateLowerResistReduction,
computeImmunityBreak,
calculateCurseEffect,
calculateSkeletonMaxCount,
calculateSkeletalMageMaxCount,
calculateReviveMaxCount,
calculateSkeletonMasteryStats,
calculateGolemMasteryStats,
calculateSummonResistPct,
calculateClayGolemStats,
calculateBloodGolemStats,
calculateIronGolemStats,
calculateFireGolemStats,
calculateReviveStats,
type SkeletonMasteryStats,
type GolemMasteryStats,
type ClayGolemStats,
type BloodGolemStats,
type IronGolemItemProps,
type IronGolemStats,
type FireGolemStats,
type ReviveStats,
type CurseOpts,
type CurseSpec,
type DamageRange,
type PoisonDamageSpec,
type SynergyState,
} from './engine/missile-engine.ts'
export {
calculateFrostNovaDamage,
calculateFrostNovaChillLength,
calculateFrostNovaSynergyMultiplier,
spawnFrostNovaRing,
calculateNovaDamage,
calculateStaticFieldDamage,
calculateStaticFieldRadiusYards,
calculateStaticFieldRadiusPx,
hasLineOfSight,
spawnNovaRing,
executeStaticField,
calculateLightningDamage,
calculateLightningSynergyMultiplier,
calculateChainLightningDamage,
calculateChainLightningSynergyMultiplier,
calculateChainLightningJumps,
type StaticFieldResult,
type StaticFieldTarget,
GAT_CHARGED_BOLT_OFFSETS,
calculateChargedBoltCount,
calculateChargedBoltSynergyMultiplier,
calculateChargedBoltDamage,
calculateEnergyShieldManaRatio,
calculateEnergyShieldDuration,
calculateEnergyShieldAbsorbPct,
calculateThunderStormDuration,
calculateThunderStormInterval,
calculateThunderStormDamage,
calculateLightningMasteryBonusPct,
calculateTelekinesisDamage,
isTelekinesisItemAllowed,
isTelekinesisObjectAllowed,
calculateFrozenArmorDefense,
calculateFrozenArmorDuration,
calculateFrozenArmorFreezeLength,
calculateShiverArmorDefense,
calculateShiverArmorDuration,
calculateShiverArmorDamage,
calculateShiverArmorChillLength,
calculateChillingArmorDefense,
calculateChillingArmorDuration,
calculateChillingArmorDamage,
calculateChillingArmorChillLength,
calculateColdArmorDuration,
calculateIceBlastDamage,
calculateIceBlastFreezeLength,
calculateGlacialSpikeDamage,
calculateGlacialSpikeFreezeLength,
calculateColdMasteryPierce,
calculateFireWallDamage,
calculateBlazeDamage,
calculateInfernoDamage,
calculateBlazeDuration,
calculateBlazePatchDuration,
calculateInfernoRange,
calculateInfernoRangePx,
calculateInfernoContinuousMana256,
calculateFireWallPatches,
calculateEnchantDamage,
calculateEnchantAttackRating,
calculateEnchantDuration,
calculateFireMasteryBonus,
calculateEnchantAttackDamage,
calculateTeethDamage,
calculateTeethSynergyMultiplier,
calculateTeethCount,
calculateBoneArmorCapacity,
calculateBoneArmorSynergyBonus,
calculatePoisonDaggerDamage,
calculatePoisonDaggerSynergyMultiplier,
calculatePoisonDaggerAttackRatingBonus,
calculateCorpseExplosionDamage,
calculateCorpseExplosionRadiusYards,
calculateCorpseExplosionRadiusPx,
calculateBoneWallStats,
calculatePoisonExplosionDamage,
calculatePoisonExplosionSynergyMultiplier,
calculateBoneSpearDamage,
calculateBoneSpearSynergyMultiplier,
calculateBonePrisonStats,
calculatePoisonNovaDamage,
calculatePoisonNovaSynergyMultiplier,
calculateBoneSpiritDamage,
calculateBoneSpiritSynergyMultiplier,
spawnPoisonNovaRing,
calculateCurseRadiusYards,
calculateCurseRadiusPx,
calculateCurseDurationFrames,
calculateAmplifyDamageDebuff,
calculateWeakenDebuff,
calculateIronMaidenReturnPct,
calculateTerrorDistance,
calculateDecrepifyDebuff,
calculateLowerResistReduction,
computeImmunityBreak,
calculateCurseEffect,
calculateSkeletonMaxCount,
calculateSkeletalMageMaxCount,
calculateReviveMaxCount,
calculateSkeletonMasteryStats,
calculateGolemMasteryStats,
calculateSummonResistPct,
calculateClayGolemStats,
calculateBloodGolemStats,
calculateIronGolemStats,
calculateFireGolemStats,
calculateReviveStats,
type SkeletonMasteryStats,
type GolemMasteryStats,
type ClayGolemStats,
type BloodGolemStats,
type IronGolemItemProps,
type IronGolemStats,
type FireGolemStats,
type ReviveStats,
type CurseOpts,
type CurseSpec,
type DamageRange,
type PoisonDamageSpec,
type SynergyState,
} from './engine/missile-engine.ts'
import {
BLIZZARD_CIRCLE_X,
BLIZZARD_CIRCLE_Y,
getBlizzardDropOffset,
} from './engine/blizzard-table.ts'
export { BLIZZARD_CIRCLE_X, BLIZZARD_CIRCLE_Y, getBlizzardDropOffset } from './engine/blizzard-table.ts'
import {
METEOR_FIRE_SUBTILE_OFFSETS,
type MeteorFireSubtileOffset,
} from './engine/meteor-table.ts'
export {
METEOR_FIRE_SUBTILE_OFFSETS,
type MeteorFireSubtileOffset,
} from './engine/meteor-table.ts'
import {
calculateMagicArrowStats,
calculateFireArrowStats,
calculateColdArrowStats,
calculateMultipleShotStats,
calculateExplodingArrowStats,
calculateIceArrowStats,
calculateGuidedArrowStats,
calculateStrafeStats,
calculateImmolationArrowStats,
calculateFreezingArrowStats,
generateMultipleShotAngles,
isMultipleShotCenterArrow,
type MagicArrowStats,
type FireArrowStats,
type ColdArrowStats,
type MultipleShotStats,
type ExplodingArrowStats,
type IceArrowStats,
type GuidedArrowStats,
type StrafeStats,
type ImmolationArrowStats,
type FreezingArrowStats,
} from './skills/amazon-bow.ts'
export {
calculateMagicArrowStats,
calculateFireArrowStats,
calculateColdArrowStats,
calculateMultipleShotStats,
calculateExplodingArrowStats,
calculateIceArrowStats,
calculateGuidedArrowStats,
calculateStrafeStats,
calculateImmolationArrowStats,
calculateFreezingArrowStats,
generateMultipleShotAngles,
isMultipleShotCenterArrow,
type MagicArrowStats,
type FireArrowStats,
type ColdArrowStats,
type MultipleShotStats,
type ExplodingArrowStats,
type IceArrowStats,
type GuidedArrowStats,
type StrafeStats,
type ImmolationArrowStats,
type FreezingArrowStats,
} from './skills/amazon-bow.ts'
import {
calculateInnerSightStats,
calculateCriticalStrikeStats,
calculateDodgeStats,
calculateSlowMissilesStats,
calculateAvoidStats,
calculatePenetrateStats,
calculateDecoyStats,
calculateEvadeStats,
calculateValkyrieStats,
calculatePierceStats,
type InnerSightStats,
type CriticalStrikeStats,
type DodgeStats,
type SlowMissilesStats,
type AvoidStats,
type PenetrateStats,
type DecoyStats,
type EvadeStats,
type ValkyrieStats,
type ValkyrieEquipmentTier,
type PierceStats,
} from './skills/amazon-passive.ts'
export {
calculateInnerSightStats,
calculateCriticalStrikeStats,
calculateDodgeStats,
calculateSlowMissilesStats,
calculateAvoidStats,
calculatePenetrateStats,
calculateDecoyStats,
calculateEvadeStats,
calculateValkyrieStats,
calculatePierceStats,
type InnerSightStats,
type CriticalStrikeStats,
type DodgeStats,
type SlowMissilesStats,
type AvoidStats,
type PenetrateStats,
type DecoyStats,
type EvadeStats,
type ValkyrieStats,
type ValkyrieEquipmentTier,
} from './skills/amazon-passive.ts'
import {
calculateJabStats,
calculatePowerStrikeStats,
calculatePoisonJavelinStats,
calculateImpaleStats,
calculateLightningBoltStats,
calculateChargedStrikeStats,
calculatePlagueJavelinStats,
calculateFendStats,
calculateLightningStrikeStats,
calculateLightningFuryStats,
type JabStats,
type PowerStrikeStats,
type PoisonJavelinStats,
type ImpaleStats,
type LightningBoltStats,
type ChargedStrikeStats,
type PlagueJavelinStats,
type FendStats,
type LightningStrikeStats,
type LightningFuryStats,
type PowerStrikeSynergies,
type PoisonJavelinSynergies,
type LightningBoltSynergies,
type ChargedStrikeSynergies,
type PlagueJavelinSynergies,
type LightningStrikeSynergies,
type LightningFurySynergies,
} from './skills/amazon-javelin-spear.ts'
export {
calculateJabStats,
calculatePowerStrikeStats,
calculatePoisonJavelinStats,
calculateImpaleStats,
calculateLightningBoltStats,
calculateChargedStrikeStats,
calculatePlagueJavelinStats,
calculateFendStats,
calculateLightningStrikeStats,
calculateLightningFuryStats,
type JabStats,
type PowerStrikeStats,
type PoisonJavelinStats,
type ImpaleStats,
type LightningBoltStats,
type ChargedStrikeStats,
type PlagueJavelinStats,
type FendStats,
type LightningStrikeStats,
type LightningFuryStats,
type PowerStrikeSynergies,
type PoisonJavelinSynergies,
type LightningBoltSynergies,
type ChargedStrikeSynergies,
type PlagueJavelinSynergies,
type LightningStrikeSynergies,
type LightningFurySynergies,
} from './skills/amazon-javelin-spear.ts'
/** One castable skill, read from a table row. */
export interface SkillDef {
/** Table id. */
readonly id: string
/** Display name, resolved through the text table when the row names an index. */
readonly name: string
/** Mana spent per cast. */
readonly manaCost: number
/** Ticks between casts. */
readonly cooldownTicks: number
/** Maximum cast distance; also the projectile's lifetime in pixels. */
readonly range: number
/** Whether the skill spawns a projectile (as opposed to striking instantly). */
readonly projectile: boolean
/** Projectile speed in pixels per second. */
readonly speed: number
/** Damage at skill level 1. */
readonly baseMinDamage: number
/** Damage at skill level 1. */
readonly baseMaxDamage: number
/** Damage added per additional skill level. */
readonly damagePerLevel: number
/** Radius of the instant effect, for non-projectile skills. */
readonly radius: number
/** Cast overlay identifier from Skills.txt castoverlay column (e.g. 'fire_cast_1'). */
readonly castOverlay?: string | undefined
}
/** A projectile in flight. */
export interface Projectile {
/** Skill it came from. */
readonly skillId: string
/** World x. */
readonly x: number
/** World y. */
readonly y: number
/** Velocity x, pixels per tick. */
readonly vx: number
/** Velocity y, pixels per tick. */
readonly vy: number
/** Damage on hit. */
readonly damage: number
/** Ticks left before it expires. */
readonly ttl: number
/** Whether the player fired it (projectiles hit the other side). */
readonly fromPlayer: boolean
/** Optional missile type / token (e.g. 'firebolt'). */
readonly missileType?: string | undefined
/** Pierces through enemies without expiring until range or wall hit. */
readonly pierce?: boolean | undefined
/** Set of monster indices already hit by this projectile (for pierce/nextHit). */
readonly hitTargets?: Set<number> | undefined
/** Area of effect splash radius in pixels upon impact. */
readonly aoeRadius?: number | undefined
/** Elemental status effect applied on hit: chill, freeze, poison. */
readonly statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
/** Duration in ticks of the status effect. */
readonly statusDuration?: number | undefined
/** Homing missile: steers towards nearest alive monster. */
readonly homing?: boolean | undefined
/** Archimedean spiral trajectory (e.g. Blessed Hammer). */
readonly spiral?: {
readonly originX: number
readonly originY: number
readonly angle: number
readonly radius: number
} | undefined
/** Random perpendicular trajectory wobble (e.g. Charged Bolt). */
readonly jitter?: boolean | undefined
/** PRNG seed for projectile variance (e.g. Charged Bolt). */
readonly seed?: number | undefined
/** Current index in the discrete orthogonal jitter table (0-31). */
readonly jitterIndex?: number | undefined
/** Target restriction: only hits undead monsters. */
readonly isUndeadOnly?: boolean | undefined
/** Sub-missile emission index along the 64-point discrete circle table (e.g. Frozen Orb). */
readonly subMissileIdx?: number | undefined
/** Affine curl damping trajectory in last 6 frames (e.g. Frozen Orb Nova). */
readonly isNovaCurl?: boolean | undefined
/** Stationary turret emitter (e.g. Hydra, Skill 62). */
readonly isTurret?: boolean | undefined
/** Fire cadence interval in ticks for turret emitters (e.g. 25). */
readonly fireCadenceTicks?: number | undefined
/** Head index within multi-head cluster (e.g. 0, 1, 2). */
readonly headIndex?: number | undefined
/** Pre-configured target world coordinate when targets array is empty. */
readonly targetX?: number | undefined
readonly targetY?: number | undefined
/** Missile type spawned by turret (defaults to 'hydra'). */
readonly spawnMissileType?: string | undefined
/** Number of elapsed ticks this projectile has lived. */
readonly ageTicks?: number | undefined
/** Altitude above ground in pixels for falling missiles (e.g. Blizzard shards). */
readonly altitude?: number | undefined
/** Skill level of the source skill (for dynamic duration/damage scaling). */
readonly slvl?: number | undefined
/** Residual ground fire duration in ticks. */
readonly fireDuration?: number | undefined
/** Residual ground fire damage per tick. */
readonly residualDamage?: number | undefined
/** Authentic 1.13c NextHit flag and NextDelay immunity window in frames. */
readonly nextHit?: boolean | number | undefined
readonly nextDelay?: number | undefined
/** Number of jumps remaining for chaining projectiles (e.g. Chain Lightning). */
readonly chainRemaining?: number | undefined
/** Previous target index hit by this chain projectile to avoid immediately retargeting it. */
readonly lastHitTargetIndex?: number | undefined
/** Previous position before last tick step (for client-side trail generation). */
readonly prevX?: number | undefined
readonly prevY?: number | undefined
}
/**
* In Diablo II's 2:1 isometric projection (orthogonal diamond grid: 80x40 cells, 16x8 sub-tiles),
* any horizontal circular curve on the map ground plane projects into scene-pixel coordinates
* as an ellipse with a 2:1 aspect ratio (vertical scale / horizontal scale = 8 / 16 = 0.5).
*
* NOTE: The calculated coordinates (x, y) are physical MAP coordinates on the level plane,
* NOT the rendered sprite icon offsets. Rendered icons are pre-rendered 2.5D DCC billboards
* positioned via sprite frame anchors, while map coordinates govern physical trajectory and collision.
*/
export const ISO_GROUND_ASPECT_RATIO = 0.5
/** What a cast produced. */
export type CastResult =
| { readonly kind: 'mana'; readonly cost: number }
| { readonly kind: 'cooldown'; readonly ticksLeft: number }
| { readonly kind: 'projectile'; readonly projectile: Projectile }
| { readonly kind: 'instant'; readonly radius: number; readonly damage: number }
/** A caster's position and facing. */
export interface Caster {
/** World x. */
readonly x: number
/** World y. */
readonly y: number
/** Facing index (0 = south, turning west). */
readonly facing: number
}
/** Direction vectors, in the same order as sprite groups (0 = south, turning west). */
const FACING_VECTORS: readonly (readonly [number, number])[] = [
[0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1], [1, 0], [1, 1],
]
/**
* Data extracted from Diablo II 1.13c Missiles.txt for projectile physics.
*/
export interface MissileTxtData {
readonly name: string
readonly id: number
readonly vel: number
readonly maxVel: number
readonly range: number
readonly levRange: number
readonly celFile: string
readonly animLen: number
readonly animSpeed: number
readonly loopAnim: number
readonly numDirections: number
readonly explosionMissile: string
readonly light: number
readonly red: number
readonly green: number
readonly blue: number
/** Calculated speed in pixels per second: vel * 25 */
readonly speedPxPerSec: number
/** Calculated flight distance in pixels: vel * range */
readonly distancePx: number
readonly nextHit?: number | boolean | undefined
readonly nextDelay?: number | undefined
readonly trans?: number | undefined
readonly subLoop?: number | undefined
readonly subStop?: number | undefined
}
/** Canonical 1.13c Ground Truth Missiles.txt defaults. */
export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> = Object.freeze({
lightninghit: {
name: 'lightninghit',
id: 99,
vel: 0,
maxVel: 0,
range: 12,
levRange: 0,
celFile: 'LightningStrike',
animLen: 8,
animSpeed: 16,
loopAnim: 1,
numDirections: 32,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
trans: 1,
speedPxPerSec: 0,
distancePx: 0,
subLoop: 1,
subStop: 7,
},
lightningbolt: {
name: 'lightningbolt',
id: 98,
vel: 30,
maxVel: 30,
range: 25,
levRange: 0,
celFile: 'LightningStrike',
animLen: 16,
animSpeed: 16,
loopAnim: 2,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
trans: 1,
speedPxPerSec: 750,
distancePx: 750,
},
lightning: {
name: 'lightning',
id: 98,
vel: 30,
maxVel: 30,
range: 25,
levRange: 0,
celFile: 'LightningStrike',
animLen: 16,
animSpeed: 16,
loopAnim: 2,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
trans: 1,
speedPxPerSec: 750,
distancePx: 750,
},
chainlightning: {
name: 'chainlightning',
id: 93,
vel: 30,
maxVel: 30,
range: 25,
levRange: 0,
celFile: 'LightningStrike',
animLen: 16,
animSpeed: 16,
loopAnim: 1,
numDirections: 32,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
nextHit: 1,
nextDelay: 4,
trans: 1,
speedPxPerSec: 750,
distancePx: 750,
},
thunderstorm1: {
name: 'thunderstorm1',
id: 166,
vel: 2,
maxVel: 2,
range: 9,
levRange: 0,
celFile: 'LightningboltBig',
animLen: 8,
animSpeed: 8,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
trans: 1,
speedPxPerSec: 50,
distancePx: 18,
},
thunderstorm: {
name: 'thunderstorm',
id: 166,
vel: 2,
maxVel: 2,
range: 9,
levRange: 0,
celFile: 'LightningboltBig',
animLen: 8,
animSpeed: 8,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
trans: 1,
speedPxPerSec: 50,
distancePx: 18,
},
nova: {
name: 'nova',
id: 90,
vel: 24,
maxVel: 24,
range: 13,
levRange: 0,
celFile: 'ElectricNova',
animLen: 13,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
nextHit: 1,
nextDelay: 4,
trans: 1,
speedPxPerSec: 600,
distancePx: 312,
},
electricnova: {
name: 'electricnova',
id: 90,
vel: 24,
maxVel: 24,
range: 13,
levRange: 0,
celFile: 'ElectricNova',
animLen: 13,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
nextHit: 1,
nextDelay: 4,
trans: 1,
speedPxPerSec: 600,
distancePx: 312,
},
frostnova: {
name: 'frostnova',
id: 119,
vel: 24,
maxVel: 24,
range: 14,
levRange: 0,
celFile: 'FrostNova',
animLen: 14,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: '',
light: 0,
red: 81,
green: 81,
blue: 255,
nextHit: 1,
nextDelay: 4,
trans: 1,
speedPxPerSec: 600,
distancePx: 336,
},
firebolt: {
name: 'firebolt',
id: 58,
vel: 20,
maxVel: 20,
range: 50,
levRange: 0,
celFile: 'Firebolt',
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'fireexplode',
light: 7,
red: 255,
green: 178,
blue: 64,
speedPxPerSec: 500,
trans: 1,
distancePx: 1000,
},
fireball: {
name: 'fireball',
id: 62,
vel: 20,
maxVel: 20,
range: 50,
levRange: 0,
celFile: 'Fireball',
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'explodingarrowexp',
light: 7,
red: 255,
green: 178,
blue: 64,
speedPxPerSec: 500,
trans: 1,
distancePx: 1000,
},
fireexplode: {
name: 'fireexplode',
id: 29,
vel: 0,
maxVel: 0,
range: 12,
levRange: 0,
celFile: 'FireArrowExplode2',
animLen: 12,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 13,
red: 255,
green: 178,
blue: 64,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
explodingarrowexp: {
name: 'explodingarrowexp',
id: 42,
vel: 0,
maxVel: 0,
range: 16,
levRange: 0,
celFile: 'ExpArrowExplode',
animLen: 16,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 255,
green: 178,
blue: 64,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
icebolt: {
name: 'icebolt',
id: 59,
vel: 12,
maxVel: 12,
range: 50,
levRange: 0,
celFile: 'Icebolt',
animLen: 6,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'iceexplode',
light: 7,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 300,
trans: 1,
distancePx: 600,
},
iceblast: {
name: 'iceblast',
id: 91,
vel: 12,
maxVel: 12,
range: 50,
levRange: 0,
celFile: 'IceBlast',
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'freezeexplode',
light: 7,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 300,
trans: 1,
distancePx: 600,
},
glacialspike: {
name: 'glacialspike',
id: 96,
vel: 16,
maxVel: 16,
range: 40,
levRange: 0,
celFile: 'GlacialSpike',
animLen: 6,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'freezeexplode',
light: 5,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 400,
trans: 1,
distancePx: 640,
},
iceexplode: {
name: 'iceexplode',
id: 30,
vel: 0,
maxVel: 0,
range: 16,
levRange: 0,
celFile: 'IceArrowExplode',
animLen: 16,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 11,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
freezeexplode: {
name: 'freezeexplode',
id: 88,
vel: 0,
maxVel: 0,
range: 15,
levRange: 0,
celFile: 'FreezeExplodeCenter',
animLen: 15,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 11,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
freezingarrowexp1: {
name: 'freezingarrowexp1',
id: 88,
vel: 0,
maxVel: 0,
range: 15,
levRange: 0,
celFile: 'FreezeExplodeCenter',
animLen: 15,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 11,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
glacialspikeejecta: {
name: 'glacialspikeejecta',
id: 89,
vel: 0,
maxVel: 0,
range: 15,
levRange: 0,
celFile: 'FreezeExplodeEjecta',
animLen: 15,
animSpeed: 16,
loopAnim: 0,
numDirections: 8,
explosionMissile: '',
light: 11,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
frozenorb: {
name: 'frozenorb',
id: 260,
vel: 10,
maxVel: 10,
range: 30,
levRange: 0,
celFile: 'IceOrb',
animLen: 16,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'frozenorbexplode',
light: 6,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 250,
trans: 1,
distancePx: 300,
},
frozenorbbolt: {
name: 'frozenorbbolt',
id: 261,
vel: 18,
maxVel: 18,
range: 25,
levRange: 0,
celFile: 'IceBolt',
animLen: 6,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'iceexplode',
light: 4,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 450,
trans: 1,
distancePx: 450,
},
frozenorbnova: {
name: 'frozenorbnova',
id: 262,
vel: 24,
maxVel: 24,
range: 25,
levRange: 0,
celFile: 'IceBolt',
animLen: 6,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'iceexplode',
light: 4,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 600,
trans: 1,
distancePx: 600,
},
frozenorbexplode: {
name: 'frozenorbexplode',
id: 263,
vel: 0,
maxVel: 0,
range: 16,
levRange: 0,
celFile: 'IceOrbExplode',
animLen: 16,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 6,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzardcenter: {
name: 'blizzardcenter',
id: 158,
vel: 0,
maxVel: 0,
range: 100,
levRange: 0,
celFile: '',
animLen: 2,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzard1: {
name: 'blizzard1',
id: 159,
vel: 0,
maxVel: 0,
range: 9,
levRange: 0,
celFile: 'icestormfallvar01',
animLen: 6,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: 'blizzardexplode1',
light: 4,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzard2: {
name: 'blizzard2',
id: 160,
vel: 0,
maxVel: 0,
range: 9,
levRange: 0,
celFile: 'icestormfallvar02',
animLen: 6,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: 'blizzardexplode2',
light: 4,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzard3: {
name: 'blizzard3',
id: 161,
vel: 0,
maxVel: 0,
range: 9,
levRange: 0,
celFile: 'Blizzard',
animLen: 8,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: 'blizzardexplode3',
light: 4,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzard4: {
name: 'blizzard4',
id: 162,
vel: 0,
maxVel: 0,
range: 9,
levRange: 0,
celFile: 'Blizzard',
animLen: 8,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: 'blizzardexplode1',
light: 4,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzardexplode1: {
name: 'blizzardexplode1',
id: 163,
vel: 0,
maxVel: 0,
range: 6,
levRange: 0,
celFile: 'icestormimpactvar01',
animLen: 6,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 6,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzardexplode2: {
name: 'blizzardexplode2',
id: 164,
vel: 0,
maxVel: 0,
range: 6,
levRange: 0,
celFile: 'icestormimpactvar02',
animLen: 6,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 6,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzardexplode3: {
name: 'blizzardexplode3',
id: 165,
vel: 0,
maxVel: 0,
range: 6,
levRange: 0,
celFile: 'icestormimpactvar02',
animLen: 6,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 6,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
hydra: {
name: 'hydra',
id: 247,
vel: 16,
maxVel: 16,
range: 30,
levRange: 0,
celFile: 'Firebolt',
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'fireexplode',
light: 5,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 400,
trans: 1,
distancePx: 480,
},
hydrafire: {
name: 'hydrafire',
id: 247,
vel: 16,
maxVel: 16,
range: 30,
levRange: 0,
celFile: 'Firebolt',
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'fireexplode',
light: 5,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 400,
trans: 1,
distancePx: 480,
},
firewallmaker: {
name: 'firewallmaker',
id: 82,
vel: 0,
maxVel: 0,
range: 1,
levRange: 0,
celFile: '',
animLen: 1,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 0,
green: 0,
blue: 0,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
firewall: {
name: 'firewall',
id: 69,
vel: 0,
maxVel: 0,
range: 90,
levRange: 0,
celFile: 'groundFireBig',
animLen: 37,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: '',
light: 5,
red: 255,
green: 178,
blue: 64,
trans: 1,
speedPxPerSec: 0,
distancePx: 0,
},
groundfirebig: {
name: 'groundfirebig',
id: 83,
vel: 0,
maxVel: 0,
range: 90,
levRange: 0,
celFile: 'groundFireBig',
animLen: 15,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: '',
light: 10,
red: 255,
green: 178,
blue: 64,
trans: 1,
speedPxPerSec: 0,
distancePx: 0,
},
blaze: {
name: 'blaze',
id: 80,
vel: 0,
maxVel: 0,
range: 90,
levRange: 25,
celFile: 'Blaze',
animLen: 14,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: '',
light: 8,
red: 255,
green: 178,
blue: 64,
trans: 1,
speedPxPerSec: 0,
distancePx: 0,
},
infernoflame1: {
name: 'infernoflame1',
id: 72,
vel: 12,
maxVel: 12,
range: 16,
levRange: 0,
celFile: 'InfernoFlame1',
animLen: 8,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 6,
red: 255,
green: 178,
blue: 64,
trans: 1,
speedPxPerSec: 300,
distancePx: 192,
},
infernoflame2: {
name: 'infernoflame2',
id: 73,
vel: 12,
maxVel: 12,
range: 16,
levRange: 0,
celFile: 'InfernoFlame2',
animLen: 8,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 6,
red: 255,
green: 178,
blue: 64,
trans: 1,
speedPxPerSec: 300,
distancePx: 192,
},
meteor: {
name: 'meteor',
id: 100,
vel: 0,
maxVel: 0,
range: 60,
levRange: 0,
celFile: 'Meteor',
animLen: 12,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: 'meteorexplode',
light: 0,
red: 0,
green: 0,
blue: 0,
speedPxPerSec: 0,
trans: 0,
distancePx: 0,
},
meteorcenter: {
name: 'meteorcenter',
id: 101,
vel: 0,
maxVel: 0,
range: 60,
levRange: 0,
celFile: 'MeteorTargetIndicator',
animLen: 17,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: 'meteorexplode',
light: 4,
red: 255,
green: 64,
blue: 64,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
meteortail: {
name: 'meteortail',
id: 102,
vel: 0,
maxVel: 0,
range: 60,
levRange: 0,
celFile: 'MeteorTail',
animLen: 12,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 0,
green: 0,
blue: 0,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
meteorexplode: {
name: 'meteorexplode',
id: 103,
vel: 0,
maxVel: 0,
range: 16,
levRange: 0,
celFile: 'ExpArrowExplode',
animLen: 16,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 13,
red: 255,
green: 178,
blue: 64,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
meteorfire: {
name: 'meteorfire',
id: 240,
vel: 0,
maxVel: 0,
range: 30,
levRange: 15,
celFile: 'groundFireMedium',
animLen: 37,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: '',
light: 3,
red: 255,
green: 178,
blue: 64,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
chargedbolt: {
name: 'chargedbolt',
id: 56,
vel: 12,
maxVel: 12,
range: 98,
levRange: 0,
celFile: 'ChargedBolt',
animLen: 10,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: '',
light: 3,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 300,
trans: 1,
distancePx: 1176,
},
teeth: {
name: 'teeth',
id: 114,
vel: 16,
maxVel: 16,
range: 50,
levRange: 0,
celFile: 'teethMissile',
animLen: 30,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: 'teethexplode',
light: 3,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 400,
trans: 1,
distancePx: 800,
},
teethexplode: {
name: 'teethexplode',
id: 204,
vel: 0,
maxVel: 0,
range: 13,
levRange: 0,
celFile: 'teethexplode',
animLen: 13,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
bonespear: {
name: 'bonespear',
id: 192,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'BoneSpear',
animLen: 6,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'teethexplode',
light: 3,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 600,
trans: 1,
distancePx: 960,
},
bonespearexplode: {
name: 'bonespearexplode',
id: 216,
vel: 0,
maxVel: 0,
range: 13,
levRange: 0,
celFile: 'teethexplode',
animLen: 13,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
bonespirit: {
name: 'bonespirit',
id: 193,
vel: 12,
maxVel: 12,
range: 128,
levRange: 0,
celFile: 'BoneSpirit',
animLen: 8,
animSpeed: 16,
loopAnim: 1,
numDirections: 8,
explosionMissile: 'bonespiritexplode',
light: 3,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 300,
trans: 1,
distancePx: 1536,
},
bonespiritexplode: {
name: 'bonespiritexplode',
id: 327,
vel: 0,
maxVel: 0,
range: 13,
levRange: 0,
celFile: 'BoneSpiritExplode',
animLen: 13,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
poisonnova: {
name: 'poisonnova',
id: 118,
vel: 12,
maxVel: 12,
range: 30,
levRange: 0,
celFile: 'poisonNova',
animLen: 30,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 300,
trans: 1,
distancePx: 360,
},
holybolt: {
name: 'holybolt',
id: 55,
vel: 20,
maxVel: 20,
range: 50,
levRange: 0,
celFile: 'HolyBoltMissile',
animLen: 16,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: '',
light: 7,
red: 222,
green: 222,
blue: 255,
speedPxPerSec: 500,
trans: 1,
distancePx: 1000,
},
blessedhammer: {
name: 'blessedhammer',
id: 92,
vel: 18,
maxVel: 30,
range: 120,
levRange: 0,
celFile: 'blessedhammer',
animLen: 6,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: '',
light: 5,
red: 222,
green: 222,
blue: 255,
speedPxPerSec: 450,
trans: 1,
distancePx: 2160,
},
magicarrow: {
name: 'magicarrow',
id: 27,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'SafeArrow',
animLen: 1,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: 'teethexplode',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 600,
trans: 1,
distancePx: 960,
},
firearrow: {
name: 'firearrow',
id: 12,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'FireArrow',
animLen: 8,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: 'fireexplode',
light: 6,
red: 255,
green: 178,
blue: 64,
speedPxPerSec: 600,
trans: 1,
distancePx: 960,
},
icearrow: {
name: 'icearrow',
id: 28,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'IceArrow',
animLen: 8,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: 'iceexplode',
light: 6,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 600,
trans: 1,
distancePx: 960,
},
arrow: {
name: 'arrow',
id: 0,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'Arrow',
animLen: 1,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 600,
trans: 0,
distancePx: 960,
},
multipleshotarrow: {
name: 'multipleshotarrow',
id: 213,
vel: 24,
maxVel: 24,
range: 50,
levRange: 0,
celFile: 'Arrow',
animLen: 1,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 600,
trans: 0,
distancePx: 1200,
},
lightningjavelin: {
name: 'lightningjavelin',
id: 205,
vel: 30,
maxVel: 30,
range: 25,
levRange: 0,
celFile: 'LightningJavelin',
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: '',
light: 6,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 750,
trans: 1,
distancePx: 750,
},
})
/**
* Safely parse an RGB color channel (0..255) with fallback (default 255).
* Preserves authentic 0 values while mapping empty/unspecified cells to fallback.
*/
export function parseColorChannel(raw: string | undefined, fallback = 255): number {
if (raw === undefined) return fallback
const trimmed = raw.trim()
if (trimmed === '') return fallback
const n = Number(trimmed)
return Number.isFinite(n) ? n : fallback
}
/**
* Parses raw Missiles.txt text into a Map of missile records.
*/
export function parseMissilesTxt(content: string): Map<string, MissileTxtData> {
const map = new Map<string, MissileTxtData>()
const lines = content.split(/\r?\n/).filter(line => line.trim().length > 0)
if (lines.length < 2) return map
const headers = lines[0]!.split('\t').map(h => h.trim())
const col = (name: string) => headers.indexOf(name)
const missileIdx = col('Missile')
const idIdx = col('Id')
const velIdx = col('Vel')
const maxVelIdx = col('MaxVel')
const rangeIdx = col('Range')
const levRangeIdx = col('LevRange')
const celFileIdx = col('CelFile')
const animLenIdx = col('AnimLen')
const animSpeedIdx = col('AnimSpeed')
const loopAnimIdx = col('LoopAnim')
const numDirIdx = col('NumDirections')
const explIdx = col('ExplosionMissile')
const lightIdx = col('Light')
const redIdx = col('Red')
const greenIdx = col('Green')
const blueIdx = col('Blue')
const nextHitIdx = col('NextHit')
const nextDelayIdx = col('NextDelay')
const transIdx = col('Trans')
for (let i = 1; i < lines.length; i++) {
const cells = lines[i]!.split('\t')
const name = (cells[missileIdx] ?? '').trim().toLowerCase()
if (!name || name === 'expansion') continue
const vel = Number(cells[velIdx] ?? 0) || 0
const maxVel = Number(cells[maxVelIdx] ?? 0) || vel
const range = Number(cells[rangeIdx] ?? 0) || 0
const levRange = Number(cells[levRangeIdx] ?? 0) || 0
const celFile = (cells[celFileIdx] ?? '').trim()
const animLen = Number(cells[animLenIdx] ?? 0) || 0
const animSpeed = Number(cells[animSpeedIdx] ?? 0) || 0
const loopAnim = Number(cells[loopAnimIdx] ?? 0) || 0
const numDirections = Number(cells[numDirIdx] ?? 0) || 1
const explosionMissile = (cells[explIdx] ?? '').trim()
const light = Number(cells[lightIdx] ?? 0) || 0
const red = parseColorChannel(cells[redIdx])
const green = parseColorChannel(cells[greenIdx])
const blue = parseColorChannel(cells[blueIdx])
const nextHit = Number(cells[nextHitIdx] ?? 0) || 0
const nextDelay = Number(cells[nextDelayIdx] ?? 0) || 0
const trans = Number(cells[transIdx] ?? 0) || 0
const rec: MissileTxtData = {
name,
id: Number(cells[idIdx] ?? 0) || 0,
vel,
maxVel,
range,
levRange,
celFile,
animLen,
animSpeed,
loopAnim,
numDirections,
explosionMissile,
light,
red,
green,
blue,
nextHit,
nextDelay,
trans,
speedPxPerSec: vel * 25,
distancePx: vel * range,
}
map.set(name, rec)
if (name === 'nova') {
map.set('electricnova', { ...rec, name: 'electricnova' })
}
}
return map
}
/**
* Extracts missile speed and flight distance from a Missiles.txt table or parsed records.
*/
export function getMissileTxtData(
missileName: string,
missilesSource?:
| Map<string, MissileTxtData>
| { rows: readonly Readonly<Record<string, string>>[] }
| { header: readonly string[]; rows: readonly (readonly string[])[] }
| string
| undefined,
): MissileTxtData {
const key = missileName.trim().toLowerCase()
if (!missilesSource) {
return CANONICAL_113C_MISSILES[key] ?? {
name: key,
id: 0,
vel: 20,
maxVel: 20,
range: 50,
levRange: 0,
celFile: key,
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'fireexplode',
light: 7,
red: 255,
green: 178,
blue: 64,
trans: 0,
speedPxPerSec: 500,
distancePx: 1000,
}
}
if (typeof missilesSource === 'string') {
const parsed = parseMissilesTxt(missilesSource)
return parsed.get(key) ?? getMissileTxtData(missileName, undefined)
}
if (missilesSource instanceof Map) {
return missilesSource.get(key) ?? getMissileTxtData(missileName, undefined)
}
// D2Table ({ header, rows })
if ('header' in missilesSource && Array.isArray((missilesSource as any).header)) {
const t = missilesSource as { header: readonly string[]; rows: readonly (readonly string[])[] }
const mIdx = t.header.indexOf('Missile')
if (mIdx !== -1) {
const row = t.rows.find(r => (r[mIdx] ?? '').trim().toLowerCase() === key)
if (row) {
const vel = Number(row[t.header.indexOf('Vel')] ?? 0) || 0
const maxVel = Number(row[t.header.indexOf('MaxVel')] ?? 0) || vel
const range = Number(row[t.header.indexOf('Range')] ?? 0) || 0
const levRange = Number(row[t.header.indexOf('LevRange')] ?? 0) || 0
const celFile = (row[t.header.indexOf('CelFile')] ?? '').trim()
const animLen = Number(row[t.header.indexOf('AnimLen')] ?? 0) || 0
const animSpeed = Number(row[t.header.indexOf('AnimSpeed')] ?? 0) || 0
const loopAnim = Number(row[t.header.indexOf('LoopAnim')] ?? 0) || 0
const numDirections = Number(row[t.header.indexOf('NumDirections')] ?? 0) || 1
const explosionMissile = (row[t.header.indexOf('ExplosionMissile')] ?? '').trim()
const light = Number(row[t.header.indexOf('Light')] ?? 0) || 0
const red = parseColorChannel(row[t.header.indexOf('Red')])
const green = parseColorChannel(row[t.header.indexOf('Green')])
const blue = parseColorChannel(row[t.header.indexOf('Blue')])
const transColIdx = t.header.indexOf('Trans')
const trans = transColIdx !== -1 ? Number(row[transColIdx] ?? 0) || 0 : 0
return {
name: key,
id: Number(row[t.header.indexOf('Id')] ?? 0) || 0,
vel,
maxVel,
range,
levRange,
celFile,
animLen,
animSpeed,
loopAnim,
numDirections,
explosionMissile,
light,
red,
green,
blue,
trans,
speedPxPerSec: vel * 25,
distancePx: vel * range,
}
}
}
}
// DataTable ({ rows: Record<string, string>[] })
if ('rows' in missilesSource && !('header' in missilesSource)) {
const tableRows = missilesSource.rows as readonly Readonly<Record<string, string>>[]
const row = tableRows.find(
r => (r['Missile'] ?? r['missile'] ?? '').trim().toLowerCase() === key,
)
if (row) {
const vel = Number(row['Vel'] ?? row['vel'] ?? 0) || 0
const maxVel = Number(row['MaxVel'] ?? row['maxvel'] ?? 0) || vel
const range = Number(row['Range'] ?? row['range'] ?? 0) || 0
const levRange = Number(row['LevRange'] ?? row['levrange'] ?? 0) || 0
const celFile = (row['CelFile'] ?? row['celfile'] ?? '').trim()
const animLen = Number(row['AnimLen'] ?? row['animlen'] ?? 0) || 0
const animSpeed = Number(row['AnimSpeed'] ?? row['animspeed'] ?? 0) || 0
const loopAnim = Number(row['LoopAnim'] ?? row['loopanim'] ?? 0) || 0
const numDirections = Number(row['NumDirections'] ?? row['numdirections'] ?? 0) || 1
const explosionMissile = (row['ExplosionMissile'] ?? row['explosionmissile'] ?? '').trim()
const light = Number(row['Light'] ?? row['light'] ?? 0) || 0
const red = parseColorChannel(row['Red'] ?? row['red'])
const green = parseColorChannel(row['Green'] ?? row['green'])
const blue = parseColorChannel(row['Blue'] ?? row['blue'])
const trans = Number(row['Trans'] ?? row['trans'] ?? 0) || 0
return {
name: key,
id: Number(row['Id'] ?? row['id'] ?? 0) || 0,
vel,
maxVel,
range,
levRange,
celFile,
animLen,
animSpeed,
loopAnim,
numDirections,
explosionMissile,
light,
red,
green,
blue,
trans,
speedPxPerSec: vel * 25,
distancePx: vel * range,
}
}
}
return getMissileTxtData(missileName, undefined)
}
/**
* Data extracted from Diablo II 1.13c Overlay.txt for visual overlay effects.
*/
export interface OverlayTxtData {
readonly overlay: string
readonly filename: string
readonly frames: number
readonly animRate: number
readonly trans: number
readonly preDraw: boolean
readonly initRadius: number
readonly radius: number
readonly red: number
readonly green: number
readonly blue: number
readonly numDirections?: number
readonly xOffset?: number
readonly yOffset?: number
}
/** Canonical 1.13c Ground Truth Overlay.txt defaults for elemental sorceress spell overlays. */
export const CANONICAL_113C_OVERLAYS: Readonly<Record<string, OverlayTxtData>> = Object.freeze({
fire_cast_1: {
overlay: 'fire_cast_1',
filename: 'FireCast_for_Sorceress',
frames: 14,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 255,
green: 178,
blue: 64,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
firecast_for_sorceress: {
overlay: 'fire_cast_1',
filename: 'FireCast_for_Sorceress',
frames: 14,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 255,
green: 178,
blue: 64,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
fire_cast_2: {
overlay: 'fire_cast_2',
filename: 'FireCast2',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 255,
green: 178,
blue: 64,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
firecast2: {
overlay: 'fire_cast_2',
filename: 'FireCast2',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 255,
green: 178,
blue: 64,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
ice_cast_1: {
overlay: 'ice_cast_1',
filename: 'IceCastNew01',
frames: 15,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 81,
green: 81,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
icecastnew01: {
overlay: 'ice_cast_1',
filename: 'IceCastNew01',
frames: 15,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 81,
green: 81,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
ice_cast_2: {
overlay: 'ice_cast_2',
filename: 'IceCastNew02',
frames: 15,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 81,
green: 81,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
icecastnew02: {
overlay: 'ice_cast_2',
filename: 'IceCastNew02',
frames: 15,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 81,
green: 81,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
ice_cast_3: {
overlay: 'ice_cast_3',
filename: 'IceCastNew03',
// Note: Overlay.txt lists 15 frames, but IceCastNew03.dcc contains 16 full frames.
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 81,
green: 81,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
icecastnew03: {
overlay: 'ice_cast_3',
filename: 'IceCastNew03',
// Note: Overlay.txt lists 15 frames, but IceCastNew03.dcc contains 16 full frames.
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 81,
green: 81,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
light_cast_1: {
overlay: 'light_cast_1',
filename: 'LightningCast',
frames: 10,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
lightningcast: {
overlay: 'light_cast_1',
filename: 'LightningCast',
frames: 10,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
light_cast_2: {
overlay: 'light_cast_2',
filename: 'LightningCastRunesFront',
frames: 10,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
lightningcastrunesfront: {
overlay: 'light_cast_2',
filename: 'LightningCastRunesFront',
frames: 10,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
teleport: {
overlay: 'teleport',
filename: 'Teleport',
frames: 18,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 5,
red: 255,
green: 255,
blue: 200,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// Authentic 1.13c Sorceress State / Buff Overlays
frozenarmor: {
overlay: 'frozenarmor',
filename: 'FrozenArmor',
frames: 24,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 13,
red: 200,
green: 200,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
shiverarmor: {
overlay: 'shiverarmor',
filename: 'FrozenArmor',
frames: 24,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 13,
red: 200,
green: 200,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
chillingarmor: {
overlay: 'chillingarmor',
filename: 'FrozenArmor',
frames: 24,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 13,
red: 200,
green: 200,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
chillarmor: {
overlay: 'chillingarmor',
filename: 'FrozenArmor',
frames: 24,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 13,
red: 200,
green: 200,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
energyshield: {
overlay: 'energyshield',
filename: 'ManaShieldBall',
frames: 8,
animRate: 8,
trans: 5,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
enchant: {
overlay: 'enchant',
filename: 'FireEnchant',
frames: 17,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 255,
green: 178,
blue: 64,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
thunderstormback: {
overlay: 'thunderstormback',
filename: 'ThunderstormCast_operate',
frames: 19,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 8,
radius: 8,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// Authentic 1.13c Paladin Aura Overlays (All 33 targets: 13 pairs front/back + 7 full rings)
// 1. Might (#98)
might_front: {
overlay: 'might_front',
filename: 'AuraMightFront',
frames: 15,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_might_front: {
overlay: 'might_front',
filename: 'AuraMightFront',
frames: 15,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
might: {
overlay: 'might_front',
filename: 'AuraMightFront',
frames: 15,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
might_back: {
overlay: 'might_back',
filename: 'AuraMightBack',
frames: 15,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_might_back: {
overlay: 'might_back',
filename: 'AuraMightBack',
frames: 15,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 2. Prayer (#99)
prayer_front: {
overlay: 'prayer_front',
filename: 'AuraPrayerFront',
frames: 16,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_prayer_front: {
overlay: 'prayer_front',
filename: 'AuraPrayerFront',
frames: 16,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
prayer: {
overlay: 'prayer_front',
filename: 'AuraPrayerFront',
frames: 16,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
prayer_back: {
overlay: 'prayer_back',
filename: 'AuraPrayerBack',
frames: 16,
animRate: 8,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_prayer_back: {
overlay: 'prayer_back',
filename: 'AuraPrayerBack',
frames: 16,
animRate: 8,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 3. Resist Fire (#100) - Full Ring
resist_fire: {
overlay: 'resist_fire',
filename: 'AuraResistFireFront',
frames: 20,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 6,
radius: 6,
red: 255,
green: 193,
blue: 103,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_resistfire: {
overlay: 'resist_fire',
filename: 'AuraResistFireFront',
frames: 20,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 6,
radius: 6,
red: 255,
green: 193,
blue: 103,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 4. Holy Fire (#102)
holy_fire_front: {
overlay: 'holy_fire_front',
filename: 'AuraHolyFireFront',
frames: 19,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_holyfire_front: {
overlay: 'holy_fire_front',
filename: 'AuraHolyFireFront',
frames: 19,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
holy_fire: {
overlay: 'holy_fire_front',
filename: 'AuraHolyFireFront',
frames: 19,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
holyfire: {
overlay: 'holy_fire_front',
filename: 'AuraHolyFireFront',
frames: 19,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
holy_fire_back: {
overlay: 'holy_fire_back',
filename: 'AuraHolyFireBack',
frames: 19,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_holyfire_back: {
overlay: 'holy_fire_back',
filename: 'AuraHolyFireBack',
frames: 19,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 5. Thorns (#103)
thorns_front: {
overlay: 'thorns_front',
filename: 'AuraThornsFront',
frames: 18,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_thorns_front: {
overlay: 'thorns_front',
filename: 'AuraThornsFront',
frames: 18,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
thorns: {
overlay: 'thorns_front',
filename: 'AuraThornsFront',
frames: 18,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
thorns_back: {
overlay: 'thorns_back',
filename: 'AuraThornsBack',
frames: 18,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_thorns_back: {
overlay: 'thorns_back',
filename: 'AuraThornsBack',
frames: 18,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 6. Defiance (#104)
defiance_front: {
overlay: 'defiance_front',
filename: 'AuraDefianceFront',
frames: 15,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_defiance_front: {
overlay: 'defiance_front',
filename: 'AuraDefianceFront',
frames: 15,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
defiance: {
overlay: 'defiance_front',
filename: 'AuraDefianceFront',
frames: 15,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
defiance_back: {
overlay: 'defiance_back',
filename: 'AuraDefianceBack',
frames: 15,
animRate: 8,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_defiance_back: {
overlay: 'defiance_back',
filename: 'AuraDefianceBack',
frames: 15,
animRate: 8,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 7. Resist Cold (#105) - Full Ring
resist_cold: {
overlay: 'resist_cold',
filename: 'AuraResistColdBack',
frames: 20,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 6,
radius: 6,
red: 210,
green: 210,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_resistcold: {
overlay: 'resist_cold',
filename: 'AuraResistColdBack',
frames: 20,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 6,
radius: 6,
red: 210,
green: 210,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 8. Blessed Aim (#108)
blessed_aim_front: {
overlay: 'blessed_aim_front',
filename: 'BlessedAim_front',
frames: 29,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
blessedaimfront: {
overlay: 'blessed_aim_front',
filename: 'BlessedAim_front',
frames: 29,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
blessed_aim: {
overlay: 'blessed_aim_front',
filename: 'BlessedAim_front',
frames: 29,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
blessedaim: {
overlay: 'blessed_aim_front',
filename: 'BlessedAim_front',
frames: 29,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
blessed_aim_back: {
overlay: 'blessed_aim_back',
filename: 'BlessedAim_back',
frames: 29,
animRate: 8,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
blessedaimback: {
overlay: 'blessed_aim_back',
filename: 'BlessedAim_back',
frames: 29,
animRate: 8,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 9. Cleansing (#109)
cleansing_front: {
overlay: 'cleansing_front',
filename: 'Cleansing_front',
frames: 16,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
cleansingfront: {
overlay: 'cleansing_front',
filename: 'Cleansing_front',
frames: 16,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
cleansing: {
overlay: 'cleansing_front',
filename: 'Cleansing_front',
frames: 16,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
cleansing_back: {
overlay: 'cleansing_back',
filename: 'Cleansing_back',
frames: 16,
animRate: 8,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
cleansingback: {
overlay: 'cleansing_back',
filename: 'Cleansing_back',
frames: 16,
animRate: 8,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 10. Resist Lightning (#110) - Full Ring
resist_lightning: {
overlay: 'resist_lightning',
filename: 'AuraResistLightningBack',
frames: 20,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 6,
radius: 6,
red: 255,
green: 255,
blue: 200,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_resistlight: {
overlay: 'resist_lightning',
filename: 'AuraResistLightningBack',
frames: 20,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 6,
radius: 6,
red: 255,
green: 255,
blue: 200,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 11. Concentration (#113)
concentration_front: {
overlay: 'concentration_front',
filename: 'Concentration_front',
frames: 30,
animRate: 3,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
concentrationfront: {
overlay: 'concentration_front',
filename: 'Concentration_front',
frames: 30,
animRate: 3,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
concentration: {
overlay: 'concentration_front',
filename: 'Concentration_front',
frames: 30,
animRate: 3,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
concentration_back: {
overlay: 'concentration_back',
filename: 'Concentration_back',
frames: 30,
animRate: 3,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
concentrationback: {
overlay: 'concentration_back',
filename: 'Concentration_back',
frames: 30,
animRate: 3,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 12. Holy Freeze (#114) - Full Ring
holy_freeze: {
overlay: 'holyfreeze',
filename: 'HolyFreeze',
frames: 15,
animRate: 8,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
holyfreeze: {
overlay: 'holyfreeze',
filename: 'HolyFreeze',
frames: 15,
animRate: 8,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 13. Vigor (#115)
vigor_front: {
overlay: 'vigor_front',
filename: 'Stamina_front',
frames: 16,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
staminafront: {
overlay: 'vigor_front',
filename: 'Stamina_front',
frames: 16,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
vigor: {
overlay: 'vigor_front',
filename: 'Stamina_front',
frames: 16,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
stamina: {
overlay: 'vigor_front',
filename: 'Stamina_front',
frames: 16,
animRate: 8,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
vigor_back: {
overlay: 'vigor_back',
filename: 'Stamina_back',
frames: 16,
animRate: 8,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
staminaback: {
overlay: 'vigor_back',
filename: 'Stamina_back',
frames: 16,
animRate: 8,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 14. Holy Shock (#118)
holy_shock_front: {
overlay: 'holy_shock_front',
filename: 'HolyShock_front',
frames: 14,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
holyshockfront: {
overlay: 'holy_shock_front',
filename: 'HolyShock_front',
frames: 14,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
holy_shock: {
overlay: 'holy_shock_front',
filename: 'HolyShock_front',
frames: 14,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
holyshock: {
overlay: 'holy_shock_front',
filename: 'HolyShock_front',
frames: 14,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
holy_shock_back: {
overlay: 'holy_shock_back',
filename: 'HolyShock_back',
frames: 14,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
holyshockback: {
overlay: 'holy_shock_back',
filename: 'HolyShock_back',
frames: 14,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 15. Sanctuary (#119)
sanctuary_front: {
overlay: 'sanctuary_front',
filename: 'SanctuaryAura_Front',
frames: 12,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
sanctuaryfront: {
overlay: 'sanctuary_front',
filename: 'SanctuaryAura_Front',
frames: 12,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
sanctuary: {
overlay: 'sanctuary_front',
filename: 'SanctuaryAura_Front',
frames: 12,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
sanctuary_back: {
overlay: 'sanctuary_back',
filename: 'SanctuaryAura_Back',
frames: 12,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
sanctuaryback: {
overlay: 'sanctuary_back',
filename: 'SanctuaryAura_Back',
frames: 12,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 16. Meditation (#120) - Full Ring
meditation: {
overlay: 'meditation',
filename: 'Meditation',
frames: 20,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
meditationfront: {
overlay: 'meditation',
filename: 'Meditation',
frames: 20,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 17. Fanaticism (#122)
fanaticism_front: {
overlay: 'fanaticism_front',
filename: 'Fanatacism_Front',
frames: 30,
animRate: 6,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
fanaticismfront: {
overlay: 'fanaticism_front',
filename: 'Fanatacism_Front',
frames: 30,
animRate: 6,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
fanaticism_back: {
overlay: 'fanaticism_back',
filename: 'Fanatacism_Back',
frames: 30,
animRate: 6,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
fanaticismback: {
overlay: 'fanaticism_back',
filename: 'Fanatacism_Back',
frames: 30,
animRate: 6,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
fanaticism: {
overlay: 'fanaticism_back',
filename: 'Fanatacism_Back',
frames: 30,
animRate: 6,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_fanatic: {
overlay: 'fanaticism_back',
filename: 'Fanatacism_Back',
frames: 30,
animRate: 6,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 18. Conviction (#123) - Full Ring
conviction: {
overlay: 'convictionback',
filename: 'Conviction',
frames: 21,
animRate: 6,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
convictionback: {
overlay: 'convictionback',
filename: 'Conviction',
frames: 21,
animRate: 6,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 19. Redemption (#124) - Full Ring
redemption: {
overlay: 'redemptionback',
filename: 'Redemption',
frames: 12,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
redemptionback: {
overlay: 'redemptionback',
filename: 'Redemption',
frames: 12,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
// 20. Salvation (#125)
salvation_front: {
overlay: 'salvation_front',
filename: 'AuraResistAllFront',
frames: 20,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 6,
radius: 6,
red: 211,
green: 148,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_resistall_front: {
overlay: 'salvation_front',
filename: 'AuraResistAllFront',
frames: 20,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 6,
radius: 6,
red: 211,
green: 148,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
salvation: {
overlay: 'salvation_front',
filename: 'AuraResistAllFront',
frames: 20,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 6,
radius: 6,
red: 211,
green: 148,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
salvation_back: {
overlay: 'salvation_back',
filename: 'AuraResistAllBack',
frames: 20,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 6,
radius: 6,
red: 211,
green: 148,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
aura_resistall_back: {
overlay: 'salvation_back',
filename: 'AuraResistAllBack',
frames: 20,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 6,
radius: 6,
red: 211,
green: 148,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
cursecast: {
overlay: 'cursecast',
filename: 'CurseCast',
frames: 25,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 9,
red: 255,
green: 128,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
curseamplifydamage: {
overlay: 'curseamplifydamage',
filename: 'CurseAmplifyDamageEffect',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
cursedimvision: {
overlay: 'cursedimvision',
filename: 'CurseDimVisionEffect',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
curseweaken: {
overlay: 'curseweaken',
filename: 'CurseWeakenEffect',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
curseironmaiden: {
overlay: 'curseironmaiden',
filename: 'CurseIronMaidenEffect',
frames: 19,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
curseterror: {
overlay: 'curseterror',
filename: 'CurseTerrorEffect',
frames: 14,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
curseconfuse: {
overlay: 'curseconfuse',
filename: 'CurseConfuseEffect',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
cursereversevampire: {
overlay: 'cursereversevampire',
filename: 'CurseReverseVampireEffect',
frames: 19,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
steallife: {
overlay: 'cursereversevampire',
filename: 'CurseReverseVampireEffect',
frames: 19,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
curseattract: {
overlay: 'curseattract',
filename: 'CurseAttractHarmEffect',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
cursedecrepify: {
overlay: 'cursedecrepify',
filename: 'CurseDecrepifyEffect',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
curselowerresist: {
overlay: 'curselowerresist',
filename: 'CurseLowerResistEffect',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
})
/**
* Parses raw Overlay.txt text into a Map of overlay records.
*/
export function parseOverlayTxt(content: string): Map<string, OverlayTxtData> {
const map = new Map<string, OverlayTxtData>()
const lines = content.split(/\r?\n/).filter(line => line.trim().length > 0)
if (lines.length < 2) return map
const headers = lines[0]!.split('\t').map(h => h.trim())
const col = (name: string) => headers.findIndex(h => h.toLowerCase() === name.toLowerCase())
const overlayIdx = col('overlay')
const filenameIdx = col('Filename')
const framesIdx = col('Frames')
const animRateIdx = col('AnimRate')
const transIdx = col('Trans')
const preDrawIdx = col('PreDraw')
const initRadiusIdx = col('InitRadius')
const radiusIdx = col('Radius')
const redIdx = col('Red')
const greenIdx = col('Green')
const blueIdx = col('Blue')
const numDirIdx = col('NumDirections')
const xOffsetIdx = col('Xoffset')
const yOffsetIdx = col('Yoffset')
for (let i = 1; i < lines.length; i++) {
const cells = lines[i]!.split('\t')
const rawName = (cells[overlayIdx] ?? '').trim()
const name = rawName.toLowerCase()
if (!name || name === 'expansion') continue
const filename = (cells[filenameIdx] ?? '').trim()
const frames = Number(cells[framesIdx] ?? 1) || 1
const animRate = Number(cells[animRateIdx] ?? 16) || 16
const trans = Number(cells[transIdx] ?? 0) || 0
const preDraw = Number(cells[preDrawIdx] ?? 0) !== 0
const initRadius = Number(cells[initRadiusIdx] ?? 0) || 0
const radius = Number(cells[radiusIdx] ?? 0) || 0
const red = parseColorChannel(cells[redIdx])
const green = parseColorChannel(cells[greenIdx])
const blue = parseColorChannel(cells[blueIdx])
const numDirections = Number(cells[numDirIdx] ?? 1) || 1
const xOffset = Number(cells[xOffsetIdx] ?? 0) || 0
const yOffset = Number(cells[yOffsetIdx] ?? 0) || 0
const rec: OverlayTxtData = {
overlay: rawName,
filename,
frames,
animRate,
trans,
preDraw,
initRadius,
radius,
red,
green,
blue,
numDirections,
xOffset,
yOffset,
}
map.set(name, rec)
if (filename) {
map.set(filename.toLowerCase(), rec)
}
}
return map
}
/**
* Extracts overlay data from an Overlay.txt table or parsed records.
*/
export function getOverlayTxtData(
overlayName: string,
overlaySource?:
| Map<string, OverlayTxtData>
| { rows: readonly Readonly<Record<string, string>>[] }
| { header: readonly string[]; rows: readonly (readonly string[])[] }
| string
| undefined,
): OverlayTxtData {
const key = overlayName.trim().toLowerCase()
if (!overlaySource) {
return CANONICAL_113C_OVERLAYS[key] ?? {
overlay: overlayName,
filename: overlayName,
frames: 14,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 255,
green: 178,
blue: 64,
}
}
if (typeof overlaySource === 'string') {
const parsed = parseOverlayTxt(overlaySource)
return parsed.get(key) ?? getOverlayTxtData(overlayName, undefined)
}
if (overlaySource instanceof Map) {
return overlaySource.get(key) ?? getOverlayTxtData(overlayName, undefined)
}
// D2Table ({ header, rows })
if ('header' in overlaySource && Array.isArray((overlaySource as any).header)) {
const t = overlaySource as { header: readonly string[]; rows: readonly (readonly string[])[] }
const oIdx = t.header.indexOf('Overlay')
const fIdx = t.header.indexOf('Filename')
const row = t.rows.find(
r => (oIdx !== -1 && (r[oIdx] ?? '').trim().toLowerCase() === key) ||
(fIdx !== -1 && (r[fIdx] ?? '').trim().toLowerCase() === key),
)
if (row) {
return {
overlay: oIdx !== -1 ? (row[oIdx] ?? key).trim() : key,
filename: fIdx !== -1 ? (row[fIdx] ?? '').trim() : '',
frames: Number(row[t.header.indexOf('Frames')] ?? 1) || 1,
animRate: Number(row[t.header.indexOf('AnimRate')] ?? 16) || 16,
trans: Number(row[t.header.indexOf('Trans')] ?? 0) || 0,
preDraw: Number(row[t.header.indexOf('PreDraw')] ?? 0) !== 0,
initRadius: Number(row[t.header.indexOf('InitRadius')] ?? 0) || 0,
radius: Number(row[t.header.indexOf('Radius')] ?? 0) || 0,
red: parseColorChannel(row[t.header.indexOf('Red')]),
green: parseColorChannel(row[t.header.indexOf('Green')]),
blue: parseColorChannel(row[t.header.indexOf('Blue')]),
numDirections: Number(row[t.header.indexOf('NumDirections')] ?? 1) || 1,
xOffset: Number(row[t.header.indexOf('Xoffset')] ?? 0) || 0,
yOffset: Number(row[t.header.indexOf('Yoffset')] ?? 0) || 0,
}
}
}
// DataTable ({ rows: Record<string, string>[] })
if ('rows' in overlaySource && !('header' in overlaySource)) {
const tableRows = overlaySource.rows as readonly Readonly<Record<string, string>>[]
const row = tableRows.find(
r => (r['overlay'] ?? r['Overlay'] ?? '').trim().toLowerCase() === key ||
(r['Filename'] ?? r['filename'] ?? '').trim().toLowerCase() === key,
)
if (row) {
return {
overlay: (row['overlay'] ?? row['Overlay'] ?? key).trim(),
filename: (row['Filename'] ?? row['filename'] ?? '').trim(),
frames: Number(row['Frames'] ?? row['frames'] ?? 1) || 1,
animRate: Number(row['AnimRate'] ?? row['animrate'] ?? 16) || 16,
trans: Number(row['Trans'] ?? row['trans'] ?? 0) || 0,
preDraw: Number(row['PreDraw'] ?? row['predraw'] ?? 0) !== 0,
initRadius: Number(row['InitRadius'] ?? row['initradius'] ?? 0) || 0,
radius: Number(row['Radius'] ?? row['radius'] ?? 0) || 0,
red: parseColorChannel(row['Red'] ?? row['red']),
green: parseColorChannel(row['Green'] ?? row['green']),
blue: parseColorChannel(row['Blue'] ?? row['blue']),
numDirections: Number(row['NumDirections'] ?? row['numdirections'] ?? 1) || 1,
xOffset: Number(row['Xoffset'] ?? row['xoffset'] ?? 0) || 0,
yOffset: Number(row['Yoffset'] ?? row['yoffset'] ?? 0) || 0,
}
}
}
return getOverlayTxtData(overlayName, undefined)
}
/**
* An active unit overlay in the world (e.g. cast overlay, aura, buff visual).
*/
export interface ActiveOverlay {
readonly id: string
readonly overlayName: string
readonly casterId: string
x: number
y: number
frame: number
readonly maxFrames: number
readonly animRate: number
readonly preDraw: boolean
readonly trans: number
readonly lightRadius: number
readonly lightColor: readonly [number, number, number]
expired: boolean
readonly loop?: boolean | undefined
readonly targetMonsterIndex?: number | undefined
readonly isAura?: boolean | undefined
readonly auraSkillId?: number | undefined
readonly isCastOverlay?: boolean | undefined
readonly isStateOverlay?: boolean | undefined
readonly stateGroup?: number | undefined
}
export interface CreateActiveOverlayOptions {
readonly loop?: boolean | undefined
readonly targetMonsterIndex?: number | undefined
readonly isAura?: boolean | undefined
readonly auraSkillId?: number | undefined
readonly isCastOverlay?: boolean | undefined
readonly isStateOverlay?: boolean | undefined
readonly stateGroup?: number | undefined
}
/**
* Creates an ActiveOverlay entity initialized at the caster's coordinates.
*/
export function createActiveOverlay(
overlayName: string,
caster: { x: number; y: number },
casterId = 'player',
overlaySource?: Map<string, OverlayTxtData> | string,
options?: CreateActiveOverlayOptions,
): ActiveOverlay {
const data = getOverlayTxtData(overlayName, overlaySource)
const cleanName = overlayName.trim().toLowerCase()
const isColdArmor = ['frozenarmor', 'shiverarmor', 'chillingarmor', 'chillarmor'].includes(cleanName)
const isKnownState = [
'frozenarmor',
'shiverarmor',
'chillingarmor',
'chillarmor',
'energyshield',
'enchant',
'thunderstormback',
].includes(cleanName)
const isStateOverlay = options?.isStateOverlay ?? isKnownState
const isCastOverlay = options?.isCastOverlay ?? (!isStateOverlay && !options?.isAura)
const stateGroup = options?.stateGroup ?? (isColdArmor ? 1 : 0)
return {
id: `overlay_${overlayName}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
overlayName: data.overlay,
casterId,
x: caster.x,
y: caster.y,
frame: 0,
maxFrames: data.frames,
animRate: data.animRate,
preDraw: data.preDraw,
trans: data.trans,
lightRadius: data.radius,
lightColor: [data.red, data.green, data.blue] as const,
expired: false,
loop: options?.loop,
targetMonsterIndex: options?.targetMonsterIndex,
isAura: options?.isAura,
auraSkillId: options?.auraSkillId,
isCastOverlay,
isStateOverlay,
stateGroup,
}
}
/** Complete canonical mapping for all Paladin aura skills (IDs 98..125) to their 1.13c overlay name. */
export const PALADIN_AURA_OVERLAYS: Readonly<Record<number, string>> = Object.freeze({
98: 'might',
99: 'prayer',
100: 'resist_fire',
102: 'holy_fire',
103: 'thorns',
104: 'defiance',
105: 'resist_cold',
108: 'blessed_aim',
109: 'cleansing',
110: 'resist_lightning',
113: 'concentration',
114: 'holy_freeze',
115: 'vigor',
118: 'holy_shock',
119: 'sanctuary',
120: 'meditation',
122: 'fanaticism',
123: 'conviction',
124: 'redemption',
125: 'salvation',
})
/**
* Authentic Diablo II v1.13c Paladin Aura Overlay Parts (States.txt + Overlay.txt).
* - 13 auras have TWO separate parts: front (preDraw=false) + back (preDraw=true).
* - 7 auras are single full-circle overlays (preDraw=true or false).
*/
export const PALADIN_AURA_OVERLAY_PARTS: Readonly<Record<number, readonly string[]>> = Object.freeze({
98: ['might_front', 'might_back'],
99: ['prayer_front', 'prayer_back'],
100: ['resist_fire'],
102: ['holy_fire_front', 'holy_fire_back'],
103: ['thorns_front', 'thorns_back'],
104: ['defiance_front', 'defiance_back'],
105: ['resist_cold'],
108: ['blessed_aim_front', 'blessed_aim_back'],
109: ['cleansing_front', 'cleansing_back'],
110: ['resist_lightning'],
113: ['concentration_front', 'concentration_back'],
114: ['holy_freeze'],
115: ['vigor_front', 'vigor_back'],
118: ['holy_shock_front', 'holy_shock_back'],
119: ['sanctuary_front', 'sanctuary_back'],
120: ['meditation'],
122: ['fanaticism_front', 'fanaticism_back'],
123: ['conviction'],
124: ['redemption'],
125: ['salvation_front', 'salvation_back'],
})
export const PALADIN_AURA_SKILL_IDS: ReadonlySet<number> = new Set(
Object.keys(PALADIN_AURA_OVERLAYS).map(Number),
)
export function isAuraSkill(skillId: number): boolean {
return PALADIN_AURA_SKILL_IDS.has(skillId)
}
export function getAuraOverlayName(skillId: number): string | null {
return PALADIN_AURA_OVERLAYS[skillId] ?? null
}
export function getAuraOverlayNames(skillId: number): readonly string[] {
return PALADIN_AURA_OVERLAY_PARTS[skillId] ?? (PALADIN_AURA_OVERLAYS[skillId] ? [PALADIN_AURA_OVERLAYS[skillId]] : [])
}
/** Complete canonical mapping for all Sorceress skills to their 1.13c castoverlay. */
export const SORCERESS_SKILL_CAST_OVERLAYS: Readonly<Record<number, string>> = Object.freeze({
36: 'fire_cast_1', // Fire Bolt
38: 'light_cast_1', // Charged Bolt
39: 'ice_cast_1', // Ice Bolt
40: 'ice_cast_1', // Frozen Armor
41: 'fire_cast_2', // Inferno
42: 'light_cast_1', // Static Field
43: 'light_cast_2', // Telekinesis
44: 'ice_cast_2', // Frost Nova
45: 'ice_cast_1', // Ice Blast
46: 'fire_cast_2', // Blaze
47: 'fire_cast_2', // Fire Ball
48: 'light_cast_1', // Nova
49: 'light_cast_1', // Lightning
50: 'ice_cast_2', // Shiver Armor
51: 'fire_cast_2', // Fire Wall
52: 'fire_cast_2', // Enchant
53: 'light_cast_1', // Chain Lightning
54: 'teleport', // Teleport
55: 'ice_cast_2', // Glacial Spike
56: 'fire_cast_2', // Meteor
57: 'light_cast_2', // Thunder Storm
58: 'light_cast_2', // Energy Shield
59: 'ice_cast_3', // Blizzard
60: 'ice_cast_3', // Chilling Armor
62: 'fire_cast_2', // Hydra
64: 'ice_cast_3', // Frozen Orb
})
const SORCERESS_SKILL_NAME_TO_OVERLAY: Readonly<Record<string, string>> = Object.freeze({
firebolt: 'fire_cast_1',
'fire bolt': 'fire_cast_1',
chargedbolt: 'light_cast_1',
'charged bolt': 'light_cast_1',
icebolt: 'ice_cast_1',
'ice bolt': 'ice_cast_1',
frozenarmor: 'ice_cast_1',
'frozen armor': 'ice_cast_1',
inferno: 'fire_cast_2',
staticfield: 'light_cast_1',
'static field': 'light_cast_1',
telekinesis: 'light_cast_2',
frostnova: 'ice_cast_2',
'frost nova': 'ice_cast_2',
iceblast: 'ice_cast_1',
'ice blast': 'ice_cast_1',
blaze: 'fire_cast_2',
fireball: 'fire_cast_2',
'fire ball': 'fire_cast_2',
nova: 'light_cast_1',
lightning: 'light_cast_1',
shiverarmor: 'ice_cast_2',
'shiver armor': 'ice_cast_2',
firewall: 'fire_cast_2',
'fire wall': 'fire_cast_2',
enchant: 'fire_cast_2',
chainlightning: 'light_cast_1',
'chain lightning': 'light_cast_1',
teleport: 'teleport',
glacialspike: 'ice_cast_2',
'glacial spike': 'ice_cast_2',
meteor: 'fire_cast_2',
thunderstorm: 'light_cast_2',
'thunder storm': 'light_cast_2',
energyshield: 'light_cast_2',
'energy shield': 'light_cast_2',
blizzard: 'ice_cast_3',
chillingarmor: 'ice_cast_3',
'chilling armor': 'ice_cast_3',
hydra: 'fire_cast_2',
frozenorb: 'ice_cast_3',
'frozen orb': 'ice_cast_3',
bonearmor: 'bonearmor_cast',
'bone armor': 'bonearmor_cast',
bonewall: 'bonecast',
'bone wall': 'bonecast',
boneprison: 'bonecast',
'bone prison': 'bonecast',
})
const NECROMANCER_SKILL_CAST_OVERLAYS: Readonly<Record<number, string>> = Object.freeze({
68: 'bonearmor_cast', // Bone Armor
78: 'bonecast', // Bone Wall
88: 'bonecast', // Bone Prison
})
/**
* Returns canonical castoverlay name for a skill, if any.
*/
export function getSkillCastOverlay(skillId: number | string): string | undefined {
if (typeof skillId === 'number') {
return (
SORCERESS_SKILL_CAST_OVERLAYS[skillId] ??
NECROMANCER_SKILL_CAST_OVERLAYS[skillId] ??
BATCH1_SKILLS[skillId]?.castOverlay
)
}
const num = parseInt(skillId, 10)
if (!Number.isNaN(num)) {
return (
SORCERESS_SKILL_CAST_OVERLAYS[num] ??
NECROMANCER_SKILL_CAST_OVERLAYS[num] ??
BATCH1_SKILLS[num]?.castOverlay
)
}
const normalized = skillId.toLowerCase().trim()
const overlay =
SORCERESS_SKILL_NAME_TO_OVERLAY[normalized] ??
SORCERESS_SKILL_NAME_TO_OVERLAY[normalized.replace(/[\s_-]/g, '')]
if (overlay) return overlay
for (const skill of Object.values(BATCH1_SKILLS)) {
if (skill.name.toLowerCase() === normalized && skill.castOverlay) {
return skill.castOverlay
}
}
return undefined
}
/**
* Read one skill from a table row.
*
* @param row - the record.
* @param rowIndex - position, for a fallback id.
* @param text - text source for name indices.
* @param missilesSource - optional Missiles.txt table or parsed map to resolve speed & range.
* @returns the definition.
*/
export function skillFromRow(
row: Readonly<Record<string, string>>,
rowIndex: number,
text: TextSource,
missilesSource?:
| Map<string, MissileTxtData>
| { rows: readonly Readonly<Record<string, string>>[] }
| { header: readonly string[]; rows: readonly (readonly string[])[] }
| string
| undefined,
): SkillDef {
const id = textCell(row, 'Id', textCell(row, 'skill', `skill${String(rowIndex)}`))
const rawName = textCell(row, 'Name', textCell(row, 'skilldesc', id))
const mslName = textCell(row, 'srvmissile', textCell(row, 'cltmissile'))
let speed = Math.max(0, numberCell(row, 'Speed', 0))
let range = Math.max(1, numberCell(row, 'Range', numberCell(row, 'range', 120)))
if (mslName) {
const msl = getMissileTxtData(mslName, missilesSource)
if (msl.speedPxPerSec > 0) speed = msl.speedPxPerSec
if (msl.distancePx > 0) range = msl.distancePx
}
const rawCastOverlay = textCell(row, 'castoverlay', textCell(row, 'castOverlay', ''))
const castOverlay = rawCastOverlay.length > 0 ? rawCastOverlay.toLowerCase() : undefined
return {
id,
name: resolveText(rawName, text) || id,
manaCost: Math.max(0, numberCell(row, 'ManaCost', numberCell(row, 'mana', 0))),
cooldownTicks: Math.max(0, numberCell(row, 'CooldownTicks', numberCell(row, 'delay', 0))),
range,
// A projectile skill is one with a missile speed or srvmissile; otherwise it strikes where
// the caster is facing.
projectile: speed > 0 || numberCell(row, 'Speed', 0) > 0 || mslName.length > 0,
speed,
baseMinDamage: Math.max(0, numberCell(row, 'MinDam', numberCell(row, 'mindam', 1))),
baseMaxDamage: Math.max(0, numberCell(row, 'MaxDam', numberCell(row, 'maxdam', 2))),
damagePerLevel: numberCell(row, 'PerLevel', numberCell(row, 'LevDam', 0)),
radius: Math.max(1, numberCell(row, 'Radius', numberCell(row, 'HitRadius', 24))),
castOverlay,
}
}
/**
* Read every skill in a table.
*
* @param table - a `Skills.txt`-shaped table.
* @param text - text source for name indices.
* @param missilesSource - optional Missiles.txt table or parsed map.
* @returns the definitions, in table order.
*/
export function skillsFromTable(
table: { rows: readonly Readonly<Record<string, string>>[] },
text: TextSource,
missilesSource?:
| Map<string, MissileTxtData>
| { rows: readonly Readonly<Record<string, string>>[] }
| { header: readonly string[]; rows: readonly (readonly string[])[] }
| string
| undefined,
): SkillDef[] {
return table.rows.map((row, index) => skillFromRow(row, index, text, missilesSource))
}
/**
* Parses raw Skills.txt text into a Map of skill definitions keyed by skill name and ID.
*/
export function parseSkillsTxt(
content: string,
missilesSource?:
| Map<string, MissileTxtData>
| { rows: readonly Readonly<Record<string, string>>[] }
| { header: readonly string[]; rows: readonly (readonly string[])[] }
| string
| undefined,
): Map<string, SkillDef> {
const map = new Map<string, SkillDef>()
const lines = content.split(/\r?\n/).filter(line => line.trim().length > 0)
if (lines.length < 2) return map
const headers = lines[0]!.split('\t').map(h => h.trim())
const rows: Record<string, string>[] = []
for (let i = 1; i < lines.length; i++) {
const cells = lines[i]!.split('\t')
const row: Record<string, string> = {}
for (let c = 0; c < headers.length; c++) {
const colName = headers[c]
if (colName) {
row[colName] = (cells[c] ?? '').trim()
}
}
rows.push(row)
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!
const skillName = (row['skill'] ?? row['Name'] ?? row['skilldesc'] ?? `skill${i}`).trim()
if (!skillName || skillName === 'expansion') continue
const def = skillFromRow(row, i, { get: () => skillName }, missilesSource)
map.set(skillName.toLowerCase(), def)
if (def.id) {
map.set(def.id.toLowerCase(), def)
}
}
return map
}
/**
* Damage of a skill at a level.
*
* @param skill - the definition.
* @param level - skill level, 1-based.
* @param rng - random source for the roll inside the damage range.
* @returns the damage.
*/
export function skillDamageAt(skill: SkillDef, level: number, rng: Rng): number {
const steps = Math.max(0, level - 1)
const min = skill.baseMinDamage + skill.damagePerLevel * steps
const max = skill.baseMaxDamage + skill.damagePerLevel * steps
return rng.int(Math.round(min), Math.round(Math.max(min, max)))
}
/**
* Cast a skill.
*
* Mana and cooldown are checked here rather than by the caller so that every
* route to casting (a hotkey, an AI, a script) obeys the same rules.
*
* @param skill - the definition.
* @param caster - position and facing.
* @param level - skill level.
* @param rng - random source.
* @param cooldownLeft - ticks of cooldown still owed.
* @param mana - mana available.
* @param aim - optional aim point; defaults to straight ahead of the facing.
* @returns what the cast produced.
*/
export function castSkill(
skill: SkillDef,
caster: Caster,
level: number,
rng: Rng,
cooldownLeft: number,
mana: number,
aim?: { readonly x: number; readonly y: number },
): CastResult {
if (cooldownLeft > 0) return { kind: 'cooldown', ticksLeft: cooldownLeft }
if (mana < skill.manaCost) return { kind: 'mana', cost: skill.manaCost }
const damage = skillDamageAt(skill, level, rng)
if (!skill.projectile) return { kind: 'instant', radius: skill.radius, damage }
let dx: number
let dy: number
if (aim !== undefined) {
dx = aim.x - caster.x
dy = aim.y - caster.y
} else {
const vector = FACING_VECTORS[caster.facing] ?? FACING_VECTORS[0]!
dx = vector[0]
dy = vector[1]
}
const length = Math.hypot(dx, dy)
if (length === 0) return { kind: 'instant', radius: skill.radius, damage }
const perTick = skill.speed / 25
const ttl = Math.max(1, Math.round(skill.range / perTick))
return {
kind: 'projectile',
projectile: {
skillId: skill.id,
x: caster.x,
y: caster.y,
vx: (dx / length) * perTick,
vy: (dy / length) * perTick,
damage,
ttl,
fromPlayer: true,
missileType: skill.id === '36' || skill.id === 'firebolt' ? 'firebolt' : undefined,
},
}
}
/** A body a projectile can hit. */
export interface ProjectileTarget {
/** Stable identity, echoed back on a hit. */
readonly index: number
/** World x. */
readonly x: number
/** World y. */
readonly y: number
/** Radius for hit testing. */
readonly radius: number
/** Whether the target is alive; dead targets are not hit. */
readonly alive: boolean
}
/** What happened to projectiles during a tick. */
export interface ProjectileOutcome {
/** Projectiles still in flight. */
readonly alive: readonly Projectile[]
/** Hits, one per projectile that struck. */
readonly hits: readonly {
readonly targetIndex: number
readonly damage: number
readonly x: number
readonly y: number
readonly skillId?: string | undefined
readonly missileType?: string | undefined
readonly aoeRadius?: number | undefined
readonly statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
readonly statusDuration?: number | undefined
readonly isUndeadOnly?: boolean | undefined
}[]
/** Projectiles that died on a wall or expired. */
readonly expired: number
/** Projectiles that stopped against terrain. */
readonly wallHits: number
/** Terrain wall collision events for visual impacts/explosions. */
readonly wallHitEvents?: readonly {
readonly x: number
readonly y: number
readonly skillId?: string | undefined
readonly missileType?: string | undefined
readonly aoeRadius?: number | undefined
readonly damage?: number | undefined
readonly statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
readonly statusDuration?: number | undefined
}[] | undefined
/** Newly spawned child projectiles during this tick (e.g. Frozen Orb bolts / nova). */
readonly spawnedProjectiles?: readonly Projectile[] | undefined
}
function getBlizzardExplosionMissile(missileType: string): string {
switch (missileType) {
case 'blizzard1':
return 'blizzardexplode1'
case 'blizzard2':
return 'blizzardexplode2'
case 'blizzard3':
return 'blizzardexplode3'
case 'blizzard4':
return 'blizzardexplode1'
default:
return 'blizzardexplode1'
}
}
function isBlizzardShard(missileType?: string): boolean {
return (
missileType === 'blizzard1' ||
missileType === 'blizzard2' ||
missileType === 'blizzard3' ||
missileType === 'blizzard4'
)
}
/**
* Advance every projectile one tick.
*
* @param projectiles - projectiles in flight.
* @param targets - candidate targets.
* @param options - terrain overlap test and projectile radius.
* @returns the surviving projectiles and what they hit.
*/
export function tickProjectiles(
projectiles: readonly Projectile[],
targets: readonly ProjectileTarget[],
options: {
readonly overlap: (x: number, y: number) => number
readonly isMissileBlocked?: ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | undefined
readonly radius?: number
readonly nextDelayByTarget?: Map<number | string, number> | Map<number, number> | undefined
readonly currentTick?: number | undefined
readonly onMissileStep?: ((missileType: string, prevX: number, prevY: number, curX: number, curY: number, projectile: Projectile) => void) | undefined
},
): ProjectileOutcome {
const radius = options.radius ?? 8
const alive: Projectile[] = []
const spawned: Projectile[] = []
const waveNextDelayHits = new Set<number>()
const hits: {
targetIndex: number
damage: number
x: number
y: number
skillId?: string | undefined
missileType?: string | undefined
aoeRadius?: number | undefined
statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
statusDuration?: number | undefined
isUndeadOnly?: boolean | undefined
}[] = []
const wallHitEvents: {
x: number
y: number
skillId?: string | undefined
missileType?: string | undefined
aoeRadius?: number | undefined
damage?: number | undefined
statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
statusDuration?: number | undefined
}[] = []
let expired = 0
let wallHits = 0
for (const projectile of projectiles) {
const isExpired = projectile.missileType === 'meteorcenter' ? projectile.ttl <= 1 : projectile.ttl <= 0
if (isExpired) {
expired += 1
if (projectile.missileType === 'frozenorb') {
// Authentic D2 1.13c MISSMODE_SrvHit29_FrozenOrb (0x6FC5CE10):
// Detonates into 16 frozenorbnova missiles + frozenorbexplode explosion at expiry
for (let i = 0; i < 64; i += 4) {
const offX = FROZEN_ORB_X_POSITIONS[i]!
const offY = FROZEN_ORB_Y_POSITIONS[i]! * ISO_GROUND_ASPECT_RATIO
const angle = Math.atan2(offY, offX)
const speed = 24
spawned.push({
skillId: projectile.skillId,
x: projectile.x,
y: projectile.y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
damage: projectile.damage > 0 ? projectile.damage : 40,
ttl: 25,
fromPlayer: projectile.fromPlayer,
missileType: 'frozenorbnova',
statusEffect: 'chill',
statusDuration: 100,
isNovaCurl: true,
pierce: false,
})
}
wallHitEvents.push({
x: projectile.x,
y: projectile.y,
skillId: projectile.skillId,
missileType: 'frozenorb',
aoeRadius: 24,
})
} else if (isBlizzardShard(projectile.missileType)) {
const expType = getBlizzardExplosionMissile(projectile.missileType!)
spawned.push({
skillId: projectile.skillId,
x: projectile.x,
y: projectile.y,
vx: 0,
vy: 0,
damage: projectile.damage,
ttl: 6,
fromPlayer: projectile.fromPlayer,
missileType: expType,
statusEffect: 'chill',
statusDuration: 100,
aoeRadius: 32,
pierce: false,
})
} else if (projectile.missileType === 'fireball') {
const blastRadius = projectile.aoeRadius ?? 80
for (const target of targets) {
if (!target.alive) continue
const inRange =
Math.hypot(target.x - projectile.x, (target.y - projectile.y) / ISO_GROUND_ASPECT_RATIO) <= blastRadius
if (inRange) {
hits.push({
targetIndex: target.index,
damage: projectile.damage,
x: target.x,
y: target.y,
skillId: projectile.skillId,
missileType: 'fireball',
aoeRadius: blastRadius,
})
}
}
spawned.push({
skillId: projectile.skillId,
x: projectile.x,
y: projectile.y,
vx: 0,
vy: 0,
damage: 0,
ttl: 16,
fromPlayer: projectile.fromPlayer,
missileType: 'explodingarrowexp',
aoeRadius: blastRadius,
pierce: true,
})
wallHitEvents.push({
x: projectile.x,
y: projectile.y,
skillId: projectile.skillId,
missileType: 'explodingarrowexp',
aoeRadius: blastRadius,
})
} else if (projectile.missileType === 'meteorcenter') {
// Authentic D2 1.13c MISSMODE_SrvHit14_MeteorCenter (0x6FC5DEF0) & 0x6FC5CEC0:
// 1. Primary explosion AOE damage (Radius = 6 subtiles = 120px) in 2:1 isometric ground perspective
const blastRadius = projectile.aoeRadius ?? 120
for (const target of targets) {
if (!target.alive) continue
const inRange =
Math.hypot(target.x - projectile.x, (target.y - projectile.y) / ISO_GROUND_ASPECT_RATIO) <= blastRadius
if (inRange) {
hits.push({
targetIndex: target.index,
damage: projectile.damage,
x: target.x,
y: target.y,
skillId: projectile.skillId,
missileType: 'meteorexplode',
aoeRadius: blastRadius,
})
}
}
// 2. Spawn explosion missile: meteorexplode (ID 103, Range 16)
spawned.push({
skillId: projectile.skillId,
x: projectile.x,
y: projectile.y,
vx: 0,
vy: 0,
damage: 0,
ttl: 16,
fromPlayer: projectile.fromPlayer,
missileType: 'meteorexplode',
aoeRadius: blastRadius,
pierce: true,
})
wallHitEvents.push({
x: projectile.x,
y: projectile.y,
skillId: projectile.skillId,
missileType: 'meteorexplode',
aoeRadius: blastRadius,
})
// 3. Spawn 18 meteorfire submissiles using 18-offset table
const slvl = Math.max(1, projectile.slvl ?? 1)
const fireDuration = projectile.fireDuration ?? (90 + (slvl - 1) * 15)
const residualDmg = projectile.residualDamage ?? calculateMeteorResidualFireDamage(slvl).min
for (const off of METEOR_FIRE_SUBTILE_OFFSETS) {
const fx = projectile.x + off.x * 20
const fy = projectile.y + off.y * 10
const blocked =
options.isMissileBlocked !== undefined
? options.isMissileBlocked(fx, fy, projectile.x, projectile.y)
: options.overlap(fx, fy) > 0
if (!blocked) {
spawned.push({
skillId: projectile.skillId,
x: fx,
y: fy,
vx: 0,
vy: 0,
damage: residualDmg,
ttl: fireDuration,
fromPlayer: projectile.fromPlayer,
missileType: 'meteorfire',
aoeRadius: 16,
pierce: true,
slvl,
fireDuration,
})
}
}
}
continue
}
let vx = projectile.vx
let vy = projectile.vy
let x = projectile.x + vx
let y = projectile.y + vy
let spiral = projectile.spiral
let subMissileIdx = projectile.subMissileIdx
let jitterIndex = projectile.jitterIndex ?? (projectile.seed !== undefined ? Math.abs(projectile.seed) % 32 : undefined)
// 0. Frozen Orb Nova affine rotation & damping curl in final frames
// Authentic D2 1.13c MISSMODE_SrvDo16_FrozenOrbNova (0x6FC58340):
// In final 6 frames before expiry, every 2 frames apply: x' = (x - y) / 2, y' = (x + y) / 2
if (
(projectile.isNovaCurl || projectile.missileType === 'frozenorbnova') &&
projectile.ttl <= 6 &&
projectile.ttl % 2 === 0
) {
const diff = vx - vy
const sum = vx + vy
vx = diff / 2
vy = sum / 2
x = projectile.x + vx
y = projectile.y + vy
} else if (spiral !== undefined) {
// 1. Archimedean spiral trajectory (e.g. Blessed Hammer)
// The hammer traces an Archimedean spiral on the map ground plane. In oblique/isometric
// perspective (2:1 projection), a circular ground expansion projects to an ellipse with a 2:1 aspect ratio.
const nextAngle = spiral.angle + 0.22
const nextRadius = spiral.radius + 3.2
spiral = { ...spiral, angle: nextAngle, radius: nextRadius }
x = spiral.originX + Math.cos(nextAngle) * nextRadius
y = spiral.originY + Math.sin(nextAngle) * (nextRadius * ISO_GROUND_ASPECT_RATIO)
vx = -Math.sin(nextAngle) * 18
vy = Math.cos(nextAngle) * (18 * ISO_GROUND_ASPECT_RATIO)
} else if (projectile.homing) {
// 2. Homing trajectory (e.g. Bone Spirit) - smoothly steer towards closest living enemy
let nearestDist = Number.POSITIVE_INFINITY
let targetX = 0
let targetY = 0
let foundTarget = false
for (const t of targets) {
if (!t.alive) continue
const d = Math.hypot(t.x - projectile.x, t.y - projectile.y)
if (d < nearestDist && d > 4) {
nearestDist = d
targetX = t.x
targetY = t.y
foundTarget = true
}
}
if (foundTarget) {
const curAngle = Math.atan2(vy, vx)
const targetAngle = Math.atan2(targetY - projectile.y, targetX - projectile.x)
let diff = targetAngle - curAngle
while (diff > Math.PI) diff -= 2 * Math.PI
while (diff < -Math.PI) diff += 2 * Math.PI
const maxTurn = 0.14
const turn = Math.max(-maxTurn, Math.min(maxTurn, diff))
const newAngle = curAngle + turn
const speed = Math.hypot(vx, vy)
vx = Math.cos(newAngle) * speed
vy = Math.sin(newAngle) * speed
x = projectile.x + vx
y = projectile.y + vy
}
} else if (projectile.jitter !== false && (projectile.jitter || projectile.missileType === 'chargedbolt' || projectile.skillId === '38' || projectile.skillId === 'chargedbolt')) {
// 3. Jitter trajectory (e.g. Charged Bolt) - discrete orthogonal jitter lookup table
// Authentic D2 1.13c PATH_ComputePathChargedBolt (0x6FD85470):
// 32-entry lookup table gatChargedBoltOffsets applied perpendicularly to main propagation vector per step.
let flatVx = vx
let flatVy = vy / ISO_GROUND_ASPECT_RATIO
const currentSpeed = Math.hypot(flatVx, flatVy)
if (currentSpeed > 0) {
const perpX = -flatVy / currentSpeed
const perpY = flatVx / currentSpeed
const idx = Math.abs((jitterIndex ?? (projectile.seed !== undefined ? projectile.seed : projectile.ttl)) % 32)
const offset = GAT_CHARGED_BOLT_OFFSETS[idx]! * 3
flatVx += perpX * offset
flatVy += perpY * offset
const newSpeed = Math.hypot(flatVx, flatVy)
if (newSpeed > 0) {
const scaleDown = currentSpeed / newSpeed
flatVx *= scaleDown
flatVy *= scaleDown
}
vx = flatVx
vy = flatVy * ISO_GROUND_ASPECT_RATIO
x = projectile.x + vx
y = projectile.y + vy
jitterIndex = (idx + 1) % 32
}
}
// 4. Frozen Orb sub-missile emitter (pSrvDoFunc = 15, frequency = 1, step = 19)
// Authentic D2 1.13c MISSMODE_SrvDo15_FrozenOrb (0x6FC581F0):
// Emits 1 radial frozenorbbolt along 64-point circle table, advancing by step 19
if (projectile.missileType === 'frozenorb') {
const idx = Math.abs((subMissileIdx ?? 0) % 64)
const offX = FROZEN_ORB_X_POSITIONS[idx]!
const offY = FROZEN_ORB_Y_POSITIONS[idx]! * ISO_GROUND_ASPECT_RATIO
const angle = Math.atan2(offY, offX)
const speed = 18 // vel = 18 in 1.13c Missiles.txt (450 px/s)
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
damage: projectile.damage > 0 ? projectile.damage : 40,
ttl: 25,
fromPlayer: projectile.fromPlayer,
missileType: 'frozenorbbolt',
statusEffect: 'chill',
statusDuration: 100,
pierce: false,
})
subMissileIdx = (idx + 19) % 64
}
// 5. Stationary Turret Emitter (e.g. Hydra, Skill 62)
const isTurretEmitter =
projectile.isTurret === true ||
projectile.missileType === 'hydra_turret' ||
(projectile.missileType === 'hydra' && projectile.vx === 0 && projectile.vy === 0)
if (isTurretEmitter) {
x = projectile.x
y = projectile.y
vx = 0
vy = 0
const currentAge = (projectile.ageTicks ?? 0) + 1
const cadence = projectile.fireCadenceTicks ?? 25
if (currentAge % cadence === 0) {
let targetX: number | undefined
let targetY: number | undefined
let minDistance = Infinity
const maxRangePx = 480 // 1.13c Range = 30, Vel = 16 -> 480 px
for (const target of targets) {
if (!target.alive) continue
const dx = target.x - x
const dy = (target.y - y) / ISO_GROUND_ASPECT_RATIO
const dist = Math.hypot(dx, dy)
if (dist <= maxRangePx && dist < minDistance) {
minDistance = dist
targetX = target.x
targetY = target.y
}
}
if (targetX !== undefined && targetY !== undefined) {
const dx = targetX - x
const dy = targetY - y
const angle = Math.atan2(dy, dx)
const speed = 16 // 1.13c Missiles.txt: vel = 16 (400 px/s)
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
damage: projectile.damage > 0 ? projectile.damage : 28,
ttl: 30, // 1.13c Missiles.txt: range = 30
fromPlayer: projectile.fromPlayer,
missileType: projectile.spawnMissileType ?? 'hydra',
statusEffect: projectile.statusEffect,
statusDuration: projectile.statusDuration,
pierce: false,
})
}
}
}
// 6. Blizzard Center Emitter (pSrvDoFunc = 10, Skill 59)
// Authentic D2 1.13c MISSMODE_SrvDo10_blizzardcenter (0x6FC60CA0):
// Emits 1 falling shard every 4 ticks (Param3 = 4) across 64-entry circle table (Radius 7)
// with step 13, for a total of exactly 25 shards over 100 ticks.
if (projectile.missileType === 'blizzardcenter') {
const currentAge = (projectile.ageTicks ?? 0) + 1
if (currentAge % 4 === 0) {
const idx = Math.abs((subMissileIdx ?? 0) % 64)
const offset = getBlizzardDropOffset(idx, 7)
const shardVariants = ['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4'] as const
const shardType = shardVariants[Math.floor((currentAge / 4) - 1) % shardVariants.length]!
spawned.push({
skillId: projectile.skillId,
x: projectile.x + offset.dx,
y: projectile.y + offset.dy,
vx: 0,
vy: 0,
altitude: 120,
damage: projectile.damage,
ttl: 9,
fromPlayer: projectile.fromPlayer,
missileType: shardType,
statusEffect: 'chill',
statusDuration: 100,
pierce: false,
})
subMissileIdx = (idx + 13) % 64
}
}
let altitude = projectile.altitude
if (isBlizzardShard(projectile.missileType)) {
if (altitude === undefined) altitude = 120
altitude = Math.max(0, altitude - (120 / 9))
} else if (projectile.missileType === 'meteor' || projectile.missileType === 'meteortail') {
if (altitude === undefined) altitude = 60 * 25
altitude = Math.max(0, altitude - 25)
}
const hitTargets = projectile.hitTargets ?? new Set<number>()
const currentAge = (projectile.ageTicks ?? 0) + 1
if (
(projectile.missileType === 'meteorfire' && currentAge % 5 === 0) ||
projectile.missileType === 'firewall' ||
projectile.missileType === 'blaze' ||
projectile.missileType === 'groundfirebig' ||
projectile.missileType === 'infernoflame1' ||
projectile.missileType === 'infernoflame2'
) {
hitTargets.clear()
}
if (options.onMissileStep !== undefined && (projectile.x !== x || projectile.y !== y)) {
options.onMissileStep(projectile.missileType ?? '', projectile.x, projectile.y, x, y, projectile)
}
const moved: Projectile = {
...projectile,
x,
y,
vx,
vy,
prevX: projectile.x,
prevY: projectile.y,
spiral,
subMissileIdx,
jitterIndex,
altitude,
hitTargets,
ageTicks: currentAge,
ttl: projectile.ttl - 1,
}
// Walls first: a projectile dies against missile barriers (solid walls, closed doors)
const isAirborne =
(projectile.missileType === 'meteor' || projectile.missileType === 'meteortail') &&
altitude !== undefined &&
altitude > 0
let blockedByTerrain = false
if (!isAirborne) {
const moveDist = Math.hypot(x - projectile.x, y - projectile.y)
const stepSize = 8
const steps = Math.max(1, Math.ceil(moveDist / stepSize))
let checkPrevX = projectile.x
let checkPrevY = projectile.y
for (let s = 1; s <= steps; s++) {
const t = s / steps
const curX = projectile.x + (x - projectile.x) * t
const curY = projectile.y + (y - projectile.y) * t
if (options.isMissileBlocked !== undefined) {
if (options.isMissileBlocked(curX, curY, checkPrevX, checkPrevY)) {
blockedByTerrain = true
break
}
} else if (options.overlap(curX, curY) > 0) {
blockedByTerrain = true
break
}
checkPrevX = curX
checkPrevY = curY
}
}
if (blockedByTerrain) {
wallHits += 1
wallHitEvents.push({
x,
y,
skillId: projectile.skillId,
missileType: projectile.missileType,
aoeRadius: projectile.aoeRadius,
damage: projectile.damage,
statusEffect: projectile.statusEffect,
statusDuration: projectile.statusDuration,
})
if (projectile.missileType === 'frozenorb') {
for (let i = 0; i < 64; i += 4) {
const offX = FROZEN_ORB_X_POSITIONS[i]!
const offY = FROZEN_ORB_Y_POSITIONS[i]! * ISO_GROUND_ASPECT_RATIO
const angle = Math.atan2(offY, offX)
const speed = 24
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
damage: projectile.damage > 0 ? projectile.damage : 40,
ttl: 25,
fromPlayer: projectile.fromPlayer,
missileType: 'frozenorbnova',
statusEffect: 'chill',
statusDuration: 100,
isNovaCurl: true,
pierce: false,
})
}
} else if (projectile.missileType === 'glacialspike') {
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: 0,
vy: 0,
damage: 0,
ttl: 15,
fromPlayer: projectile.fromPlayer,
missileType: 'freezeexplode',
aoeRadius: projectile.aoeRadius ?? 64,
pierce: true,
})
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: 0,
vy: 0,
damage: 0,
ttl: 15,
fromPlayer: projectile.fromPlayer,
missileType: 'glacialspikeejecta',
aoeRadius: projectile.aoeRadius ?? 64,
pierce: true,
})
} else if (isBlizzardShard(projectile.missileType)) {
const expType = getBlizzardExplosionMissile(projectile.missileType!)
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: 0,
vy: 0,
damage: 0,
ttl: 6,
fromPlayer: projectile.fromPlayer,
missileType: expType,
statusEffect: 'chill',
statusDuration: 100,
aoeRadius: 32,
pierce: false,
})
} else if (projectile.missileType === 'fireball') {
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: 0,
vy: 0,
damage: 0,
ttl: 16,
fromPlayer: projectile.fromPlayer,
missileType: 'explodingarrowexp',
aoeRadius: projectile.aoeRadius ?? 80,
pierce: true,
})
}
continue
}
let struck = false
const isHarmlessEmitter =
projectile.missileType === 'frozenorb' ||
projectile.missileType === 'blizzardcenter' ||
projectile.missileType === 'meteorcenter' ||
projectile.missileType === 'meteor' ||
projectile.missileType === 'meteortail' ||
projectile.missileType === 'firewallmaker' ||
projectile.missileType === 'freezeexplode' ||
projectile.missileType === 'glacialspikeejecta' ||
projectile.missileType === 'explodingarrowexp' ||
projectile.missileType === 'thunderstorm1' ||
projectile.missileType === 'thunderstorm' ||
projectile.missileType === 'lightninghit' ||
isTurretEmitter
if (!isHarmlessEmitter) {
for (const target of targets) {
if (!target.alive) continue
if (hitTargets.has(target.index)) continue // already struck by piercing projectile
if (projectile.missileType === 'blaze' && projectile.fromPlayer && (target as any).isPlayer) {
continue
}
const isGroundPatch =
projectile.missileType === 'meteorfire' ||
projectile.missileType === 'firewall' ||
projectile.missileType === 'blaze' ||
projectile.missileType === 'groundfirebig'
const dist = isGroundPatch
? Math.hypot(target.x - x, (target.y - y) / ISO_GROUND_ASPECT_RATIO)
: Math.hypot(target.x - x, target.y - y)
if (dist > target.radius + radius) continue
const hasNextDelay =
(projectile.nextDelay !== undefined && projectile.nextDelay > 0) ||
projectile.missileType === 'nova' ||
projectile.missileType === 'electricnova'
const nextDelayFrames = projectile.nextDelay ?? 4
if (hasNextDelay) {
if (waveNextDelayHits.has(target.index)) continue
if (options.nextDelayByTarget && options.currentTick !== undefined) {
const exp = options.nextDelayByTarget.get(target.index)
if (exp !== undefined && exp > options.currentTick) continue
}
}
const statusEffect =
projectile.statusEffect ??
(isBlizzardShard(projectile.missileType) || projectile.missileType?.startsWith('blizzardexplode')
? 'chill'
: undefined)
const statusDuration =
projectile.statusDuration ??
(isBlizzardShard(projectile.missileType) || projectile.missileType?.startsWith('blizzardexplode')
? 100
: undefined)
hits.push({
targetIndex: target.index,
damage: projectile.damage,
x,
y,
skillId: projectile.skillId,
missileType: projectile.missileType,
aoeRadius: projectile.aoeRadius,
statusEffect,
statusDuration,
isUndeadOnly: projectile.isUndeadOnly,
})
if (hasNextDelay) {
waveNextDelayHits.add(target.index)
if (options.nextDelayByTarget && options.currentTick !== undefined) {
options.nextDelayByTarget.set(target.index, options.currentTick + nextDelayFrames)
}
}
hitTargets.add(target.index)
// Authentic 1.13c Chain Lightning retargeting / jump
const isChainLightning =
projectile.missileType === 'chainlightning' ||
(projectile.chainRemaining !== undefined && projectile.chainRemaining > 0)
if (isChainLightning) {
const jumpsLeft = projectile.chainRemaining !== undefined ? projectile.chainRemaining : 0
if (jumpsLeft > 0) {
// Search radius: 20 subtiles (10 yards = 400 px)
const searchRadiusPx = 400
let closestCand: ProjectileTarget | null = null
let closestDist = Infinity
for (const cand of targets) {
if (!cand.alive || cand.index === target.index) continue
// Authentic ping-pong: only exclude immediate prior victim (target.index)
const d = Math.hypot(cand.x - target.x, cand.y - target.y)
if (d <= searchRadiusPx && d < closestDist) {
closestDist = d
closestCand = cand
}
}
if (closestCand) {
const dx = closestCand.x - target.x
const dy = closestCand.y - target.y
const len = Math.hypot(dx, dy)
const speed = 30 // 1.13c Missiles.txt vel = 30 (750 px/s)
const pVx = len > 0 ? (dx / len) * speed : speed
const pVy = len > 0 ? (dy / len) * speed : 0
spawned.push({
skillId: projectile.skillId,
x: target.x,
y: target.y,
vx: pVx,
vy: pVy,
damage: projectile.damage,
ttl: 25,
fromPlayer: projectile.fromPlayer,
missileType: 'chainlightning',
chainRemaining: jumpsLeft - 1,
lastHitTargetIndex: target.index,
nextHit: 1,
nextDelay: 4,
})
}
}
struck = true
break
}
if (!projectile.pierce) {
struck = true
if (projectile.missileType === 'glacialspike') {
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: 0,
vy: 0,
damage: 0,
ttl: 15,
fromPlayer: projectile.fromPlayer,
missileType: 'freezeexplode',
aoeRadius: projectile.aoeRadius ?? 64,
pierce: true,
})
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: 0,
vy: 0,
damage: 0,
ttl: 15,
fromPlayer: projectile.fromPlayer,
missileType: 'glacialspikeejecta',
aoeRadius: projectile.aoeRadius ?? 64,
pierce: true,
})
} else if (isBlizzardShard(projectile.missileType)) {
const expType = getBlizzardExplosionMissile(projectile.missileType!)
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: 0,
vy: 0,
damage: 0,
ttl: 6,
fromPlayer: projectile.fromPlayer,
missileType: expType,
statusEffect: 'chill',
statusDuration: 100,
aoeRadius: 32,
pierce: false,
})
} else if (projectile.missileType === 'fireball') {
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: 0,
vy: 0,
damage: 0,
ttl: 16,
fromPlayer: projectile.fromPlayer,
missileType: 'explodingarrowexp',
aoeRadius: projectile.aoeRadius ?? 80,
pierce: true,
})
}
break
}
}
}
if (!struck) {
alive.push(moved)
}
}
alive.push(...spawned)
return { alive, hits, expired, wallHits, wallHitEvents, spawnedProjectiles: spawned }
}
// ============================================================================
// 1.13c Batch 1 Skills Execution, Synergy Integration, and Combat Calculations
// ============================================================================
export interface SkillExecutionContext {
readonly caster: CombatPlayer
readonly target?: { x: number; y: number; id?: string } | undefined
readonly skillLevel: number // effective level: hardPoints + softPoints
readonly hardPoints?: number | undefined // base points blvl: 0..20 (for synergy evaluation)
readonly synergyMultiplier?: number | undefined // computed from skill-tree
}
export type SkillType =
| 'projectile'
| 'melee'
| 'area'
| 'buff'
| 'curse'
| 'movement'
| 'summon'
| 'aura'
| 'passive'
export interface Batch1SkillDef {
readonly id: number
readonly name: string
readonly classCode: CharacterClassCode
readonly charClass: string
readonly type: SkillType
readonly mana: number
readonly lvlmana: number
readonly minmana: number
readonly manashift: number
readonly delay: number
readonly hitshift: number
readonly emin: number
readonly emax: number
readonly elev1: number
readonly elev2: number
readonly elev3: number
readonly elev4: number
readonly elev5: number
readonly emaxlev1?: number
readonly emaxlev2?: number
readonly emaxlev3?: number
readonly emaxlev4?: number
readonly emaxlev5?: number
readonly srcDamage: number
readonly range?: number
readonly speed?: number
readonly castOverlay?: string
readonly srvdofunc?: number | undefined
readonly castDelay?: number | undefined
}
export const BATCH1_SKILLS: Readonly<Record<number, Batch1SkillDef>> = Object.freeze({
// Amazon (5 skills)
6: { id: 6, name: 'Magic Arrow', classCode: 'ama', charClass: 'amazon', type: 'projectile', mana: 12, lvlmana: -1, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128, range: 480, speed: 600 },
7: { id: 7, name: 'Fire Arrow', classCode: 'ama', charClass: 'amazon', type: 'projectile', mana: 24, lvlmana: 1, minmana: 1, manashift: 5, delay: 0, hitshift: 8, emin: 1, emax: 4, elev1: 2, elev2: 3, elev3: 6, elev4: 12, elev5: 24, emaxlev1: 2, emaxlev2: 3, emaxlev3: 7, emaxlev4: 14, emaxlev5: 27, srcDamage: 128, range: 480, speed: 600 },
10: { id: 10, name: 'Jab', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 8, lvlmana: 1, minmana: 1, manashift: 6, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
11: { id: 11, name: 'Cold Arrow', classCode: 'ama', charClass: 'amazon', type: 'projectile', mana: 28, lvlmana: 1, minmana: 1, manashift: 5, delay: 0, hitshift: 7, emin: 6, emax: 8, elev1: 4, elev2: 5, elev3: 8, elev4: 16, elev5: 42, emaxlev1: 4, emaxlev2: 5, emaxlev3: 9, emaxlev4: 17, emaxlev5: 44, srcDamage: 128, range: 480, speed: 600 },
14: { id: 14, name: 'Power Strike', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 8, lvlmana: 1, minmana: 1, manashift: 6, delay: 0, hitshift: 8, emin: 1, emax: 16, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, emaxlev1: 18, emaxlev2: 36, emaxlev3: 54, emaxlev4: 72, emaxlev5: 90, srcDamage: 128 },
// Sorceress (6 skills)
36: { id: 36, name: 'Fire Bolt', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 5, lvlmana: 0, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 6, emax: 12, elev1: 3, elev2: 4, elev3: 8, elev4: 18, elev5: 54, emaxlev1: 3, emaxlev2: 6, emaxlev3: 10, emaxlev4: 20, emaxlev5: 56, srcDamage: 0, range: 1000, speed: 500, castOverlay: 'fire_cast_1' },
37: { id: 37, name: 'Warmth', classCode: 'sor', charClass: 'sorceress', type: 'passive', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
39: { id: 39, name: 'Ice Bolt', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 3, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 7, emin: 6, emax: 10, elev1: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, emaxlev1: 3, emaxlev2: 5, emaxlev3: 7, emaxlev4: 9, emaxlev5: 11, srcDamage: 0, range: 400, speed: 450, castOverlay: 'ice_cast_1' },
44: { id: 44, name: 'Frost Nova', classCode: 'sor', charClass: 'sorceress', type: 'area', mana: 9, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 7, emin: 4, emax: 8, elev1: 4, elev2: 6, elev3: 8, elev4: 10, elev5: 12, emaxlev1: 5, emaxlev2: 7, emaxlev3: 9, emaxlev4: 11, emaxlev5: 13, srcDamage: 0, castOverlay: 'ice_cast_2' },
47: { id: 47, name: 'Fire Ball', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 10, lvlmana: 1, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 12, emax: 28, elev1: 13, elev2: 23, elev3: 28, elev4: 33, elev5: 38, emaxlev1: 15, emaxlev2: 25, emaxlev3: 30, emaxlev4: 35, emaxlev5: 40, srcDamage: 0, range: 450, speed: 450, castOverlay: 'fire_cast_2' },
54: { id: 54, name: 'Teleport', classCode: 'sor', charClass: 'sorceress', type: 'movement', mana: 24, lvlmana: -1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0, castOverlay: 'teleport' },
// Necromancer (5 skills)
66: { id: 66, name: 'Amplify Damage', classCode: 'nec', charClass: 'necromancer', type: 'curse', mana: 4, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
67: { id: 67, name: 'Teeth', classCode: 'nec', charClass: 'necromancer', type: 'area', mana: 6, lvlmana: 1, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 4, emax: 8, elev1: 2, elev2: 2, elev3: 3, elev4: 4, elev5: 5, emaxlev1: 2, emaxlev2: 3, emaxlev3: 4, emaxlev4: 5, emaxlev5: 6, srcDamage: 0 },
68: { id: 68, name: 'Bone Armor', classCode: 'nec', charClass: 'necromancer', type: 'buff', mana: 11, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
70: { id: 70, name: 'Raise Skeleton', classCode: 'nec', charClass: 'necromancer', type: 'summon', mana: 6, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 1, elev3: 2, elev4: 3, elev5: 4, srcDamage: 0 },
74: { id: 74, name: 'Corpse Explosion', classCode: 'nec', charClass: 'necromancer', type: 'area', mana: 15, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
// Paladin (6 skills)
96: { id: 96, name: 'Sacrifice', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128, castOverlay: 'blood1' },
97: { id: 97, name: 'Smite', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 2, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
98: { id: 98, name: 'Might', classCode: 'pal', charClass: 'paladin', type: 'aura', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
101: { id: 101, name: 'Holy Bolt', classCode: 'pal', charClass: 'paladin', type: 'projectile', mana: 32, lvlmana: 1, minmana: 1, manashift: 4, delay: 0, hitshift: 8, emin: 8, emax: 16, elev1: 8, elev2: 10, elev3: 13, elev4: 16, elev5: 20, emaxlev1: 8, emaxlev2: 11, emaxlev3: 15, emaxlev4: 18, emaxlev5: 23, srcDamage: 0, range: 450, speed: 450 },
106: { id: 106, name: 'Zeal', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 2, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
112: { id: 112, name: 'Blessed Hammer', classCode: 'pal', charClass: 'paladin', type: 'area', mana: 20, lvlmana: 1, minmana: 1, manashift: 6, delay: 0, hitshift: 8, emin: 12, emax: 16, elev1: 8, elev2: 10, elev3: 12, elev4: 13, elev5: 14, srcDamage: 0 },
// Barbarian (6 skills)
126: { id: 126, name: 'Bash', classCode: 'bar', charClass: 'barbarian', type: 'melee', mana: 2, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
130: { id: 130, name: 'Howl', classCode: 'bar', charClass: 'barbarian', type: 'curse', mana: 4, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
132: { id: 132, name: 'Leap', classCode: 'bar', charClass: 'barbarian', type: 'movement', mana: 2, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
133: { id: 133, name: 'Double Swing', classCode: 'bar', charClass: 'barbarian', type: 'melee', mana: 8, lvlmana: -1, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
138: { id: 138, name: 'Shout', classCode: 'bar', charClass: 'barbarian', type: 'buff', mana: 6, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
143: { id: 143, name: 'Leap Attack', classCode: 'bar', charClass: 'barbarian', type: 'melee', mana: 9, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
// Druid (5 skills)
223: { id: 223, name: 'Werewolf', classCode: 'dru', charClass: 'druid', type: 'buff', mana: 15, lvlmana: 0, minmana: 1, manashift: 8, delay: 25, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
224: { id: 224, name: 'Lycanthropy', classCode: 'dru', charClass: 'druid', type: 'passive', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
225: { id: 225, name: 'Firestorm', classCode: 'dru', charClass: 'druid', type: 'area', mana: 4, lvlmana: 0, minmana: 1, manashift: 8, delay: 15, hitshift: 2, emin: 3, emax: 6, elev1: 3, elev2: 5, elev3: 7, elev4: 14, elev5: 21, emaxlev1: 3, emaxlev2: 6, emaxlev3: 8, emaxlev4: 15, emaxlev5: 23, srcDamage: 0 },
226: { id: 226, name: 'Oak Sage', classCode: 'dru', charClass: 'druid', type: 'summon', mana: 15, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
229: { id: 229, name: 'Molten Boulder', classCode: 'dru', charClass: 'druid', type: 'projectile', mana: 20, lvlmana: 1, minmana: 1, manashift: 7, delay: 50, hitshift: 8, emin: 6, emax: 12, elev1: 4, elev2: 7, elev3: 10, elev4: 13, elev5: 16, emaxlev1: 5, emaxlev2: 8, emaxlev3: 11, emaxlev4: 14, emaxlev5: 17, srcDamage: 0, range: 450, speed: 300 },
// Assassin (5 skills)
251: { id: 251, name: 'Fire Blast', classCode: 'ass', charClass: 'assassin', type: 'projectile', mana: 24, lvlmana: 1, minmana: 1, manashift: 5, delay: 0, hitshift: 7, emin: 6, emax: 8, elev1: 3, elev2: 8, elev3: 20, elev4: 38, elev5: 58, emaxlev1: 5, emaxlev2: 11, emaxlev3: 24, emaxlev4: 44, emaxlev5: 66, srcDamage: 0, range: 350, speed: 400 },
252: { id: 252, name: 'Claw Mastery', classCode: 'ass', charClass: 'assassin', type: 'passive', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
254: { id: 254, name: 'Tiger Strike', classCode: 'ass', charClass: 'assassin', type: 'melee', mana: 1, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
258: { id: 258, name: 'Burst of Speed', classCode: 'ass', charClass: 'assassin', type: 'buff', mana: 10, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
261: { id: 261, name: 'Charged Bolt Sentry', classCode: 'ass', charClass: 'assassin', type: 'summon', mana: 13, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 7, emin: 2, emax: 14, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, emaxlev1: 6, emaxlev2: 8, emaxlev3: 12, emaxlev4: 14, emaxlev5: 16, srcDamage: 0 },
})
/**
* Returns the metadata definition for a Batch 1 combat skill, or undefined if not in Batch 1.
*/
export function getBatch1SkillDef(skillId: number): Batch1SkillDef | undefined {
return BATCH1_SKILLS[skillId]
}
/**
* Checks whether a skill ID belongs to the canonical Batch 1 essential combat skills.
*/
export function isBatch1Skill(skillId: number): boolean {
return skillId in BATCH1_SKILLS
}
/**
* Computes 1.13c mana cost with 24.8 fixed-point precision scaling and minmana clamping.
*
* Formula: Math.max(minmana, (mana + lvlmana * (effectiveLevel - 1)) * 2^(manashift - 8))
* - Decreasing mana skills reach 0.0 at high levels (Magic Arrow, Double Swing).
* - Minimum mana clamping prevents dropping below minmana (Teleport clamps at 1).
* - Resilience: Invalid / NaN / non-positive levels default to level 1.
*/
export function getSkillManaCost(skillId: number, effectiveLevel: number): number {
if (skillId === 6) {
return calculateMagicArrowStats(effectiveLevel).manaCost
}
if (skillId === 7) {
return calculateFireArrowStats(effectiveLevel).manaCost
}
if (skillId === 8) {
return calculateInnerSightStats(effectiveLevel).manaCost
}
if (skillId === 9) {
return calculateCriticalStrikeStats(effectiveLevel).manaCost
}
if (skillId === 10) {
return calculateJabStats(effectiveLevel).manaCost
}
if (skillId === 11) {
return calculateColdArrowStats(effectiveLevel).manaCost
}
if (skillId === 12) {
return calculateMultipleShotStats(effectiveLevel).manaCost
}
if (skillId === 13) {
return calculateDodgeStats(effectiveLevel).manaCost
}
if (skillId === 14) {
return calculatePowerStrikeStats(effectiveLevel).manaCost
}
if (skillId === 15) {
return calculatePoisonJavelinStats(effectiveLevel).manaCost
}
if (skillId === 16) {
return calculateExplodingArrowStats(effectiveLevel).manaCost
}
if (skillId === 17) {
return calculateSlowMissilesStats(effectiveLevel).manaCost
}
if (skillId === 18) {
return calculateAvoidStats(effectiveLevel).manaCost
}
if (skillId === 19) {
return calculateImpaleStats(effectiveLevel).manaCost
}
if (skillId === 20) {
return calculateLightningBoltStats(effectiveLevel).manaCost
}
if (skillId === 21) {
return calculateIceArrowStats(effectiveLevel).manaCost
}
if (skillId === 22) {
return calculateGuidedArrowStats(effectiveLevel).manaCost
}
if (skillId === 23) {
return calculatePenetrateStats(effectiveLevel).manaCost
}
if (skillId === 24) {
return calculateChargedStrikeStats(effectiveLevel).manaCost
}
if (skillId === 25) {
return calculatePlagueJavelinStats(effectiveLevel).manaCost
}
if (skillId === 26) {
return calculateStrafeStats(effectiveLevel).manaCost
}
if (skillId === 27) {
return calculateImmolationArrowStats(effectiveLevel).manaCost
}
if (skillId === 28) {
return calculateDecoyStats(effectiveLevel).manaCost
}
if (skillId === 29) {
return calculateEvadeStats(effectiveLevel).manaCost
}
if (skillId === 30) {
return calculateFendStats(effectiveLevel).manaCost
}
if (skillId === 31) {
return calculateFreezingArrowStats(effectiveLevel).manaCost
}
if (skillId === 32) {
return calculateValkyrieStats(effectiveLevel).manaCost
}
if (skillId === 33) {
return calculatePierceStats(effectiveLevel).manaCost
}
if (skillId === 34) {
return calculateLightningStrikeStats(effectiveLevel).manaCost
}
if (skillId === 35) {
return calculateLightningFuryStats(effectiveLevel).manaCost
}
if (skillId === 38) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 24, lvlmana = 4, manashift = 5 (factor = 0.125)
// Formula: (24 + 4 * (lvl - 1)) * 0.125 = 3 + 0.5 * (lvl - 1)
return (24 + 4 * (lvl - 1)) * 0.125
}
if (skillId === 40) {
// 1.13c Skills.txt: mana = 7, lvlmana = 0, manashift = 8
return 7
}
if (skillId === 41) {
// 1.13c Skills.txt: mana = 6, lvlmana = 0, manashift = 8
return 6
}
if (skillId === 46) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 22, lvlmana = 1, manashift = 7 (factor = 0.5)
return Math.floor((22 + (lvl - 1)) * 0.5)
}
if (skillId === 51) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 22, lvlmana = 1, manashift = 8 (factor = 1.0)
return 22 + (lvl - 1)
}
if (skillId === 52) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 25, lvlmana = 1, manashift = 8 (factor = 1.0)
return 25 + (lvl - 1)
}
if (skillId === 43) {
// 1.13c Skills.txt: mana = 7, lvlmana = 0, manashift = 8
return 7
}
if (skillId === 48) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 15, lvlmana = 1, manashift = 8
return 15 + (lvl - 1)
}
if (skillId === 42) {
// 1.13c Skills.txt: mana = 9, lvlmana = 0, manashift = 8
return 9
}
if (skillId === 45) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 12, lvlmana = 1, manashift = 7 (factor = 0.5)
return (12 + (lvl - 1)) * 0.5
}
if (skillId === 49) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 16, lvlmana = 1, manashift = 7 (factor = 0.5)
return (16 + (lvl - 1)) * 0.5
}
if (skillId === 50) {
// 1.13c Skills.txt: mana = 11, lvlmana = 0, manashift = 8
return 11
}
if (skillId === 53) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 9, lvlmana = 1, manashift = 8 (factor = 1.0)
return 9 + (lvl - 1)
}
if (skillId === 55) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 20, lvlmana = 1, manashift = 7 (factor = 0.5)
return (20 + (lvl - 1)) * 0.5
}
if (skillId === 56) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 34, lvlmana = 1, manashift = 7 (factor = 0.5)
return (34 + (lvl - 1)) * 0.5
}
if (skillId === 57) {
// 1.13c Skills.txt: mana = 19, lvlmana = 0, manashift = 8
return 19
}
if (skillId === 58) {
// 1.13c Skills.txt: mana = 5, lvlmana = 0, manashift = 8
return 5
}
if (skillId === 59) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 23, lvlmana = 1, manashift = 8 (factor = 1.0)
return 23 + (lvl - 1)
}
if (skillId === 60) {
// 1.13c Skills.txt: mana = 17, lvlmana = 0, manashift = 8
return 17
}
if (skillId === 61) {
// Fire Mastery: passive
return 0
}
if (skillId === 62) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 40, lvlmana = 1, manashift = 7 (factor = 0.5)
return (40 + (lvl - 1)) * 0.5
}
if (skillId === 63) {
// Lightning Mastery: passive
return 0
}
if (skillId === 64) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 30, lvlmana = 1, manashift = 8 (factor = 1.0)
return 30 + (lvl - 1)
}
if (skillId === 65) {
// Cold Mastery: passive
return 0
}
const def = getBatch1SkillDef(skillId)
if (!def) return 0
if (def.mana === 0 && def.lvlmana === 0) return 0
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
const shiftFactor = 2 ** ((def.manashift ?? 8) - 8)
const rawCost = (def.mana + def.lvlmana * (lvl - 1)) * shiftFactor
return Math.max(def.minmana ?? 0, rawCost)
}
/**
* Returns casting delay cooldown in frames at 25 fps.
* - Werewolf: 25 frames (1.0s)
* - Firestorm: 15 frames (0.6s)
* - Molten Boulder: 50 frames (2.0s)
* - Meteor: 30 frames (1.2s)
* - Blizzard: 45 frames (1.8s)
* - Hydra: 40 frames (1.6s)
* - Frozen Orb: 25 frames (1.0s)
* - All other Batch 1 skills: 0 frames
*/
export function getSkillCooldownTicks(skillId: number): number {
if (
skillId === 6 ||
skillId === 7 ||
skillId === 8 ||
skillId === 9 ||
skillId === 10 ||
skillId === 11 ||
skillId === 12 ||
skillId === 13 ||
skillId === 14 ||
skillId === 16 ||
skillId === 17 ||
skillId === 18 ||
skillId === 19 ||
skillId === 20 ||
skillId === 21 ||
skillId === 22 ||
skillId === 23 ||
skillId === 24 ||
skillId === 26 ||
skillId === 28 ||
skillId === 29 ||
skillId === 30 ||
skillId === 31 ||
skillId === 33 ||
skillId === 34 ||
skillId === 35 ||
skillId === 38 ||
skillId === 40 ||
skillId === 41 ||
skillId === 42 ||
skillId === 43 ||
skillId === 44 ||
skillId === 45 ||
skillId === 46 ||
skillId === 48 ||
skillId === 49 ||
skillId === 50 ||
skillId === 52 ||
skillId === 53 ||
skillId === 55 ||
skillId === 57 ||
skillId === 58 ||
skillId === 60 ||
skillId === 61 ||
skillId === 63 ||
skillId === 65
) {
return 0
}
if (skillId === 15) {
return 12 // Poison Javelin: 12 frames (0.5s) casting delay
}
if (skillId === 25) {
return 100 // Plague Javelin: 100 frames (4.0s) casting delay
}
if (skillId === 27) {
return 25 // Immolation Arrow: 25 frames (1.0s)
}
if (skillId === 32) {
return 150 // Valkyrie: 150 frames (6.0s) casting delay
}
if (skillId === 51) {
return 35
}
if (skillId === 56) {
return 30
}
if (skillId === 59) {
return 45
}
if (skillId === 62) {
return 40
}
if (skillId === 64) {
return 25
}
if (skillId === 121) {
return 25
}
const def = getBatch1SkillDef(skillId)
return def?.delay ?? 0
}
/**
* Computes min/max skill damage based on 1.13c 5-level-band progressions, HitShift precision,
* hard-point synergy multiplier integration, and weapon damage scaling.
*
* @param skillId - Canonical numeric skill ID.
* @param effectiveLevel - Current effective skill level (hard + soft points).
* @param synergyMultiplier - Evaluated synergy multiplier: 1.0 + (synergyBonusPct / 100).
* @param weaponDamage - Optional base weapon damage range { min, max }.
*/
export function calculateSkillDamage(
skillId: number | string,
slvl: number,
synergies?: number | {
readonly iceBolt?: number | undefined
readonly iceBlast?: number | undefined
readonly glacialSpike?: number | undefined
readonly blizzard?: number | undefined
readonly frozenOrb?: number | undefined
readonly fireBolt?: number | undefined
readonly fireBall?: number | undefined
readonly fireMastery?: number | undefined
readonly inferno?: number | undefined
readonly [key: string]: any
},
weaponDamage?: { min: number; max: number }
): { min: number; max: number } {
if (skillId === 6 || skillId === '6' || skillId === 'magicarrow' || skillId === 'magic arrow') {
if (!weaponDamage) {
return { min: 0, max: 0 }
}
const stats = calculateMagicArrowStats(slvl)
const baseMin = weaponDamage.min ?? 0
const baseMax = weaponDamage.max ?? 0
return { min: baseMin + stats.flatMagicDamage, max: baseMax + stats.flatMagicDamage }
}
if (skillId === 7 || skillId === '7' || skillId === 'firearrow' || skillId === 'fire arrow') {
const stats = calculateFireArrowStats(slvl, synergies as any)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
}
if (skillId === 11 || skillId === '11' || skillId === 'coldarrow' || skillId === 'cold arrow') {
const stats = calculateColdArrowStats(slvl, synergies as any)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
}
if (skillId === 12 || skillId === '12' || skillId === 'multipleshot' || skillId === 'multiple shot') {
const stats = calculateMultipleShotStats(slvl)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: Math.floor(baseMin * stats.damageMultiplier), max: Math.floor(baseMax * stats.damageMultiplier) }
}
if (skillId === 15 || skillId === '15' || skillId === 'poisonjavelin' || skillId === 'poison javelin') {
const stats = calculatePoisonJavelinStats(slvl, synergies as any)
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
}
if (skillId === 16 || skillId === '16' || skillId === 'explodingarrow' || skillId === 'exploding arrow') {
const stats = calculateExplodingArrowStats(slvl, synergies as any)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: baseMin + stats.minBlastDamage, max: baseMax + stats.maxBlastDamage }
}
if (skillId === 19 || skillId === '19' || skillId === 'impale') {
const stats = calculateImpaleStats(slvl)
const ed = 1 + stats.enhancedDamagePct / 100
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
return { min: Math.floor(baseMin * ed), max: Math.floor(baseMax * ed) }
}
if (skillId === 20 || skillId === '20' || skillId === 'lightningbolt' || skillId === 'lightning bolt') {
const stats = calculateLightningBoltStats(slvl, synergies as any)
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
}
if (skillId === 21 || skillId === '21' || skillId === 'icearrow' || skillId === 'ice arrow') {
const stats = calculateIceArrowStats(slvl, synergies as any)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
}
if (skillId === 22 || skillId === '22' || skillId === 'guidedarrow' || skillId === 'guided arrow') {
const stats = calculateGuidedArrowStats(slvl)
const ed = 1 + stats.enhancedDamagePct / 100
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: Math.floor(baseMin * ed), max: Math.floor(baseMax * ed) }
}
if (skillId === 24 || skillId === '24' || skillId === 'chargedstrike' || skillId === 'charged strike') {
const stats = calculateChargedStrikeStats(slvl, synergies as any)
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
}
if (skillId === 25 || skillId === '25' || skillId === 'plaguejavelin' || skillId === 'plague javelin') {
const stats = calculatePlagueJavelinStats(slvl, synergies as any)
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
}
if (skillId === 26 || skillId === '26' || skillId === 'strafe') {
const stats = calculateStrafeStats(slvl)
const mult = stats.damageMultiplier * (1 + stats.enhancedDamagePct / 100)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: Math.floor(baseMin * mult), max: Math.floor(baseMax * mult) }
}
if (skillId === 27 || skillId === '27' || skillId === 'immolationarrow' || skillId === 'immolation arrow') {
const stats = calculateImmolationArrowStats(slvl, synergies as any)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: baseMin + stats.minExplosionDamage, max: baseMax + stats.maxExplosionDamage }
}
if (skillId === 30 || skillId === '30' || skillId === 'fend') {
const stats = calculateFendStats(slvl)
const ed = 1 + stats.enhancedDamagePct / 100
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
return { min: Math.floor(baseMin * ed), max: Math.floor(baseMax * ed) }
}
if (skillId === 31 || skillId === '31' || skillId === 'freezingarrow' || skillId === 'freezing arrow') {
const stats = calculateFreezingArrowStats(slvl, synergies as any)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: baseMin + stats.minColdDamage, max: baseMax + stats.maxColdDamage }
}
if (skillId === 34 || skillId === '34' || skillId === 'lightningstrike' || skillId === 'lightning strike') {
const stats = calculateLightningStrikeStats(slvl, synergies as any)
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
}
if (skillId === 35 || skillId === '35' || skillId === 'lightningfury' || skillId === 'lightning fury') {
const stats = calculateLightningFuryStats(slvl, synergies as any)
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
}
if (
skillId === 8 || skillId === '8' || skillId === 'innersight' || skillId === 'inner sight' ||
skillId === 9 || skillId === '9' || skillId === 'criticalstrike' || skillId === 'critical strike' ||
skillId === 13 || skillId === '13' || skillId === 'dodge' ||
skillId === 17 || skillId === '17' || skillId === 'slowmissiles' || skillId === 'slow missiles' ||
skillId === 18 || skillId === '18' || skillId === 'avoid' ||
skillId === 23 || skillId === '23' || skillId === 'penetrate' ||
skillId === 28 || skillId === '28' || skillId === 'decoy' || skillId === 'dopplezon' ||
skillId === 29 || skillId === '29' || skillId === 'evade' ||
skillId === 32 || skillId === '32' || skillId === 'valkyrie' ||
skillId === 33 || skillId === '33' || skillId === 'pierce'
) {
return { min: 0, max: 0 }
}
if (skillId === 38 || skillId === '38' || skillId === 'chargedbolt' || skillId === 'charged bolt') {
return calculateChargedBoltDamage(slvl, synergies as any)
}
if (skillId === 47 || skillId === '47' || skillId === 'fireball' || skillId === 'fire ball') {
return calculateFireBallDamage(slvl, synergies as any)
}
if (skillId === 41 || skillId === '41' || skillId === 'inferno') {
return calculateInfernoDamage(slvl, synergies as any)
}
if (skillId === 46 || skillId === '46' || skillId === 'blaze') {
return calculateBlazeDamage(slvl, synergies as any)
}
if (skillId === 51 || skillId === '51' || skillId === 'firewall' || skillId === 'fire wall') {
return calculateFireWallDamage(slvl, synergies as any)
}
if (skillId === 52 || skillId === '52' || skillId === 'enchant') {
return calculateEnchantDamage(slvl, synergies as any)
}
if (skillId === 40 || skillId === '40' || skillId === 'frozenarmor' || skillId === 'frozen armor') {
return { min: 0, max: 0 }
}
if (skillId === 43 || skillId === '43' || skillId === 'telekinesis') {
return calculateTelekinesisDamage(slvl)
}
if (skillId === 44 || skillId === '44' || skillId === 'frostnova' || skillId === 'frost nova') {
return calculateFrostNovaDamage(slvl, synergies as any)
}
if (skillId === 48 || skillId === '48' || skillId === 'nova') {
return calculateNovaDamage(slvl, synergies as any)
}
if (skillId === 42 || skillId === '42' || skillId === 'staticfield' || skillId === 'static field') {
return { min: 0, max: 0 }
}
if (skillId === 45 || skillId === '45' || skillId === 'iceblast' || skillId === 'ice blast') {
return calculateIceBlastDamage(slvl, synergies as any)
}
if (skillId === 50 || skillId === '50' || skillId === 'shiverarmor' || skillId === 'shiver armor') {
return calculateShiverArmorDamage(slvl, synergies as any)
}
if (skillId === 53 || skillId === '53' || skillId === 'chainlightning' || skillId === 'chain lightning') {
return calculateChainLightningDamage(slvl, synergies as any)
}
if (skillId === 55 || skillId === '55' || skillId === 'glacialspike' || skillId === 'glacial spike') {
return calculateGlacialSpikeDamage(slvl, synergies as any)
}
if (skillId === 60 || skillId === '60' || skillId === 'chillingarmor' || skillId === 'chilling armor') {
return calculateChillingArmorDamage(slvl, synergies as any)
}
if (skillId === 49 || skillId === '49' || skillId === 'lightning') {
return calculateLightningDamage(slvl, synergies as any)
}
if (skillId === 57 || skillId === '57' || skillId === 'thunderstorm' || skillId === 'thunder storm') {
return calculateThunderStormDamage(slvl, synergies as any)
}
if (skillId === 58 || skillId === '58' || skillId === 'energyshield' || skillId === 'energy shield') {
return { min: 0, max: 0 }
}
if (skillId === 61 || skillId === '61' || skillId === 'firemastery' || skillId === 'fire mastery') {
return { min: 0, max: 0 }
}
if (skillId === 63 || skillId === '63' || skillId === 'lightningmastery' || skillId === 'lightning mastery') {
return { min: 0, max: 0 }
}
if (skillId === 65 || skillId === '65' || skillId === 'coldmastery' || skillId === 'cold mastery') {
return { min: 0, max: 0 }
}
if (skillId === 59 || skillId === '59' || skillId === 'blizzard') {
return calculateBlizzardDamage(slvl, synergies)
}
if (skillId === 56 || skillId === '56' || skillId === 'meteor') {
return calculateMeteorDamage(slvl, synergies as any)
}
if (skillId === 62 || skillId === '62' || skillId === 'hydra') {
return calculateHydraDamage(slvl, synergies as any)
}
if (skillId === 67 || skillId === '67' || skillId === 'teeth') {
return calculateTeethDamage(slvl, synergies as any)
}
if (skillId === 68 || skillId === '68' || skillId === 'bonearmor' || skillId === 'bone armor') {
const lvl = Math.max(1, Math.floor(slvl))
const base = 20 + (lvl - 1) * 10
let cap = base
if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) {
cap = Math.floor(base * synergies)
} else if (synergies && typeof synergies === 'object') {
cap = calculateBoneArmorCapacity(slvl, synergies as any)
}
return { min: cap, max: cap }
}
if (skillId === 70 || skillId === '70' || skillId === 'raiseskeleton' || skillId === 'raise skeleton') {
return { min: 0, max: 0 }
}
if (skillId === 73 || skillId === '73' || skillId === 'poisondagger' || skillId === 'poison dagger') {
const p = calculatePoisonDaggerDamage(slvl, synergies as any)
return { min: p.minDamage, max: p.maxDamage }
}
if (skillId === 74 || skillId === '74' || skillId === 'corpseexplosion' || skillId === 'corpse explosion') {
const hp = weaponDamage?.max ?? 1000
const ce = calculateCorpseExplosionDamage(hp)
const syn = typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
return { min: Math.floor(ce.minTotal * syn), max: Math.floor(ce.maxTotal * syn) }
}
if (skillId === 78 || skillId === '78' || skillId === 'bonewall' || skillId === 'bone wall') {
const bw = calculateBoneWallStats(slvl, synergies as any)
return { min: bw.hp, max: bw.hp }
}
if (skillId === 83 || skillId === '83' || skillId === 'poisonexplosion' || skillId === 'poison explosion') {
const pe = calculatePoisonExplosionDamage(slvl, synergies as any)
return { min: pe.minDamage, max: pe.maxDamage }
}
if (skillId === 84 || skillId === '84' || skillId === 'bonespear' || skillId === 'bone spear') {
return calculateBoneSpearDamage(slvl, synergies as any)
}
if (skillId === 88 || skillId === '88' || skillId === 'boneprison' || skillId === 'bone prison') {
const bp = calculateBonePrisonStats(slvl, synergies as any)
return { min: bp.hp, max: bp.hp }
}
if (skillId === 92 || skillId === '92' || skillId === 'poisonnova' || skillId === 'poison nova') {
const pn = calculatePoisonNovaDamage(slvl, synergies as any)
return { min: pn.minDamage, max: pn.maxDamage }
}
if (skillId === 93 || skillId === '93' || skillId === 'bonespirit' || skillId === 'bone spirit') {
return calculateBoneSpiritDamage(slvl, synergies as any)
}
const numericSkillId = typeof skillId === 'number' ? skillId : Number(skillId)
if ([66, 71, 72, 76, 77, 81, 82, 86, 87, 91].includes(numericSkillId)) {
return { min: 0, max: 0 }
}
const def = Number.isFinite(numericSkillId) ? getBatch1SkillDef(numericSkillId) : undefined
if (!def) {
if (weaponDamage) {
const wMin = Number.isFinite(weaponDamage.min) ? Math.max(0, Math.floor(weaponDamage.min)) : 0
const wMax = Number.isFinite(weaponDamage.max) ? Math.max(0, Math.floor(weaponDamage.max)) : 0
return { min: wMin, max: wMax }
}
return { min: 0, max: 0 }
}
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let min = def.emin
let max = def.emax
// Level bands (Level 2..8: lev1, 9..16: lev2, 17..22: lev3, 23..28: lev4, 29+: lev5)
if (lvl > 1) {
const maxElev1 = def.emaxlev1 ?? def.elev1
const maxElev2 = def.emaxlev2 ?? def.elev2
const maxElev3 = def.emaxlev3 ?? def.elev3
const maxElev4 = def.emaxlev4 ?? def.elev4
const maxElev5 = def.emaxlev5 ?? def.elev5
const b1 = Math.min(lvl - 1, 7)
min += b1 * def.elev1
max += b1 * maxElev1
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
min += b2 * def.elev2
max += b2 * maxElev2
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
min += b3 * def.elev3
max += b3 * maxElev3
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
min += b4 * def.elev4
max += b4 * maxElev4
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * def.elev5
max += b5 * maxElev5
}
}
// Precision shift: HitShift 7 = 0.5 (128/256), HitShift 8 = 1.0 (256/256)
const hitShift = def.hitshift ?? 8
const shiftMultiplier = 2 ** (hitShift - 8)
min *= shiftMultiplier
max *= shiftMultiplier
// Apply synergy multiplier
const syn = typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
min = Math.floor(min * syn)
max = Math.floor(max * syn)
// Weapon damage scaling via SrcDam (128 = 100% weapon damage)
if (weaponDamage && def.srcDamage > 0) {
const wpnMult = def.srcDamage / 128.0
const wMin = Number.isFinite(weaponDamage.min) ? Math.max(0, Math.floor(weaponDamage.min)) : 0
const wMax = Number.isFinite(weaponDamage.max) ? Math.max(0, Math.floor(weaponDamage.max)) : 0
min += Math.floor(wMin * wpnMult)
max += Math.floor(wMax * wpnMult)
}
return { min, max }
}
/**
* Calculates Blizzard (Skill 59) synergy multiplier:
* +5% damage per hard point in Ice Bolt (39), Ice Blast (45), and Glacial Spike (55).
*/
export function calculateBlizzardSynergyMultiplier(synergies: {
readonly iceBolt?: number | undefined
readonly iceBlast?: number | undefined
readonly glacialSpike?: number | undefined
}): number {
const points = (synergies.iceBolt ?? 0) + (synergies.iceBlast ?? 0) + (synergies.glacialSpike ?? 0)
return 1.0 + points * 0.05
}
/**
* Calculates Blizzard (Skill 59) min and max damage with 1.13c 5-band level scaling and synergies.
*/
export function calculateBlizzardDamage(
effectiveLevel: number,
synergies: {
readonly iceBolt?: number | undefined
readonly iceBlast?: number | undefined
readonly glacialSpike?: number | undefined
} | number = {},
): { min: number; max: number } {
let syn = 1.0
if (typeof synergies === 'number') {
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
} else if (synergies && typeof synergies === 'object') {
syn = calculateBlizzardSynergyMultiplier(synergies)
}
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
let min = 45
let max = 75
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
min += b1 * 15
max += b1 * 16
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
min += b2 * 30
max += b2 * 31
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
min += b3 * 45
max += b3 * 46
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
min += b4 * 55
max += b4 * 56
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * 65
max += b5 * 66
}
}
return {
min: Math.floor(min * syn),
max: Math.floor(max * syn),
}
}
/**
* Calculates Meteor (Skill 56) synergy multiplier:
* +5% fire damage per hard point in Fire Bolt (36) and Fire Ball (47).
* Fire Mastery (61): +30% base, +7%/lvl if present.
*/
export function calculateMeteorSynergyMultiplier(synergies: {
readonly fireBolt?: number | undefined
readonly fireBall?: number | undefined
readonly fireMastery?: number | undefined
}): number {
const points = (synergies.fireBolt ?? 0) + (synergies.fireBall ?? 0)
const synPct = points * 5 // +5% per hard point
const masteryPct = synergies.fireMastery && synergies.fireMastery > 0
? 30 + (synergies.fireMastery - 1) * 7 // Fire Mastery: +30% base, +7%/lvl
: 0
return (1.0 + synPct / 100) * (1.0 + masteryPct / 100)
}
/**
* Calculates Meteor (Skill 56) primary detonation fire damage with 1.13c 5-band level scaling and synergies.
* Base: 80 - 100
* 5 bands: [23, 39, 79, 81, 83] min / [25, 41, 81, 83, 85] max.
*/
export function calculateMeteorDamage(
effectiveLevel: number,
synergies: {
readonly fireBolt?: number | undefined
readonly fireBall?: number | undefined
readonly fireMastery?: number | undefined
} | number = {},
): { min: number; max: number } {
let syn = 1.0
if (typeof synergies === 'number') {
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
} else if (synergies && typeof synergies === 'object') {
syn = calculateMeteorSynergyMultiplier(synergies)
}
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
let min = 80
let max = 100
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
min += b1 * 23
max += b1 * 25
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
min += b2 * 39
max += b2 * 41
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
min += b3 * 79
max += b3 * 81
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
min += b4 * 81
max += b4 * 83
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * 83
max += b5 * 85
}
}
return {
min: Math.floor(min * syn),
max: Math.floor(max * syn),
}
}
/**
* Calculates Meteor residual ground fire (meteorfire, ID 240) periodic damage.
* 1.13c Ground Truth:
* - Base: 15 - 25
* - 5 bands: [4, 5, 6, 6, 6] min/max
* - Synergies: +3% per hard point of Inferno (41); Fire Mastery (61) applies.
* - HitShift: 3 (8/256 internal precision per tick).
* - Per-second multiplier: 25 * 8 / 256 = 200 / 256 = 0.78125.
*/
export function calculateMeteorResidualFireDamage(
effectiveLevel: number,
synergies: {
readonly inferno?: number | undefined
readonly fireMastery?: number | undefined
} | number = {},
): {
min: number
max: number
perSecMin: number
perSecMax: number
} {
let syn = 1.0
if (typeof synergies === 'number') {
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
} else if (synergies && typeof synergies === 'object') {
const infernoPts = synergies.inferno ?? 0
const infernoPct = infernoPts * 3 // +3% per point
const masteryPct = synergies.fireMastery && synergies.fireMastery > 0
? 30 + (synergies.fireMastery - 1) * 7
: 0
syn = (1.0 + infernoPct / 100) * (1.0 + masteryPct / 100)
}
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
let min = 15
let max = 25
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
min += b1 * 4
max += b1 * 4
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
min += b2 * 5
max += b2 * 5
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
min += b3 * 6
max += b3 * 6
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
min += b4 * 6
max += b4 * 6
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * 6
max += b5 * 6
}
}
const rawMin = Math.floor(min * syn)
const rawMax = Math.floor(max * syn)
// In 1.13c HitShift is 3 (8/256) at 25 fps: per-second multiplier is 200/256 = 0.78125
const perSecMin = Math.floor((rawMin * 200) / 256)
const perSecMax = Math.floor((rawMax * 200) / 256)
return {
min: rawMin,
max: rawMax,
perSecMin,
perSecMax,
}
}
/**
* Calculates Meteor detailed damage profile including impact fire, optional physical split,
* and residual ground fire damage per second.
*/
export function calculateMeteorDetailedDamage(
effectiveLevel: number,
synergies: {
readonly fireBolt?: number | undefined
readonly fireBall?: number | undefined
readonly fireMastery?: number | undefined
readonly inferno?: number | undefined
} = {},
options?: { readonly physRatio?: number | undefined }
): {
impactFire: { min: number; max: number }
impactPhys: { min: number; max: number }
residualFire: { min: number; max: number; perSecMin: number; perSecMax: number }
} {
const det = calculateMeteorDamage(effectiveLevel, synergies)
const physRatio = Math.max(0, Math.min(1, options?.physRatio ?? 0)) // 0 in vanilla 1.13c
const fireRatio = 1.0 - physRatio
const impactFire = {
min: Math.floor(det.min * fireRatio),
max: Math.floor(det.max * fireRatio),
}
const impactPhys = {
min: Math.floor(det.min * physRatio),
max: Math.floor(det.max * physRatio),
}
const residualFire = calculateMeteorResidualFireDamage(effectiveLevel, synergies)
return {
impactFire,
impactPhys,
residualFire,
}
}
/**
* Calculates Hydra (Skill 62) synergy multiplier:
* +3% fire damage per hard point in Fire Bolt (36) and Fire Ball (47).
*/
export function calculateHydraSynergyMultiplier(synergies: {
readonly fireBolt?: number | undefined
readonly fireBall?: number | undefined
readonly fireMastery?: number | undefined
} | number = {}): number {
if (typeof synergies === 'number') {
return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
}
const points = (synergies.fireBolt ?? 0) + (synergies.fireBall ?? 0)
return 1.0 + points * 0.03
}
/**
* Calculates Hydra (Skill 62) min and max damage with 1.13c 5-band level scaling and synergies.
* 1.13c Ground Truth:
* - EMin: 28, EMax: 39
* - 5 bands: elev = [11, 15, 19, 23, 27], emaxlev = [13, 17, 21, 25, 29]
* - HitShift: 7 (factor = 0.5)
* - Synergies: +3% per hard point of Fire Bolt (36) and Fire Ball (47).
*/
export function calculateHydraDamage(
effectiveLevel: number,
synergies: {
readonly fireBolt?: number | undefined
readonly fireBall?: number | undefined
readonly fireMastery?: number | undefined
} | number = {},
): { min: number; max: number } {
let syn = 1.0
if (typeof synergies === 'number') {
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
} else if (synergies && typeof synergies === 'object') {
syn = calculateHydraSynergyMultiplier(synergies)
}
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
let min = 28
let max = 39
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
min += b1 * 11
max += b1 * 13
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
min += b2 * 15
max += b2 * 17
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
min += b3 * 19
max += b3 * 21
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
min += b4 * 23
max += b4 * 25
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * 27
max += b5 * 29
}
}
// HitShift 7 = 0.5 (128/256 precision)
min *= 0.5
max *= 0.5
return {
min: Math.floor(min * syn),
max: Math.floor(max * syn),
}
}
/**
* Calculates Fire Ball (Skill 47) synergy multiplier:
* +14% fire damage per hard point in Fire Bolt (36) and Meteor (56).
* Fire Mastery (61): +30% base, +7%/lvl if present.
*/
export function calculateFireBallSynergyMultiplier(synergies: {
readonly fireBolt?: number | undefined
readonly meteor?: number | undefined
readonly fireMastery?: number | undefined
} | number = {}): number {
if (typeof synergies === 'number') {
return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
}
const points = (synergies.fireBolt ?? 0) + (synergies.meteor ?? 0)
const synPct = points * 14 // +14% per hard point
const masteryPct = synergies.fireMastery && synergies.fireMastery > 0
? 30 + (synergies.fireMastery - 1) * 7 // Fire Mastery: +30% base, +7%/lvl
: 0
return (1.0 + synPct / 100) * (1.0 + masteryPct / 100)
}
/**
* Calculates Fire Ball (Skill 47) min and max damage with 1.13c 5-band level scaling and synergies.
* 1.13c Ground Truth (`Skills.txt` row 47):
* - EMin: 12, EMax: 28
* - 5 bands: elev = [13, 23, 28, 33, 38], emaxlev = [15, 25, 30, 35, 40]
* - HitShift: 7 (factor = 0.5)
* - Synergies: +14% per hard point in Fire Bolt (36) and Meteor (56).
*/
export function calculateFireBallDamage(
effectiveLevel: number,
synergies: {
readonly fireBolt?: number | undefined
readonly meteor?: number | undefined
readonly fireMastery?: number | undefined
} | number = {},
): { min: number; max: number } {
let syn = 1.0
if (typeof synergies === 'number') {
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
} else if (synergies && typeof synergies === 'object') {
syn = calculateFireBallSynergyMultiplier(synergies)
}
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
let min = 12
let max = 28
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
min += b1 * 13
max += b1 * 15
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
min += b2 * 23
max += b2 * 25
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
min += b3 * 28
max += b3 * 30
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
min += b4 * 33
max += b4 * 35
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * 38
max += b5 * 40
}
}
min *= 0.5
max *= 0.5
return {
min: Math.floor(min * syn),
max: Math.floor(max * syn),
}
}
/** Result of executing a Batch 1 skill. */
export type SkillExecutionResult =
| { readonly kind: 'cooldown'; readonly ticksLeft: number }
| { readonly kind: 'mana'; readonly cost: number; readonly currentMana: number }
| {
readonly kind: 'projectile'
readonly skillId: number
readonly skillName: string
readonly projectile: Projectile
readonly manaCost: number
readonly cooldownTicks: number
readonly damage: { min: number; max: number }
}
| {
readonly kind: 'melee'
readonly skillId: number
readonly skillName: string
readonly damage: { min: number; max: number }
readonly manaCost: number
readonly cooldownTicks: number
readonly hits: number
readonly knockback: boolean
readonly autoHit: boolean
readonly selfDamagePct?: number | undefined
}
| {
readonly kind: 'area'
readonly skillId: number
readonly skillName: string
readonly radius: number
readonly damage: { min: number; max: number }
readonly manaCost: number
readonly cooldownTicks: number
}
| {
readonly kind: 'buff'
readonly skillId: number
readonly skillName: string
readonly durationSeconds: number
readonly manaCost: number
readonly cooldownTicks: number
readonly buffEffect: Readonly<Record<string, number>>
}
| {
readonly kind: 'curse'
readonly skillId: number
readonly skillName: string
readonly radius: number
readonly durationSeconds: number
readonly manaCost: number
readonly cooldownTicks: number
readonly debuffEffect: Readonly<Record<string, number>>
}
| {
readonly kind: 'movement'
readonly skillId: number
readonly skillName: string
readonly destination: { readonly x: number; readonly y: number }
readonly manaCost: number
readonly cooldownTicks: number
}
| {
readonly kind: 'summon'
readonly skillId: number
readonly skillName: string
readonly summonType: string
readonly manaCost: number
readonly cooldownTicks: number
readonly count: number
}
| {
readonly kind: 'aura'
readonly skillId: number
readonly skillName: string
readonly radius: number
readonly manaCost: number
readonly cooldownTicks: number
readonly auraBonusPct: number
}
| {
readonly kind: 'passive'
readonly skillId: number
readonly skillName: string
readonly passiveBonus: Readonly<Record<string, number>>
}
/**
* Executes combat mechanics for any of the Batch 1 essential combat skills across all 7 classes.
* Enforces resource checks (cooldown, mana) and produces typed execution outcomes (projectile,
* melee, area, buff, curse, movement, summon, aura, passive).
*/
export function executeSkill(
skillId: number,
context: SkillExecutionContext,
options?: {
readonly rng?: Rng
readonly weaponDamage?: { min: number; max: number }
readonly deductResources?: boolean
}
): SkillExecutionResult {
const def = getBatch1SkillDef(skillId)
if (!def) {
return {
kind: 'melee',
skillId,
skillName: 'Unknown',
damage: options?.weaponDamage
? { min: Math.max(0, options.weaponDamage.min), max: Math.max(0, options.weaponDamage.max) }
: { min: 0, max: 0 },
manaCost: 0,
cooldownTicks: 0,
hits: 1,
knockback: false,
autoHit: false,
}
}
const { caster, target, skillLevel } = context
const synergyMultiplier = context.synergyMultiplier ?? 1.0
const cooldownLeft = caster.cooldown ?? 0
if (cooldownLeft > 0 && def.delay > 0) {
return { kind: 'cooldown', ticksLeft: cooldownLeft }
}
const manaCost = getSkillManaCost(skillId, skillLevel)
const currentMana = Number.isFinite(caster.mana) ? caster.mana! : 0
if (currentMana < manaCost) {
return { kind: 'mana', cost: manaCost, currentMana }
}
const damage = calculateSkillDamage(skillId, skillLevel, synergyMultiplier, options?.weaponDamage)
const cooldownTicks = def.delay
if (options?.deductResources !== false) {
caster.mana = Math.max(0, currentMana - manaCost)
if (cooldownTicks > 0) {
caster.cooldown = Math.max(caster.cooldown ?? 0, cooldownTicks)
}
}
const facing = caster.facing ?? 0
const vector = FACING_VECTORS[facing] ?? FACING_VECTORS[0]!
let dx = vector[0]
let dy = vector[1]
if (target !== undefined) {
const tdx = target.x - caster.x
const tdy = target.y - caster.y
const len = Math.hypot(tdx, tdy)
if (len > 0) {
dx = tdx / len
dy = tdy / len
}
}
switch (def.type) {
case 'projectile': {
const speed = def.speed ?? 450
const range = def.range ?? 400
const perTick = speed / 25
const ttl = Math.max(1, Math.round(range / perTick))
const projDamage = options?.rng
? options.rng.int(damage.min, Math.max(damage.min, damage.max))
: Math.round((damage.min + damage.max) / 2)
const projectile: Projectile = {
skillId: String(skillId),
x: caster.x,
y: caster.y,
vx: dx * perTick,
vy: dy * perTick,
damage: projDamage,
ttl,
fromPlayer: true,
}
return {
kind: 'projectile',
skillId,
skillName: def.name,
projectile,
manaCost,
cooldownTicks,
damage,
}
}
case 'melee': {
let hits = 1
let knockback = false
let autoHit = false
let selfDamagePct: number | undefined
if (skillId === 10) hits = 3 // Jab: 3 strikes
if (skillId === 106) hits = Math.min(Math.max(1, Math.floor(skillLevel)) + 1, 5) // Zeal: min(lvl+1, 5)
if (skillId === 133) hits = 2 // Double Swing: 2 hits
if (skillId === 126) knockback = true // Bash: knockback
if (skillId === 97) { autoHit = true; knockback = true } // Smite: 100% hit, knockback
if (skillId === 96) selfDamagePct = 8 // Sacrifice: 8% self-damage
return {
kind: 'melee',
skillId,
skillName: def.name,
damage,
manaCost,
cooldownTicks,
hits,
knockback,
autoHit,
selfDamagePct,
}
}
case 'area': {
const radius = skillId === 44 ? 240 : skillId === 74 ? 80 + 20 * (skillLevel - 1) : 120
return {
kind: 'area',
skillId,
skillName: def.name,
radius,
damage,
manaCost,
cooldownTicks,
}
}
case 'buff': {
const durationSeconds = skillId === 258 ? 120 + 12 * (skillLevel - 1) : skillId === 138 ? 16 + 4 * (skillLevel - 1) : 40
const buffEffect: Record<string, number> = {}
if (skillId === 68) buffEffect.damageAbsorption = 20 + 10 * (skillLevel - 1) // Bone Armor
if (skillId === 138) buffEffect.defensePct = 100 + 10 * (skillLevel - 1) // Shout
if (skillId === 258) {
buffEffect.attackSpeedPct = 20 + 2 * (skillLevel - 1) // Burst of Speed
buffEffect.walkRunSpeedPct = 20 + 3 * (skillLevel - 1)
}
if (skillId === 223) {
buffEffect.werewolfLifePct = 50 + 5 * (skillLevel - 1) // Werewolf
buffEffect.attackSpeedPct = 20 + 2 * (skillLevel - 1)
}
return {
kind: 'buff',
skillId,
skillName: def.name,
durationSeconds,
manaCost,
cooldownTicks,
buffEffect: Object.freeze(buffEffect),
}
}
case 'curse': {
const radius = 80 + 20 * (skillLevel - 1)
const durationSeconds = skillId === 66 ? 8 + 3 * (skillLevel - 1) : 10
const debuffEffect: Record<string, number> = {}
if (skillId === 66) debuffEffect.physicalResistPenaltyPct = 100 // Amplify Damage
if (skillId === 130) debuffEffect.flee = 1 // Howl
return {
kind: 'curse',
skillId,
skillName: def.name,
radius,
durationSeconds,
manaCost,
cooldownTicks,
debuffEffect: Object.freeze(debuffEffect),
}
}
case 'movement': {
const destination = target ? { x: target.x, y: target.y } : { x: caster.x + dx * 200, y: caster.y + dy * 200 }
return {
kind: 'movement',
skillId,
skillName: def.name,
destination,
manaCost,
cooldownTicks,
}
}
case 'summon': {
const summonType = skillId === 70 ? 'skeleton' : skillId === 226 ? 'oak_sage' : 'charged_bolt_sentry'
return {
kind: 'summon',
skillId,
skillName: def.name,
summonType,
manaCost,
cooldownTicks,
count: skillId === 70 ? Math.min(1 + Math.floor(skillLevel / 3), 20) : 1,
}
}
case 'aura': {
const auraBonusPct = 40 + 10 * (skillLevel - 1) // Might
return {
kind: 'aura',
skillId,
skillName: def.name,
radius: 200,
manaCost: 0,
cooldownTicks: 0,
auraBonusPct,
}
}
case 'passive': {
const passiveBonus: Record<string, number> = {}
if (skillId === 37) passiveBonus.manaRegenPct = 30 + 12 * (skillLevel - 1) // Warmth
if (skillId === 224) passiveBonus.shapeshiftLifePct = 20 + 5 * (skillLevel - 1) // Lycanthropy
if (skillId === 252) passiveBonus.clawDamagePct = 35 + 8 * (skillLevel - 1) // Claw Mastery
return {
kind: 'passive',
skillId,
skillName: def.name,
passiveBonus: Object.freeze(passiveBonus),
}
}
}
}
// ============================================================================
// Paladin Combat Skills (Tree 1 - 10 Skills) Standalone 1.13c Formulas
// ============================================================================
/**
* 1. Sacrifice (96)
* - %ED: 180 + (slvl - 1) * 15%
* - AR: 20 + (slvl - 1) * 7%
* - Synergies: Redemption (+15%/lvl), Fanaticism (+5%/lvl)
* - Self-damage: strictly 8% of damage dealt
*/
export function calculateSacrificeStats(
slvl: number,
synergies?: { redemption?: number; fanaticism?: number },
): { toHitBonusPct: number; damagePct: number; selfDamagePct: number } {
const lvl = Math.max(1, slvl)
const toHitBonusPct = 20 + (lvl - 1) * 7
const baseEd = 180 + (lvl - 1) * 15
const redemptionBlvl = synergies?.redemption ?? 0
const fanaticismBlvl = synergies?.fanaticism ?? 0
const synBonus = redemptionBlvl * 15 + fanaticismBlvl * 5
return {
toHitBonusPct,
damagePct: baseEd + synBonus,
selfDamagePct: 8,
}
}
/**
* 2. Smite (97)
* - %ED: 15 + (slvl - 1) * 15%
* - Stun: Math.min(250, 15 + (slvl - 1) * 5) frames
* - Damage: shield base min/max + Holy Shield flat bonus
*/
export function calculateSmiteStats(
slvl: number,
shieldMin: number,
shieldMax: number,
holyShieldBonus?: { min: number; max: number },
): { damagePct: number; stunDurationFrames: number; minDamage: number; maxDamage: number } {
const lvl = Math.max(1, slvl)
const damagePct = 15 + (lvl - 1) * 15
const stunDurationFrames = Math.min(250, 15 + (lvl - 1) * 5)
const hsMin = holyShieldBonus?.min ?? 0
const hsMax = holyShieldBonus?.max ?? 0
const minDamage = shieldMin + hsMin
const maxDamage = shieldMax + hsMax
return {
damagePct,
stunDurationFrames,
minDamage,
maxDamage,
}
}
/**
* 3. Holy Bolt (101) Magic Damage vs Undead
* 5-band scaling:
* EMin: 8, EMinLev: 8, 10, 13, 16, 20
* EMax: 16, EMaxLev: 8, 11, 15, 18, 23
* Synergies: Blessed Hammer (+50%/blvl), Fist of the Heavens (+50%/blvl)
*/
export function calculateHolyBoltDamage(
slvl: number,
synergies?: { blessedHammer?: number; fistOfTheHeavens?: number },
): { min: number; max: number } {
const lvl = Math.max(1, slvl)
let min = 8
let max = 16
if (lvl > 1) {
const b1 = Math.min(lvl, 8) - 1
min += b1 * 8
max += b1 * 8
}
if (lvl > 8) {
const b2 = Math.min(lvl, 16) - 8
min += b2 * 10
max += b2 * 11
}
if (lvl > 16) {
const b3 = Math.min(lvl, 22) - 16
min += b3 * 13
max += b3 * 15
}
if (lvl > 22) {
const b4 = Math.min(lvl, 28) - 22
min += b4 * 16
max += b4 * 18
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * 20
max += b5 * 23
}
const bhBlvl = synergies?.blessedHammer ?? 0
const fohBlvl = synergies?.fistOfTheHeavens ?? 0
const synBonusPct = (bhBlvl + fohBlvl) * 50
const minFinal = Math.trunc((min * (100 + synBonusPct)) / 100)
const maxFinal = Math.trunc((max * (100 + synBonusPct)) / 100)
return { min: minFinal, max: maxFinal }
}
/**
* 4. Holy Bolt (101) Friendly Heal
* Min heal: 1 + (slvl - 1) * 2
* Max heal: 6 + (slvl - 1) * 4
* Synergy: Prayer (+15%/blvl)
*/
export function calculateHolyBoltHeal(
slvl: number,
prayerBlvl: number = 0,
): { min: number; max: number } {
const lvl = Math.max(1, slvl)
const rawMin = 1 + (lvl - 1) * 2
const rawMax = 6 + (lvl - 1) * 4
const prayerBonus = prayerBlvl * 15
const min = Math.trunc((rawMin * (100 + prayerBonus)) / 100)
const max = Math.trunc((rawMax * (100 + prayerBonus)) / 100)
return { min, max }
}
/**
* 5. Zeal (106)
* - Hits: min(slvl + 1, 5) per par5=2, par6=5 -> min(2 + slvl - 1, 5) = min(slvl + 1, 5) (lvl 1 -> 2, lvl 4+ -> 5)
* - AR: 10 + (slvl - 1) * 10%
* - %ED: slvl >= 5 ? (slvl - 4) * 6% : 0
* - Synergy: Sacrifice (+12%/blvl)
*/
export function calculateZealStats(
slvl: number,
synergies?: { sacrifice?: number },
): { hits: number; toHitBonusPct: number; damagePct: number } {
const lvl = Math.max(1, slvl)
const hits = Math.min(5, Math.max(2, 1 + lvl))
const toHitBonusPct = 10 + (lvl - 1) * 10
const baseEd = lvl >= 5 ? (lvl - 4) * 6 : 0
const sacBlvl = synergies?.sacrifice ?? 0
const damagePct = baseEd + sacBlvl * 12
return { hits, toHitBonusPct, damagePct }
}
/**
* 6. Charge (107)
* - AR: 50 + (slvl - 1) * 15%
* - %ED: 100 + (slvl - 1) * 25%
* - Synergies: Vigor (+20%/blvl), Might (+20%/blvl)
* - Speed multiplier: 3.0 (300% base run velocity)
*/
export function calculateChargeStats(
slvl: number,
synergies?: { vigor?: number; might?: number },
): { toHitBonusPct: number; damagePct: number; speedMultiplier: number } {
const lvl = Math.max(1, slvl)
const toHitBonusPct = 50 + (lvl - 1) * 15
const baseEd = 100 + (lvl - 1) * 25
const vigorBlvl = synergies?.vigor ?? 0
const mightBlvl = synergies?.might ?? 0
const synBonus = (vigorBlvl + mightBlvl) * 20
return {
toHitBonusPct,
damagePct: baseEd + synBonus,
speedMultiplier: 3.0,
}
}
/**
* 7. Vengeance (111)
* - AR: 20 + (slvl - 1) * 10%
* - Base Elemental %: 70 + (slvl - 1) * 6%
* - Synergies:
* - Fire %: + Resist Fire (+10%/blvl) + Salvation (+2%/blvl)
* - Cold %: + Resist Cold (+10%/blvl) + Salvation (+2%/blvl)
* - Lightning %: + Resist Lightning (+10%/blvl) + Salvation (+2%/blvl)
* - Cold Chill Duration: 30 + (slvl - 1) * 15 frames (1.2s + 0.6s/lvl)
*/
export function calculateVengeanceStats(
slvl: number,
synergies?: {
resistFire?: number
resistCold?: number
resistLightning?: number
salvation?: number
},
): {
toHitBonusPct: number
firePct: number
coldPct: number
ltngPct: number
chillDurationFrames: number
} {
const lvl = Math.max(1, slvl)
const toHitBonusPct = 20 + (lvl - 1) * 10
const baseElemPct = 70 + (lvl - 1) * 6
const salvBonus = (synergies?.salvation ?? 0) * 2
const firePct = baseElemPct + (synergies?.resistFire ?? 0) * 10 + salvBonus
const coldPct = baseElemPct + (synergies?.resistCold ?? 0) * 10 + salvBonus
const ltngPct = baseElemPct + (synergies?.resistLightning ?? 0) * 10 + salvBonus
const chillDurationFrames = 30 + (lvl - 1) * 15
return {
toHitBonusPct,
firePct,
coldPct,
ltngPct,
chillDurationFrames,
}
}
/**
* 8. Blessed Hammer (112) Magic Damage
* 5-band scaling:
* EMin: 12, EMinLev: 8, 12, 16, 20, 24
* EMax: 16, EMaxLev: 8, 12, 16, 20, 24
* Synergies: Blessed Aim (+14%/blvl), Vigor (+14%/blvl)
* Concentration: receives 50% of active Concentration %ED
*/
export function calculateBlessedHammerDamage(
slvl: number,
synergies?: { blessedAim?: number; vigor?: number },
concentrationEd: number = 0,
): { min: number; max: number; concentrationBonusPct: number } {
const lvl = Math.max(1, slvl)
let min = 12
let max = 16
if (lvl > 1) {
const b1 = Math.min(lvl, 8) - 1
min += b1 * 8
max += b1 * 8
}
if (lvl > 8) {
const b2 = Math.min(lvl, 16) - 8
min += b2 * 12
max += b2 * 12
}
if (lvl > 16) {
const b3 = Math.min(lvl, 22) - 16
min += b3 * 16
max += b3 * 16
}
if (lvl > 22) {
const b4 = Math.min(lvl, 28) - 22
min += b4 * 20
max += b4 * 20
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * 24
max += b5 * 24
}
const aimBlvl = synergies?.blessedAim ?? 0
const vigorBlvl = synergies?.vigor ?? 0
const synBonusPct = (aimBlvl + vigorBlvl) * 14
const concBonusPct = Math.trunc(concentrationEd / 2)
const totalMultPct = 100 + synBonusPct
let minFinal = Math.trunc((min * totalMultPct) / 100)
let maxFinal = Math.trunc((max * totalMultPct) / 100)
if (concBonusPct > 0) {
minFinal = Math.trunc((minFinal * (100 + concBonusPct)) / 100)
maxFinal = Math.trunc((maxFinal * (100 + concBonusPct)) / 100)
}
return { min: minFinal, max: maxFinal, concentrationBonusPct: concBonusPct }
}
/**
* 9. Conversion (116)
* - Chance %: dm34 -> Math.floor((slvl * 50) / (slvl + 10))
* - Duration: 400 frames (16.0s)
*/
export function calculateConversionStats(slvl: number): {
chancePct: number
durationFrames: number
durationSeconds: number
} {
const lvl = Math.max(1, slvl)
const chancePct = Math.floor((lvl * 50) / (lvl + 10))
return {
chancePct,
durationFrames: 400,
durationSeconds: 16,
}
}
/**
* 10. Holy Shield (117)
* - Duration: 750 + (slvl - 1) * 625 frames (30s + 25s/lvl)
* - Defense %: 25 + (slvl - 1) * 15% + Defiance (+15%/blvl)
* - Blocking %: 10 + Math.floor((slvl * 40) / (slvl + 10))% (dm56)
* - Flat Smite Damage: 3 + (slvl - 1) * 4 min / 6 + (slvl - 1) * 4 max (per 1.13c compute5BandScaling on Holy Shield min/maxDam)
*/
export function calculateHolyShieldStats(
slvl: number,
defianceBlvl: number = 0,
): {
durationFrames: number
defensePct: number
blockChancePct: number
smiteMinFlat: number
smiteMaxFlat: number
} {
const lvl = Math.max(1, slvl)
const durationFrames = 750 + (lvl - 1) * 625
const defensePct = 25 + (lvl - 1) * 15 + defianceBlvl * 15
const blockChancePct = computeDiminishingReturns(10, 40, lvl)
// Holy Shield flat smite min/max damage progression (Skills.txt: minDam=3, maxDam=6, levDam1=2..3 etc.)
// Let's use exact 5-band scaling: minDam=3, lev=2..3, maxDam=6, lev=2..3
// From 1.13c Skills.txt: minDam=3, minLevDam1=2, minLevDam2=3, minLevDam3=3, minLevDam4=4, minLevDam5=4
// maxDam=6, maxLevDam1=2, maxLevDam2=3, maxLevDam3=3, maxLevDam4=4, maxLevDam5=4
let smiteMinFlat = 3
let smiteMaxFlat = 6
if (lvl > 1) {
const b1 = Math.min(lvl, 8) - 1
smiteMinFlat += b1 * 2
smiteMaxFlat += b1 * 2
}
if (lvl > 8) {
const b2 = Math.min(lvl, 16) - 8
smiteMinFlat += b2 * 3
smiteMaxFlat += b2 * 3
}
if (lvl > 16) {
const b3 = Math.min(lvl, 22) - 16
smiteMinFlat += b3 * 4
smiteMaxFlat += b3 * 4
}
if (lvl > 22) {
const b4 = Math.min(lvl, 28) - 22
smiteMinFlat += b4 * 4
smiteMaxFlat += b4 * 4
}
if (lvl > 28) {
const b5 = lvl - 28
smiteMinFlat += b5 * 4
smiteMaxFlat += b5 * 4
}
return {
durationFrames,
defensePct,
blockChancePct,
smiteMinFlat,
smiteMaxFlat,
}
}
/**
* 11. Fist of the Heavens (121) Celestial Lightning Damage
* 5-band scaling:
* EMin: 150, EMinLev: 15, 30, 45, 55, 65
* EMax: 200, EMaxLev: 15, 30, 45, 55, 65
* Synergy: Holy Shock (+7%/blvl)
*/
export function calculateFistOfTheHeavensDamage(
slvl: number,
synergies?: { holyShock?: number },
): { lightningMin: number; lightningMax: number } {
const lvl = Math.max(1, slvl)
let min = 150
let max = 200
if (lvl > 1) {
const b1 = Math.min(lvl, 8) - 1
min += b1 * 15
max += b1 * 15
}
if (lvl > 8) {
const b2 = Math.min(lvl, 16) - 8
min += b2 * 30
max += b2 * 30
}
if (lvl > 16) {
const b3 = Math.min(lvl, 22) - 16
min += b3 * 45
max += b3 * 45
}
if (lvl > 22) {
const b4 = Math.min(lvl, 28) - 22
min += b4 * 55
max += b4 * 55
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * 65
max += b5 * 65
}
const holyShockBlvl = synergies?.holyShock ?? 0
const synBonusPct = holyShockBlvl * 7
const lightningMin = Math.trunc((min * (100 + synBonusPct)) / 100)
const lightningMax = Math.trunc((max * (100 + synBonusPct)) / 100)
return { lightningMin, lightningMax }
}
/**
* 12. Fist of the Heavens (121) Radial Holy Bolt Damage
* Base Holy Bolt from FoH:
* EMin: 8, EMinLev: 8, 10, 13, 16, 20
* EMax: 16, EMaxLev: 8, 11, 15, 18, 23
* Synergy: Holy Bolt (+15%/blvl)
*/
export function calculateFistOfTheHeavensHolyBoltDamage(
slvl: number,
holyBoltBlvl: number = 0,
): { holyBoltMin: number; holyBoltMax: number } {
const lvl = Math.max(1, slvl)
let min = 8
let max = 16
if (lvl > 1) {
const b1 = Math.min(lvl, 8) - 1
min += b1 * 8
max += b1 * 8
}
if (lvl > 8) {
const b2 = Math.min(lvl, 16) - 8
min += b2 * 10
max += b2 * 11
}
if (lvl > 16) {
const b3 = Math.min(lvl, 22) - 16
min += b3 * 13
max += b3 * 15
}
if (lvl > 22) {
const b4 = Math.min(lvl, 28) - 22
min += b4 * 16
max += b4 * 18
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * 20
max += b5 * 23
}
const synBonusPct = holyBoltBlvl * 15
const holyBoltMin = Math.trunc((min * (100 + synBonusPct)) / 100)
const holyBoltMax = Math.trunc((max * (100 + synBonusPct)) / 100)
return { holyBoltMin, holyBoltMax }
}
/** Helper 5-band scaling matching D2 1.13c Skills.txt (levels 1, 2..8, 9..16, 17..22, 23..28, 29+) */
function computeBand5(
lvl: number,
base: number,
lev1: number,
lev2: number,
lev3: number,
lev4: number,
lev5: number,
): number {
if (lvl <= 0) return 0
let total = base
if (lvl > 1) {
const b1 = Math.min(lvl, 8) - 1
total += b1 * lev1
}
if (lvl > 8) {
const b2 = Math.min(lvl, 16) - 8
total += b2 * lev2
}
if (lvl > 16) {
const b3 = Math.min(lvl, 22) - 16
total += b3 * lev3
}
if (lvl > 22) {
const b4 = Math.min(lvl, 28) - 22
total += b4 * lev4
}
if (lvl > 28) {
const b5 = lvl - 28
total += b5 * lev5
}
return total
}
/**
* Compute `D2Common` diminishing-returns formula `dm(a, b, lvl)`:
* `floor((110 * lvl * (b - a)) / (100 * (lvl + 6))) + a`
*/
function computeDiminishingReturns(a: number, b: number, lvl: number): number {
if (lvl <= 0) return a
const num = 110 * lvl * (b - a)
const den = 100 * (lvl + 6)
return Math.trunc(num / den) + a
}
// ============================================================================
// PALADIN OFFENSIVE AURAS (10 SKILLS) — 1.13c Ground Truth Calculators
// ============================================================================
/**
* 1. Might (Skill 98)
* - Party physical %ED: 40% + (slvl - 1) * 10%
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2) -> (slvl * 2 + 8) * 2 / 3 yards
*/
export function calculateMightStats(slvl: number): {
damagePercent: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const damagePercent = 40 + (lvl - 1) * 10
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return { damagePercent, radiusSubtiles, radiusYards }
}
/**
* 2. Holy Fire (Skill 102)
* - Radial fire pulse (every 50 ticks)
* - Weapon added fire damage: 6 * pulseMin .. 6 * pulseMax
* - Synergies: Resist Fire (+18%/blvl), Salvation (+6%/blvl)
* - 5-band scaling with hitShift = 7: EMin=2, EMax=6, Lev1..5=[1, 2, 3, 5, 7]
*/
export function calculateHolyFireStats(
slvl: number,
synergies?: { resistFire?: number; salvation?: number },
): {
pulseMin: number
pulseMax: number
pulseMin256: number
pulseMax256: number
weaponFireMin: number
weaponFireMax: number
synergyBonusPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const baseMin = computeBand5(lvl, 2, 1, 2, 3, 5, 7)
const baseMax = computeBand5(lvl, 6, 1, 2, 3, 5, 7)
const rfBlvl = synergies?.resistFire ?? 0
const salvBlvl = synergies?.salvation ?? 0
const synergyBonusPct = rfBlvl * 18 + salvBlvl * 6
// hitShift = 7 -> 256 fixed point is (base << 7)
const rawMin256 = baseMin << 7
const rawMax256 = baseMax << 7
const pulseMin256 = Math.trunc((rawMin256 * (100 + synergyBonusPct)) / 100)
const pulseMax256 = Math.trunc((rawMax256 * (100 + synergyBonusPct)) / 100)
const pulseMin = pulseMin256 >> 8
const pulseMax = pulseMax256 >> 8
// Param5 = 6 (weapon damage multiplier): enms * 6 / 256
const weaponFireMin = Math.trunc((pulseMin256 * 6) / 256)
const weaponFireMax = Math.trunc((pulseMax256 * 6) / 256)
const radiusSubtiles = 6 + (lvl - 1) * 1
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
pulseMin,
pulseMax,
pulseMin256,
pulseMax256,
weaponFireMin,
weaponFireMax,
synergyBonusPct,
radiusSubtiles,
radiusYards,
}
}
/**
* 3. Thorns (Skill 103)
* - Physical melee reflection: 250% + (slvl - 1) * 40%
* - Radius: 16 + (slvl - 1) * 2 subtiles
*/
export function calculateThornsStats(slvl: number): {
reflectPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const reflectPct = 250 + (lvl - 1) * 40
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return { reflectPct, radiusSubtiles, radiusYards }
}
/**
* 4. Blessed Aim (Skill 108)
* - Active party %AR: 75% + (slvl - 1) * 15%
* - Passive %AR: +5% per hard point (blvl * 5%) active even when inactive
* - Radius: 16 + (slvl - 1) * 2 subtiles
*/
export function calculateBlessedAimStats(
slvl: number,
blvl?: number,
): {
attackRatingPct: number
passiveArBonusPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const attackRatingPct = 75 + (lvl - 1) * 15
const hardPoints = blvl ?? lvl
const passiveArBonusPct = hardPoints * 5
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return { attackRatingPct, passiveArBonusPct, radiusSubtiles, radiusYards }
}
/**
* 5. Concentration (Skill 113)
* - Party physical %ED: 60% + (slvl - 1) * 15%
* - Uninterrupted attack chance: 20% (Param1 = 20)
* - Blessed Hammer synergy: +50% of active Concentration %ED (Math.trunc(damagePercent / 2))
* - Radius: 16 + (slvl - 1) * 2 subtiles
*/
export function calculateConcentrationStats(slvl: number): {
damagePercent: number
uninterruptedChancePct: number
blessedHammerBonusPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const damagePercent = 60 + (lvl - 1) * 15
const uninterruptedChancePct = 20
const blessedHammerBonusPct = Math.trunc(damagePercent / 2)
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
damagePercent,
uninterruptedChancePct,
blessedHammerBonusPct,
radiusSubtiles,
radiusYards,
}
}
/**
* 6. Holy Freeze (Skill 114)
* - Continuous enemy slow: 25%..54% (dm34 / dm56), bypasses Cannot Be Frozen (CBF)
* - Radial cold pulse (every 50 ticks)
* - Weapon added cold damage: 5 * pulseMin .. 5 * pulseMax
* - Added chill length: 30 + (slvl - 1) * 10 frames
* - Synergies: Resist Cold (+15%/blvl), Salvation (+7%/blvl)
* - 5-band scaling with hitShift = 8: EMin=2, EMax=3, Lev1..5=[1, 2, 3, 4, 5]
*/
export function calculateHolyFreezeStats(
slvl: number,
synergies?: { resistCold?: number; salvation?: number },
): {
slowPct: number
pulseMin: number
pulseMax: number
pulseMin256: number
pulseMax256: number
weaponColdMin: number
weaponColdMax: number
chillDurationFrames: number
chillDurationSeconds: number
synergyBonusPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const slowPct = Math.min(54, 25 + Math.floor((lvl * 35) / (lvl + 6)))
const baseMin = computeBand5(lvl, 2, 1, 2, 3, 4, 5)
const baseMax = computeBand5(lvl, 3, 1, 2, 3, 4, 5)
const rcBlvl = synergies?.resistCold ?? 0
const salvBlvl = synergies?.salvation ?? 0
const synergyBonusPct = rcBlvl * 15 + salvBlvl * 7
// hitShift = 8
const rawMin256 = baseMin << 8
const rawMax256 = baseMax << 8
const pulseMin256 = Math.trunc((rawMin256 * (100 + synergyBonusPct)) / 100)
const pulseMax256 = Math.trunc((rawMax256 * (100 + synergyBonusPct)) / 100)
const pulseMin = pulseMin256 >> 8
const pulseMax = pulseMax256 >> 8
// Param5 = 5 (weapon damage multiplier)
const weaponColdMin = Math.trunc((pulseMin256 * 5) / 256)
const weaponColdMax = Math.trunc((pulseMax256 * 5) / 256)
const chillDurationFrames = 30 + (lvl - 1) * 10
const chillDurationSeconds = Number((chillDurationFrames / 25).toFixed(2))
const radiusSubtiles = 6 + (lvl - 1) * 1
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
slowPct,
pulseMin,
pulseMax,
pulseMin256,
pulseMax256,
weaponColdMin,
weaponColdMax,
chillDurationFrames,
chillDurationSeconds,
synergyBonusPct,
radiusSubtiles,
radiusYards,
}
}
/**
* 7. Holy Shock (Skill 118)
* - Radial lightning pulse (every 50 ticks): min strictly 1, max scales Lev1..5=[6, 8, 10, 12, 15]
* - Weapon added lightning damage: min 1, max 6 * pulseMax
* - Synergies: Resist Lightning (+12%/blvl), Salvation (+4%/blvl)
* - hitShift = 8
*/
export function calculateHolyShockStats(
slvl: number,
synergies?: { resistLightning?: number; salvation?: number },
): {
pulseMin: number
pulseMax: number
pulseMin256: number
pulseMax256: number
weaponLightningMin: number
weaponLightningMax: number
synergyBonusPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const baseMin = 1
const baseMax = computeBand5(lvl, 10, 6, 8, 10, 12, 15)
const rlBlvl = synergies?.resistLightning ?? 0
const salvBlvl = synergies?.salvation ?? 0
const synergyBonusPct = rlBlvl * 12 + salvBlvl * 4
// hitShift = 8
const rawMin256 = baseMin << 8
const rawMax256 = baseMax << 8
const pulseMin256 = Math.trunc((rawMin256 * (100 + synergyBonusPct)) / 100)
const pulseMax256 = Math.trunc((rawMax256 * (100 + synergyBonusPct)) / 100)
const pulseMin = pulseMin256 >> 8
const pulseMax = pulseMax256 >> 8
// Param5 = 6 (weapon damage multiplier)
const weaponLightningMin = 1
const weaponLightningMax = Math.trunc((pulseMax256 * 6) / 256)
const radiusSubtiles = 6 + (lvl - 1) * 1
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
pulseMin,
pulseMax,
pulseMin256,
pulseMax256,
weaponLightningMin,
weaponLightningMax,
synergyBonusPct,
radiusSubtiles,
radiusYards,
}
}
/**
* 8. Sanctuary (Skill 119)
* - Radial magic pulse + knockback every 50 ticks strictly vs Undead
* - Direct weapon attacks ignore Undead physical resistance (damageresist = 0%, piercing immunity)
* - Direct weapon %ED vs Undead: 150% + (slvl - 1) * 30%
* - Direct weapon AR vs Undead: 100 + (slvl - 1) * 50
* - Synergy: Cleansing (+7%/blvl magic damage)
* - hitShift = 8: EMin=8 Lev1..5=[4, 4, 5, 5, 6], EMax=16 Lev1..5=[4, 5, 6, 6, 7]
*/
export function calculateSanctuaryStats(
slvl: number,
synergies?: { cleansing?: number },
): {
pulseMin: number
pulseMax: number
pulseMin256: number
pulseMax256: number
undeadDamagePercent: number
undeadAttackRating: number
synergyBonusPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const baseMin = computeBand5(lvl, 8, 4, 4, 5, 5, 6)
const baseMax = computeBand5(lvl, 16, 4, 5, 6, 6, 7)
const cleansingBlvl = synergies?.cleansing ?? 0
const synergyBonusPct = cleansingBlvl * 7
// hitShift = 8
const rawMin256 = baseMin << 8
const rawMax256 = baseMax << 8
const pulseMin256 = Math.trunc((rawMin256 * (100 + synergyBonusPct)) / 100)
const pulseMax256 = Math.trunc((rawMax256 * (100 + synergyBonusPct)) / 100)
const pulseMin = pulseMin256 >> 8
const pulseMax = pulseMax256 >> 8
const undeadDamagePercent = 150 + (lvl - 1) * 30
const undeadAttackRating = 100 + (lvl - 1) * 50
const radiusSubtiles = 5 + (lvl - 1) * 1
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
pulseMin,
pulseMax,
pulseMin256,
pulseMax256,
undeadDamagePercent,
undeadAttackRating,
synergyBonusPct,
radiusSubtiles,
radiusYards,
}
}
/**
* 9. Fanaticism (Skill 122)
* - Caster IAS: 14 + Math.floor((slvl * 25) / (slvl + 7)) % (17%..32%)
* - Attack Rating: 40% + (slvl - 1) * 5%
* - Caster physical %ED: 50% + (slvl - 1) * 17%
* - Party physical %ED: strictly HALF of caster %ED (Math.trunc(casterDamagePercent / 2))
* - Radius: 11 + (slvl - 1) * 1 subtiles
*/
export function calculateFanaticismStats(slvl: number): {
casterIas: number
attackRatingPct: number
casterDamagePercent: number
partyDamagePercent: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const casterIas = 14 + Math.floor((lvl * 25) / (lvl + 7))
const attackRatingPct = 40 + (lvl - 1) * 5
const casterDamagePercent = 50 + (lvl - 1) * 17
const partyDamagePercent = Math.trunc(casterDamagePercent / 2)
const radiusSubtiles = 11 + (lvl - 1) * 1
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
casterIas,
attackRatingPct,
casterDamagePercent,
partyDamagePercent,
radiusSubtiles,
radiusYards,
}
}
/**
* 10. Conviction (Skill 123)
* - Defense reduction %: -(30 + (slvl - 1) * 5)%, capped at -90%
* - Elemental resistance reduction (Fire, Cold, Lightning): -(30 + (slvl - 1) * 5)%, capped at -150%
* - 1.13c Immunity breaking: applies at 1/5th effectiveness (-6%..-30%) against elemental immunities
* - Does NOT affect Poison or Magic resistance!
* - Radius: fixed 20 subtiles
*/
export function calculateConvictionStats(slvl: number): {
defenseReductionPct: number
resistanceReductionPct: number
immunityBreakingReductionPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const defenseReductionPct = Math.min(90, 30 + (lvl - 1) * 5)
const resistanceReductionPct = Math.min(150, 30 + (lvl - 1) * 5)
const immunityBreakingReductionPct = Math.trunc(resistanceReductionPct / 5)
const radiusSubtiles = 20
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
defenseReductionPct,
resistanceReductionPct,
immunityBreakingReductionPct,
radiusSubtiles,
radiusYards,
}
}
// ============================================================================
// PALADIN DEFENSIVE AURAS (10 SKILLS) — 1.13c Ground Truth Calculators
// ============================================================================
/**
* 1. Prayer (Skill 99)
* - Periodic heal pulse (every 50 ticks): edns = computeBand5(slvl, 2, 1, 1, 2, 2, 3) HP
* (slvl 1: 2 HP, slvl 10: 11 HP, slvl 20: 25 HP)
* - Mana cost: 16 units of mana256/frame (1/16 mana/frame = 1.5625 mana/second at 25 fps)
* - Auto-deactivates when mana is depleted (< 16 mana256)
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
*/
export function calculatePrayerStats(slvl: number): {
healAmount: number
healAmount256: number
manaCostPerFrame256: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const healAmount = computeBand5(lvl, 2, 1, 1, 2, 2, 3)
const healAmount256 = healAmount << 8
const manaCostPerFrame256 = 16
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
healAmount,
healAmount256,
manaCostPerFrame256,
radiusSubtiles,
radiusYards,
}
}
/**
* 2. Resist Fire (Skill 100)
* - Active party fire res: dm34 = floor((110 * slvl * (150 - 35)) / (100 * (slvl + 6))) + 35
* (+53% at slvl 1, +114% at slvl 10, +132% at slvl 20)
* - Passive max fire res: +1% per 2 base skill points (hard points) = Math.floor((blvl ?? slvl) / 2)
* (Active permanently even when aura is off, capped at 95%)
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
*/
export function calculateResistFireStats(
slvl: number,
blvl?: number,
): {
fireResistPct: number
maxFireResistBonusPct: number
passiveMaxFireResistPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const fireResistPct = computeDiminishingReturns(35, 150, lvl)
const basePoints = blvl !== undefined ? Math.max(0, blvl) : lvl
const passiveMaxFireResistPct = Math.floor(basePoints / 2)
const maxFireResistBonusPct = passiveMaxFireResistPct
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
fireResistPct,
maxFireResistBonusPct,
passiveMaxFireResistPct,
radiusSubtiles,
radiusYards,
}
}
/**
* 3. Defiance (Skill 104)
* - Active party %Defense: ln34 = 70 + (slvl - 1) * 10 (+70% at slvl 1, +160% at slvl 10, +260% at slvl 20)
* - Synergizes Holy Shield (+15% defense per hard point)
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
*/
export function calculateDefianceStats(slvl: number): {
defensePercent: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const defensePercent = 70 + (lvl - 1) * 10
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
defensePercent,
radiusSubtiles,
radiusYards,
}
}
/**
* 4. Resist Cold (Skill 105)
* - Active party cold res: dm34 = floor((110 * slvl * (150 - 35)) / (100 * (slvl + 6))) + 35
* (+53% at slvl 1, +114% at slvl 10, +132% at slvl 20)
* - Passive max cold res: +1% per 2 base skill points (hard points) = Math.floor((blvl ?? slvl) / 2)
* (Active permanently even when aura is off, capped at 95%)
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
*/
export function calculateResistColdStats(
slvl: number,
blvl?: number,
): {
coldResistPct: number
maxColdResistBonusPct: number
passiveMaxColdResistPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const coldResistPct = computeDiminishingReturns(35, 150, lvl)
const basePoints = blvl !== undefined ? Math.max(0, blvl) : lvl
const passiveMaxColdResistPct = Math.floor(basePoints / 2)
const maxColdResistBonusPct = passiveMaxColdResistPct
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
coldResistPct,
maxColdResistBonusPct,
passiveMaxColdResistPct,
radiusSubtiles,
radiusYards,
}
}
/**
* 5. Cleansing (Skill 109)
* - Curse and poison duration reduction: dm34 (from Skills.txt item_poisonlengthresist = 100 - dm34, dm34 is 30..90)
* dm(30, 90, slvl): 39% at slvl 1, 71% at slvl 10, 80% at slvl 20
* - Synergy: Free Prayer heal pulse (at Prayer slvl efficiency, 0 mana cost)
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
*/
export function calculateCleansingStats(
slvl: number,
prayerBlvl?: number,
): {
curseReductionPct: number
poisonLengthReductionPct: number
prayerHealAmount: number
prayerHealAmount256: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const curseReductionPct = computeDiminishingReturns(30, 90, lvl)
const poisonLengthReductionPct = curseReductionPct
const pBlvl = prayerBlvl !== undefined ? Math.max(0, prayerBlvl) : 0
const prayerHealAmount = pBlvl > 0 ? computeBand5(pBlvl, 2, 1, 1, 2, 2, 3) : 0
const prayerHealAmount256 = prayerHealAmount << 8
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
curseReductionPct,
poisonLengthReductionPct,
prayerHealAmount,
prayerHealAmount256,
radiusSubtiles,
radiusYards,
}
}
/**
* 6. Resist Lightning (Skill 110)
* - Active party lightning res: dm34 = floor((110 * slvl * (150 - 35)) / (100 * (slvl + 6))) + 35
* (+53% at slvl 1, +114% at slvl 10, +132% at slvl 20)
* - Passive max lightning res: +1% per 2 base skill points (hard points) = Math.floor((blvl ?? slvl) / 2)
* (Active permanently even when aura is off, capped at 95%)
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
*/
export function calculateResistLightningStats(
slvl: number,
blvl?: number,
): {
lightningResistPct: number
maxLightningResistBonusPct: number
passiveMaxLightningResistPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const lightningResistPct = computeDiminishingReturns(35, 150, lvl)
const basePoints = blvl !== undefined ? Math.max(0, blvl) : lvl
const passiveMaxLightningResistPct = Math.floor(basePoints / 2)
const maxLightningResistBonusPct = passiveMaxLightningResistPct
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
lightningResistPct,
maxLightningResistBonusPct,
passiveMaxLightningResistPct,
radiusSubtiles,
radiusYards,
}
}
/**
* 7. Vigor (Skill 115)
* - Faster Run/Walk (velocitypercent): dm56 = floor((110 * slvl * (50 - 7)) / (100 * (slvl + 6))) + 7
* (+13% at slvl 1, +36% at slvl 10, +43% at slvl 20)
* - Stamina recovery bonus (staminarecoverybonus): ln34 = 50 + (slvl - 1) * 25
* (+50% at slvl 1, +275% at slvl 10, +525% at slvl 20)
* - Max stamina % (skill_staminapercent): ln34 = 50 + (slvl - 1) * 25
* - Radius: ln12 subtiles (16 + (slvl - 1) * 3)
*/
export function calculateVigorStats(slvl: number): {
velocityPercent: number
staminaRecoveryBonusPct: number
maxStaminaPercent: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const velocityPercent = computeDiminishingReturns(7, 50, lvl)
const staminaRecoveryBonusPct = 50 + (lvl - 1) * 25
const maxStaminaPercent = 50 + (lvl - 1) * 25
const radiusSubtiles = 16 + (lvl - 1) * 3
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
velocityPercent,
staminaRecoveryBonusPct,
maxStaminaPercent,
radiusSubtiles,
radiusYards,
}
}
/**
* 8. Meditation (Skill 120)
* - Active party mana recovery rate: ln34 = 300 + (slvl - 1) * 25
* (+300% at slvl 1, +525% at slvl 10, +775% at slvl 20)
* - Synergy: Free Prayer heal pulse (at Prayer slvl efficiency, 0 mana cost)
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
*/
export function calculateMeditationStats(
slvl: number,
prayerBlvl?: number,
): {
manaRecoveryBonusPct: number
prayerHealAmount: number
prayerHealAmount256: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const manaRecoveryBonusPct = 300 + (lvl - 1) * 25
const pBlvl = prayerBlvl !== undefined ? Math.max(0, prayerBlvl) : 0
const prayerHealAmount = pBlvl > 0 ? computeBand5(pBlvl, 2, 1, 1, 2, 2, 3) : 0
const prayerHealAmount256 = prayerHealAmount << 8
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
manaRecoveryBonusPct,
prayerHealAmount,
prayerHealAmount256,
radiusSubtiles,
radiusYards,
}
}
/**
* 9. Redemption (Skill 124)
* - 50-tick cadence, fixed 16 subtiles radius in 2:1 isometric ground space
* - Redemption chance: dm34 = floor((110 * slvl * (100 - 10)) / (100 * (slvl + 6))) + 10
* (24% at slvl 1, 71% at slvl 10, 86% at slvl 20)
* - Life and Mana replenishment: ln56 = 25 + (slvl - 1) * 5 (25 at slvl 1, 70 at slvl 10, 120 at slvl 20)
* - Consumes valid monster corpses, marks corpse.consumed = true immediately, caster-only restore
*/
export function calculateRedemptionStats(slvl: number): {
redemptionChancePct: number
lifeRecoverPts: number
manaRecoverPts: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const redemptionChancePct = computeDiminishingReturns(10, 100, lvl)
const lifeRecoverPts = 25 + (lvl - 1) * 5
const manaRecoverPts = lifeRecoverPts
const radiusSubtiles = 16
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
redemptionChancePct,
lifeRecoverPts,
manaRecoverPts,
radiusSubtiles,
radiusYards,
}
}
/**
* 10. Salvation (Skill 125)
* - Active party All Resistances (Fire, Cold, Lightning): dm34 = floor((110 * slvl * (120 - 50)) / (100 * (slvl + 6))) + 50
* (61% at slvl 1, 98% at slvl 10, 109% at slvl 20)
* - Strictly zero effect on Poison or Magic resistance!
* - Synergizes Holy Fire (+6%), Holy Freeze (+7%), Holy Shock (+4%)
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2)
*/
export function calculateSalvationStats(slvl: number): {
fireResistPct: number
coldResistPct: number
lightningResistPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const resPct = computeDiminishingReturns(50, 120, lvl)
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
fireResistPct: resPct,
coldResistPct: resPct,
lightningResistPct: resPct,
radiusSubtiles,
radiusYards,
}
}