/** * 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' /** 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 } /** 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 | 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 /** Target restriction: only hits undead monsters. */ readonly isUndeadOnly?: boolean | 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 } /** Canonical 1.13c Ground Truth Missiles.txt defaults. */ export const CANONICAL_113C_MISSILES: Readonly> = Object.freeze({ 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, 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: 'fireexplode', light: 7, red: 255, green: 178, blue: 64, speedPxPerSec: 500, 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, distancePx: 0, }, explodingarrowexp: { name: 'explodingarrowexp', id: 47, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, distancePx: 750, }, }) /** * Parses raw Missiles.txt text into a Map of missile records. */ export function parseMissilesTxt(content: string): Map { const map = new Map() 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') 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 = Number(cells[redIdx] ?? 255) || 255 const green = Number(cells[greenIdx] ?? 255) || 255 const blue = Number(cells[blueIdx] ?? 255) || 255 const rec: MissileTxtData = { name, id: Number(cells[idIdx] ?? 0) || 0, vel, maxVel, range, levRange, celFile, animLen, animSpeed, loopAnim, numDirections, explosionMissile, light, red, green, blue, speedPxPerSec: vel * 25, distancePx: vel * range, } map.set(name, rec) } return map } /** * Extracts missile speed and flight distance from a Missiles.txt table or parsed records. */ export function getMissileTxtData( missileName: string, missilesSource?: | Map | { rows: readonly Readonly>[] } | { 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, 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 = Number(row[t.header.indexOf('Red')] ?? 255) || 255 const green = Number(row[t.header.indexOf('Green')] ?? 255) || 255 const blue = Number(row[t.header.indexOf('Blue')] ?? 255) || 255 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, speedPxPerSec: vel * 25, distancePx: vel * range, } } } } // DataTable ({ rows: Record[] }) if ('rows' in missilesSource && !('header' in missilesSource)) { const tableRows = missilesSource.rows as readonly Readonly>[] 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 = Number(row['Red'] ?? row['red'] ?? 255) || 255 const green = Number(row['Green'] ?? row['green'] ?? 255) || 255 const blue = Number(row['Blue'] ?? row['blue'] ?? 255) || 255 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, speedPxPerSec: vel * 25, distancePx: vel * range, } } } return getMissileTxtData(missileName, 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>, rowIndex: number, text: TextSource, missilesSource?: | Map | { rows: readonly Readonly>[] } | { 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 } 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))), } } /** * 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>[] }, text: TextSource, missilesSource?: | Map | { rows: readonly Readonly>[] } | { header: readonly string[]; rows: readonly (readonly string[])[] } | string | undefined, ): SkillDef[] { return table.rows.map((row, index) => skillFromRow(row, index, text, missilesSource)) } /** * 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 }[] | undefined } /** * 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 }, ): ProjectileOutcome { const radius = options.radius ?? 8 const alive: Projectile[] = [] 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 }[] = [] let expired = 0 let wallHits = 0 for (const projectile of projectiles) { if (projectile.ttl <= 0) { expired += 1; continue } let vx = projectile.vx let vy = projectile.vy let x = projectile.x + vx let y = projectile.y + vy let spiral = projectile.spiral // 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. if (spiral !== undefined) { 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) { // 3. Jitter trajectory (e.g. Charged Bolt) - sinusoidal perpendicular wiggle on the ground plane const perpX = -vy const perpY = vx const speed = Math.hypot(vx, vy) if (speed > 0) { const wobble = Math.sin(projectile.ttl * 0.8) * 1.5 x += (perpX / speed) * wobble y += (perpY / speed) * (wobble * ISO_GROUND_ASPECT_RATIO) } } const hitTargets = projectile.hitTargets ?? new Set() const moved: Projectile = { ...projectile, x, y, vx, vy, spiral, hitTargets, ttl: projectile.ttl - 1, } // Walls first: a projectile dies against missile barriers (solid walls, closed doors) const blockedByTerrain = options.isMissileBlocked !== undefined ? options.isMissileBlocked(x, y, projectile.x, projectile.y) : options.overlap(x, y) > 0 if (blockedByTerrain) { wallHits += 1 wallHitEvents.push({ x, y, skillId: projectile.skillId, missileType: projectile.missileType, aoeRadius: projectile.aoeRadius, }) continue } let struck = false for (const target of targets) { if (!target.alive) continue if (hitTargets.has(target.index)) continue // already struck by piercing projectile if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue hits.push({ targetIndex: target.index, damage: projectile.damage, x, y, skillId: projectile.skillId, missileType: projectile.missileType, aoeRadius: projectile.aoeRadius, statusEffect: projectile.statusEffect, statusDuration: projectile.statusDuration, isUndeadOnly: projectile.isUndeadOnly, }) hitTargets.add(target.index) if (!projectile.pierce) { struck = true break } } if (!struck) { alive.push(moved) } } return { alive, hits, expired, wallHits, wallHitEvents } } // ============================================================================ // 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 srcDamage: number readonly range?: number readonly speed?: number } export const BATCH1_SKILLS: Readonly> = 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: 4, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 1, emax: 4, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 128, range: 480, speed: 600 }, 11: { id: 11, name: 'Cold Arrow', classCode: 'ama', charClass: 'amazon', type: 'projectile', mana: 28, lvlmana: 4, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 3, emax: 4, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 128, range: 480, speed: 600 }, 10: { id: 10, name: 'Jab', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 16, lvlmana: 2, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 }, 14: { id: 14, name: 'Power Strike', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 16, lvlmana: 2, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 1, emax: 16, elev1: 8, elev2: 12, elev3: 16, elev4: 20, elev5: 24, 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: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, srcDamage: 0, range: 1000, speed: 500 }, 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: 6, lvlmana: 0, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 6, emax: 10, elev1: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, srcDamage: 0, range: 400, speed: 450 }, 44: { id: 44, name: 'Frost Nova', classCode: 'sor', charClass: 'sorceress', type: 'area', mana: 9, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 2, emax: 4, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 0 }, 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: 6, elev2: 12, elev3: 18, elev4: 24, elev5: 30, srcDamage: 0, range: 450, speed: 450 }, 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 }, // 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: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, 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: 0, elev3: 0, elev4: 0, elev5: 0, 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 }, 97: { id: 97, name: 'Smite', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 2, lvlmana: 0, minmana: 2, 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: 16, lvlmana: 1, minmana: 1, manashift: 5, delay: 0, hitshift: 8, emin: 8, emax: 16, elev1: 4, elev2: 8, elev3: 12, elev4: 16, elev5: 20, srcDamage: 0, range: 450, speed: 450 }, 106: { id: 106, name: 'Zeal', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 2, lvlmana: 0, minmana: 2, 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: 12, elev3: 16, elev4: 20, elev5: 24, 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 }, // Barbarian (6 skills) 126: { id: 126, name: 'Bash', classCode: 'bar', charClass: 'barbarian', type: 'melee', mana: 2, lvlmana: 0, minmana: 2, 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: 2, 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: 8, emin: 1, emax: 3, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, 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: 3, elev2: 6, elev3: 9, elev4: 12, elev5: 15, srcDamage: 0, range: 450, speed: 300 }, // Assassin (5 skills) 251: { id: 251, name: 'Fire Blast', classCode: 'ass', charClass: 'assassin', type: 'projectile', mana: 24, lvlmana: 4, minmana: 1, manashift: 5, delay: 0, hitshift: 8, emin: 3, emax: 4, elev1: 2, elev2: 3, elev3: 4, elev4: 5, elev5: 6, srcDamage: 0, range: 350, speed: 400 }, 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 }, 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 }, 261: { id: 261, name: 'Charged Bolt Sentry', classCode: 'ass', charClass: 'assassin', type: 'summon', mana: 13, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 1, emax: 7, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 0 }, 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 }, }) /** * 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 { const def = getBatch1SkillDef(skillId) if (!def) 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) * - All other Batch 1 skills: 0 frames */ export function getSkillCooldownTicks(skillId: number): number { 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, effectiveLevel: number, synergyMultiplier: number = 1.0, weaponDamage?: { min: number; max: number } ): { min: number; max: number } { const def = getBatch1SkillDef(skillId) 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(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 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 b1 = Math.min(lvl - 1, 7) min += b1 * def.elev1 max += b1 * def.elev1 if (lvl > 8) { const b2 = Math.min(lvl - 8, 8) min += b2 * def.elev2 max += b2 * def.elev2 } if (lvl > 16) { const b3 = Math.min(lvl - 16, 6) min += b3 * def.elev3 max += b3 * def.elev3 } if (lvl > 22) { const b4 = Math.min(lvl - 22, 6) min += b4 * def.elev4 max += b4 * def.elev4 } if (lvl > 28) { const b5 = lvl - 28 min += b5 * def.elev5 max += b5 * def.elev5 } } // 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 = Number.isFinite(synergyMultiplier) && synergyMultiplier > 0 ? synergyMultiplier : 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 } } /** 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> } | { readonly kind: 'curse' readonly skillId: number readonly skillName: string readonly radius: number readonly durationSeconds: number readonly manaCost: number readonly cooldownTicks: number readonly debuffEffect: Readonly> } | { 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> } /** * 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 = {} 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 = {} 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 = {} 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), } } } }