diablo2-web/src/game/skills.ts

833 lines
35 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 } from './tables.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
}
/** 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],
]
/**
* 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.
* @returns the definition.
*/
export function skillFromRow(
row: Readonly<Record<string, string>>,
rowIndex: number,
text: TextSource,
): SkillDef {
const id = textCell(row, 'Id', textCell(row, 'skill', `skill${String(rowIndex)}`))
const rawName = textCell(row, 'Name', textCell(row, 'skilldesc', id))
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: Math.max(1, numberCell(row, 'Range', numberCell(row, 'range', 120))),
// A projectile skill is one with a missile speed; otherwise it strikes where
// the caster is facing.
projectile: numberCell(row, 'Speed', 0) > 0,
speed: Math.max(0, numberCell(row, 'Speed', 0)),
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.
* @returns the definitions, in table order.
*/
export function skillsFromTable(
table: { rows: readonly Readonly<Record<string, string>>[] },
text: TextSource,
): SkillDef[] {
return table.rows.map((row, index) => skillFromRow(row, index, text))
}
/**
* 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,
},
}
}
/** 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 }[]
/** Projectiles that died on a wall or expired. */
readonly expired: number
/** Projectiles that stopped against terrain. */
readonly wallHits: number
}
/**
* 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 }[] = []
let expired = 0
let wallHits = 0
for (const projectile of projectiles) {
if (projectile.ttl <= 0) { expired += 1; continue }
const x = projectile.x + projectile.vx
const y = projectile.y + projectile.vy
const moved: Projectile = { ...projectile, x, y, ttl: projectile.ttl - 1 }
// Walls first: a projectile dies against missile barriers (solid walls, closed doors)
// while passing freely over ground-only barriers (rivers, lava, chasms) via COLLIDE_MASK_MISSILE.
const blockedByTerrain =
options.isMissileBlocked !== undefined
? options.isMissileBlocked(x, y, projectile.x, projectile.y)
: options.overlap(x, y) > 0
if (blockedByTerrain) { wallHits += 1; continue }
let struck = false
for (const target of targets) {
if (!target.alive) continue
if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue
hits.push({ targetIndex: target.index, damage: projectile.damage, x, y })
struck = true
break
}
if (!struck) alive.push(moved)
}
return { alive, hits, expired, wallHits }
}
// ============================================================================
// 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<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: 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: 400, speed: 450 },
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<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),
}
}
}
}