1040 lines
34 KiB
TypeScript
1040 lines
34 KiB
TypeScript
/**
|
||
* Diablo II v1.13c Skill Calculator Engine.
|
||
*
|
||
* Implements:
|
||
* - Full 210-skill allocation state machine with prerequisite validation and orphan protection.
|
||
* - 1.13c base-point synergy multiplier calculation (blvl 0..20 invariant).
|
||
* - Mastery amplification (Sorceress Fire/Lightning/Cold Masteries, Paladin Concentration for Hammer, etc.).
|
||
* - 5-level-band damage progression with HitShift precision scaling.
|
||
* - 24.8 fixed-point mana cost formulas with minmana clamping.
|
||
* - Localized tooltip data generation for both Chinese and English.
|
||
* - Build URL hash serialization and deserialization.
|
||
*/
|
||
|
||
import type { CharacterClassCode } from './classes.ts'
|
||
import { compute3BandDuration } from './engine/calc-ast.ts'
|
||
import {
|
||
SKILLS_CATALOG,
|
||
SKILLS_BY_ID,
|
||
SKILLS_BY_CLASS,
|
||
type SkillCatalogEntry,
|
||
type SynergyBonusInfo,
|
||
type DescLineDefinition,
|
||
} from '../data/skills-catalog.ts'
|
||
|
||
export interface SkillCalcState {
|
||
classCode: CharacterClassCode
|
||
hardPoints: Record<number, number> // skillId -> 0..20
|
||
tabSoftPoints: Record<number, number> // tabIndex 0..2 -> 0..99
|
||
allSkillsSoftPoints: number // 0..99
|
||
specificSoftPoints: Record<number, number> // skillId -> 0..99
|
||
}
|
||
|
||
export function createInitialState(classCode: CharacterClassCode = 'ama'): SkillCalcState {
|
||
return {
|
||
classCode,
|
||
hardPoints: {},
|
||
tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
|
||
allSkillsSoftPoints: 0,
|
||
specificSoftPoints: {},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns effective skill level (hard points + soft points).
|
||
* In Diablo II, an uninvested skill has effective level 0 (unless granted as an oskill).
|
||
*/
|
||
export function getEffectiveLevel(state: SkillCalcState, skillId: number): number {
|
||
const hard = state.hardPoints[skillId] ?? 0
|
||
const specSoft = state.specificSoftPoints[skillId] ?? 0
|
||
if (hard === 0 && specSoft === 0) return 0
|
||
|
||
const skill = SKILLS_BY_ID[skillId]
|
||
if (!skill) return hard
|
||
const tabSoft = state.tabSoftPoints[skill.tabIndex] ?? 0
|
||
const allSoft = state.allSkillsSoftPoints ?? 0
|
||
return hard + tabSoft + allSoft + specSoft
|
||
}
|
||
|
||
/**
|
||
* Returns total hard points invested in the build.
|
||
*/
|
||
export function getTotalPointsSpent(state: SkillCalcState): number {
|
||
return Object.values(state.hardPoints).reduce((sum, pts) => sum + (pts || 0), 0)
|
||
}
|
||
|
||
/**
|
||
* Returns hard points invested in a specific skill tree tab (0..2).
|
||
*/
|
||
export function getTabPointsSpent(state: SkillCalcState, tabIndex: number): number {
|
||
let sum = 0
|
||
for (const [idStr, pts] of Object.entries(state.hardPoints)) {
|
||
const id = Number(idStr)
|
||
const skill = SKILLS_BY_ID[id]
|
||
if (skill && skill.tabIndex === tabIndex) {
|
||
sum += pts || 0
|
||
}
|
||
}
|
||
return sum
|
||
}
|
||
|
||
/**
|
||
* Calculates the minimum character level required for the current allocated build.
|
||
* - Takes into account the highest required level for any skill with points.
|
||
* - Takes into account total points spent: 1 point at lvl 1, plus 1 point per lvl up to 99 (98 points) + 12 quest points = 110 total points.
|
||
*/
|
||
export function getRequiredCharacterLevel(state: SkillCalcState): number {
|
||
let minLevel = 1
|
||
for (const [idStr, pts] of Object.entries(state.hardPoints)) {
|
||
if (!pts || pts <= 0) continue
|
||
const id = Number(idStr)
|
||
const skill = SKILLS_BY_ID[id]
|
||
if (!skill) continue
|
||
// In D2, each hard point in a skill requires +1 character level:
|
||
// e.g. Level 1 skill with 20 points requires at least level 20.
|
||
// Level 30 skill with 20 points requires at least level 49 (30 + 19).
|
||
const skillReq = skill.reqlevel + (pts - 1)
|
||
if (skillReq > minLevel) minLevel = skillReq
|
||
}
|
||
|
||
// Also point quantity constraint: points > 12 quests means level >= points - 12 + 1
|
||
const total = getTotalPointsSpent(state)
|
||
const levelFromPoints = Math.max(1, total > 12 ? total - 12 + 1 : 1)
|
||
minLevel = Math.max(minLevel, levelFromPoints)
|
||
|
||
return Math.min(99, minLevel)
|
||
}
|
||
|
||
/**
|
||
* Checks if a skill point can be allocated to skillId.
|
||
*/
|
||
export function canAllocate(state: SkillCalcState, skillId: number): boolean {
|
||
const skill = SKILLS_BY_ID[skillId]
|
||
if (!skill || skill.classCode !== state.classCode) return false
|
||
|
||
const currentHard = state.hardPoints[skillId] ?? 0
|
||
if (currentHard >= 20) return false
|
||
|
||
// Max build points limit: 110 points
|
||
if (getTotalPointsSpent(state) >= 110) return false
|
||
|
||
// Prerequisite check: all reqskills must have at least 1 hard point
|
||
for (const reqId of skill.reqskills) {
|
||
if ((state.hardPoints[reqId] ?? 0) < 1) return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* Allocates 1 point to skillId. Returns true if successful.
|
||
*/
|
||
export function allocatePoint(state: SkillCalcState, skillId: number, count: number = 1): boolean {
|
||
let changed = false
|
||
for (let i = 0; i < count; i++) {
|
||
if (!canAllocate(state, skillId)) break
|
||
state.hardPoints[skillId] = (state.hardPoints[skillId] ?? 0) + 1
|
||
changed = true
|
||
}
|
||
return changed
|
||
}
|
||
|
||
/**
|
||
* Checks if a skill point can be deallocated from skillId without breaking prerequisite chains (orphan protection).
|
||
*/
|
||
export function canDeallocate(state: SkillCalcState, skillId: number): boolean {
|
||
const currentHard = state.hardPoints[skillId] ?? 0
|
||
if (currentHard <= 0) return false
|
||
|
||
// If we have more than 1 point, reducing to >= 1 never breaks prerequisites
|
||
if (currentHard > 1) return true
|
||
|
||
// If reducing from 1 to 0, no dependent skills may have allocated points
|
||
// BFS check over all reachable dependents
|
||
const queue = [...(SKILLS_BY_ID[skillId]?.dependents ?? [])]
|
||
const visited = new Set<number>()
|
||
|
||
while (queue.length > 0) {
|
||
const depId = queue.shift()!
|
||
if (visited.has(depId)) continue
|
||
visited.add(depId)
|
||
|
||
if ((state.hardPoints[depId] ?? 0) > 0) {
|
||
return false // An active dependent relies on this prerequisite!
|
||
}
|
||
|
||
const depSkill = SKILLS_BY_ID[depId]
|
||
if (depSkill) {
|
||
for (const nextDep of depSkill.dependents) {
|
||
if (!visited.has(nextDep)) queue.push(nextDep)
|
||
}
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* Deallocates 1 point from skillId. Returns true if successful.
|
||
*/
|
||
export function deallocatePoint(state: SkillCalcState, skillId: number, count: number = 1): boolean {
|
||
let changed = false
|
||
for (let i = 0; i < count; i++) {
|
||
if (!canDeallocate(state, skillId)) break
|
||
state.hardPoints[skillId] = (state.hardPoints[skillId] ?? 0) - 1
|
||
if (state.hardPoints[skillId] === 0) {
|
||
delete state.hardPoints[skillId]
|
||
}
|
||
changed = true
|
||
}
|
||
return changed
|
||
}
|
||
|
||
/**
|
||
* Resets all skill points (Respec).
|
||
*/
|
||
export function respec(state: SkillCalcState): number {
|
||
const refunded = getTotalPointsSpent(state)
|
||
state.hardPoints = {}
|
||
return refunded
|
||
}
|
||
|
||
// ============================================================================
|
||
// Synergy & Mastery Calculation Engine
|
||
// ============================================================================
|
||
|
||
export interface SynergyCalculationResult {
|
||
readonly list: readonly {
|
||
readonly providerId: number
|
||
readonly providerName: string
|
||
readonly providerNameZh: string
|
||
readonly percentPerLevel: number
|
||
readonly hardPoints: number
|
||
readonly currentBonusPct: number
|
||
}[]
|
||
readonly totalPercent: number
|
||
readonly multiplier: number
|
||
}
|
||
|
||
/**
|
||
* Evaluates the synergy bonuses for a skill based strictly on provider hard points (blvl 0..20).
|
||
*/
|
||
export function evaluateSynergies(state: SkillCalcState, skill: SkillCatalogEntry): SynergyCalculationResult {
|
||
if (!skill.synergiesReceived || skill.synergiesReceived.length === 0) {
|
||
return { list: [], totalPercent: 0, multiplier: 1.0 }
|
||
}
|
||
|
||
let totalPercent = 0
|
||
const list = skill.synergiesReceived.map(syn => {
|
||
const providerSkill = SKILLS_BY_ID[syn.providerId]
|
||
const hardPts = state.hardPoints[syn.providerId] ?? 0
|
||
const bonus = hardPts * syn.percentPerLevel
|
||
totalPercent += bonus
|
||
return {
|
||
providerId: syn.providerId,
|
||
providerName: syn.providerName || providerSkill?.name || `Skill ${syn.providerId}`,
|
||
providerNameZh: providerSkill?.nameZh || syn.providerName || `技能 ${syn.providerId}`,
|
||
percentPerLevel: syn.percentPerLevel,
|
||
hardPoints: hardPts,
|
||
currentBonusPct: bonus,
|
||
}
|
||
})
|
||
|
||
return {
|
||
list,
|
||
totalPercent,
|
||
multiplier: Number((1.0 + totalPercent / 100).toFixed(4)),
|
||
}
|
||
}
|
||
|
||
export interface MasteryCalculationResult {
|
||
readonly active: boolean
|
||
readonly masteryId: number
|
||
readonly masteryName: string
|
||
readonly masteryNameZh: string
|
||
readonly effectiveLevel: number
|
||
readonly bonusPercent: number
|
||
readonly multiplier: number
|
||
readonly descriptionZh: string
|
||
readonly descriptionEn: string
|
||
}
|
||
|
||
/**
|
||
* Evaluates mastery bonuses applicable to a skill (e.g. Fire/Lightning/Cold Masteries, Concentration for Hammer, Claw Mastery).
|
||
*/
|
||
export function evaluateMasteries(state: SkillCalcState, skill: SkillCatalogEntry): MasteryCalculationResult | null {
|
||
if (!skill.masteries || skill.masteries.length === 0) return null
|
||
|
||
// Check Sorceress Masteries
|
||
if (skill.classCode === 'sor') {
|
||
if (skill.masteries.includes(61)) {
|
||
// Fire Mastery
|
||
const mastery = SKILLS_BY_ID[61]
|
||
const slvl = getEffectiveLevel(state, 61)
|
||
if (slvl > 0 && mastery) {
|
||
const pct = 30 + 7 * (slvl - 1)
|
||
return {
|
||
active: true,
|
||
masteryId: 61,
|
||
masteryName: 'Fire Mastery',
|
||
masteryNameZh: '火焰精通',
|
||
effectiveLevel: slvl,
|
||
bonusPercent: pct,
|
||
multiplier: Number((1.0 + pct / 100).toFixed(4)),
|
||
descriptionZh: `+${pct}% 火焰伤害 (来自 ${slvl} 级火焰精通)`,
|
||
descriptionEn: `+${pct}% Fire Damage (from Level ${slvl} Fire Mastery)`,
|
||
}
|
||
}
|
||
}
|
||
|
||
if (skill.masteries.includes(63)) {
|
||
// Lightning Mastery
|
||
const mastery = SKILLS_BY_ID[63]
|
||
const slvl = getEffectiveLevel(state, 63)
|
||
if (slvl > 0 && mastery) {
|
||
const pct = 50 + 12 * (slvl - 1)
|
||
return {
|
||
active: true,
|
||
masteryId: 63,
|
||
masteryName: 'Lightning Mastery',
|
||
masteryNameZh: '闪电精通',
|
||
effectiveLevel: slvl,
|
||
bonusPercent: pct,
|
||
multiplier: Number((1.0 + pct / 100).toFixed(4)),
|
||
descriptionZh: `+${pct}% 闪电伤害 (来自 ${slvl} 级闪电精通)`,
|
||
descriptionEn: `+${pct}% Lightning Damage (from Level ${slvl} Lightning Mastery)`,
|
||
}
|
||
}
|
||
}
|
||
|
||
if (skill.masteries.includes(65)) {
|
||
// Cold Mastery
|
||
const mastery = SKILLS_BY_ID[65]
|
||
const slvl = getEffectiveLevel(state, 65)
|
||
if (slvl > 0 && mastery) {
|
||
const pierce = 20 + 5 * (slvl - 1)
|
||
return {
|
||
active: true,
|
||
masteryId: 65,
|
||
masteryName: 'Cold Mastery',
|
||
masteryNameZh: '冰冻精通',
|
||
effectiveLevel: slvl,
|
||
bonusPercent: pierce,
|
||
multiplier: 1.0, // Lowers resistance rather than direct multiplier
|
||
descriptionZh: `-${pierce}% 敌人冰冷抗性 (来自 ${slvl} 级冰冻精通)`,
|
||
descriptionEn: `-${pierce}% Enemy Cold Resistance (from Level ${slvl} Cold Mastery)`,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check Paladin Concentration for Blessed Hammer (id 112)
|
||
if (skill.classCode === 'pal' && skill.id === 112 && skill.masteries.includes(113)) {
|
||
const slvl = getEffectiveLevel(state, 113)
|
||
if (slvl > 0) {
|
||
// Concentration grants +60% + 15%*(slvl-1), halved for Blessed Hammer
|
||
const concPct = Math.floor((60 + 15 * (slvl - 1)) / 2)
|
||
return {
|
||
active: true,
|
||
masteryId: 113,
|
||
masteryName: 'Concentration',
|
||
masteryNameZh: '专注光环',
|
||
effectiveLevel: slvl,
|
||
bonusPercent: concPct,
|
||
multiplier: Number((1.0 + concPct / 100).toFixed(4)),
|
||
descriptionZh: `+${concPct}% 伤害 (来自 ${slvl} 级专注光环 50% 加成)`,
|
||
descriptionEn: `+${concPct}% Damage (from Level ${slvl} Concentration Aura at 50% effectiveness)`,
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check Assassin Claw Mastery (id 252)
|
||
if (skill.classCode === 'ass' && skill.masteries.includes(252)) {
|
||
const slvl = getEffectiveLevel(state, 252)
|
||
if (slvl > 0) {
|
||
const pct = 30 + 4 * (slvl - 1)
|
||
return {
|
||
active: true,
|
||
masteryId: 252,
|
||
masteryName: 'Claw Mastery',
|
||
masteryNameZh: '爪支配',
|
||
effectiveLevel: slvl,
|
||
bonusPercent: pct,
|
||
multiplier: Number((1.0 + pct / 100).toFixed(4)),
|
||
descriptionZh: `+${pct}% 伤害 (来自 ${slvl} 级爪支配)`,
|
||
descriptionEn: `+${pct}% Damage (from Level ${slvl} Claw Mastery)`,
|
||
}
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
// ============================================================================
|
||
// 1.13c Math Evaluation: Bands, Mana, Damage
|
||
// ============================================================================
|
||
|
||
export function evaluateLevelBand(base: number, bands: readonly number[], lvl: number): number {
|
||
if (lvl <= 1) return base
|
||
let val = base
|
||
const b1 = Math.min(lvl - 1, 7) // levels 2..8
|
||
val += b1 * (bands[0] ?? 0)
|
||
|
||
if (lvl > 8) {
|
||
const b2 = Math.min(lvl - 8, 8) // levels 9..16
|
||
val += b2 * (bands[1] ?? 0)
|
||
}
|
||
if (lvl > 16) {
|
||
const b3 = Math.min(lvl - 16, 6) // levels 17..22
|
||
val += b3 * (bands[2] ?? 0)
|
||
}
|
||
if (lvl > 22) {
|
||
const b4 = Math.min(lvl - 22, 6) // levels 23..28
|
||
val += b4 * (bands[3] ?? 0)
|
||
}
|
||
if (lvl > 28) {
|
||
const b5 = lvl - 28 // levels 29+
|
||
val += b5 * (bands[4] ?? 0)
|
||
}
|
||
return val
|
||
}
|
||
|
||
/**
|
||
* Calculates 1.13c mana cost with 24.8 fixed point shift and minmana clamping.
|
||
*/
|
||
export function calculateManaCost(skill: SkillCatalogEntry, lvl: number): number {
|
||
if (skill.mana === 0 && skill.lvlmana === 0) return 0
|
||
const shift = skill.manashift ?? 8
|
||
const shiftFactor = 2 ** (shift - 8)
|
||
const raw = (skill.mana + skill.lvlmana * (lvl - 1)) * shiftFactor
|
||
const cost = Math.max(skill.minmana ?? 0, raw)
|
||
return Math.round(cost * 10) / 10
|
||
}
|
||
|
||
export interface DetailedDamageResult {
|
||
readonly hasDamage: boolean
|
||
readonly type: string
|
||
readonly typeZh: string
|
||
readonly baseMin: number
|
||
readonly baseMax: number
|
||
readonly synergyMultiplier: number
|
||
readonly synergyBonusPct: number
|
||
readonly withSynergyMin: number
|
||
readonly withSynergyMax: number
|
||
readonly masteryMultiplier: number
|
||
readonly masteryBonusPct: number
|
||
readonly finalMin: number
|
||
readonly finalMax: number
|
||
readonly durationSeconds?: number | undefined
|
||
readonly weaponDamagePercent?: number | undefined
|
||
}
|
||
|
||
/**
|
||
* Computes full detailed damage values including base, synergy, and mastery multipliers.
|
||
* In Diablo II 1.13c, a skill only deals direct attack damage if descdam is set in SkillDesc.txt.
|
||
*/
|
||
export function calculateDetailedDamage(
|
||
state: SkillCalcState,
|
||
skill: SkillCatalogEntry,
|
||
evalLevel: number
|
||
): DetailedDamageResult {
|
||
const lvl = Math.max(1, evalLevel)
|
||
|
||
// In Diablo II 1.13c, a skill only deals direct attack damage if descdam is defined
|
||
const hasDamage = Boolean(skill.descdam && skill.descdam !== '')
|
||
if (!hasDamage) {
|
||
return {
|
||
hasDamage: false,
|
||
type: 'none',
|
||
typeZh: '',
|
||
baseMin: 0,
|
||
baseMax: 0,
|
||
synergyMultiplier: 1.0,
|
||
synergyBonusPct: 0,
|
||
withSynergyMin: 0,
|
||
withSynergyMax: 0,
|
||
masteryMultiplier: 1.0,
|
||
masteryBonusPct: 0,
|
||
finalMin: 0,
|
||
finalMax: 0,
|
||
}
|
||
}
|
||
|
||
let type = 'physical'
|
||
let typeZh = '物理伤害'
|
||
let baseMin = 0
|
||
let baseMax = 0
|
||
let durationFrames = 0
|
||
|
||
if (skill.eType && skill.eType !== '') {
|
||
type = skill.eType
|
||
const typeNamesZh: Record<string, string> = {
|
||
fire: '火焰伤害',
|
||
ltng: '闪电伤害',
|
||
cold: '冰冷伤害',
|
||
pois: '毒素伤害',
|
||
mag: '魔法伤害',
|
||
}
|
||
typeZh = typeNamesZh[skill.eType] || `${skill.eType} 伤害`
|
||
|
||
baseMin = evaluateLevelBand(skill.eMin, skill.eMinLev, lvl)
|
||
baseMax = evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl)
|
||
|
||
if (skill.eLen > 0 || (skill.eLevLen && skill.eLevLen.length > 0)) {
|
||
durationFrames = compute3BandDuration(
|
||
lvl,
|
||
skill.eLen,
|
||
skill.eLevLen?.[0] ?? 0,
|
||
skill.eLevLen?.[1] ?? 0,
|
||
skill.eLevLen?.[2] ?? 0,
|
||
)
|
||
}
|
||
} else if (skill.minDam > 0 || skill.maxDam > 0 || (skill.minLevDam && skill.minLevDam.some(x => x > 0))) {
|
||
type = 'physical'
|
||
typeZh = '物理伤害'
|
||
baseMin = evaluateLevelBand(skill.minDam, skill.minLevDam, lvl)
|
||
baseMax = evaluateLevelBand(skill.maxDam, skill.maxLevDam, lvl)
|
||
}
|
||
|
||
// Apply HitShift
|
||
const shiftMultiplier = 2 ** ((skill.hitshift ?? 8) - 8)
|
||
baseMin = Math.round(baseMin * shiftMultiplier)
|
||
baseMax = Math.round(baseMax * shiftMultiplier)
|
||
|
||
// Apply Synergies
|
||
const syn = evaluateSynergies(state, skill)
|
||
const withSynergyMin = Math.floor(baseMin * syn.multiplier)
|
||
const withSynergyMax = Math.floor(baseMax * syn.multiplier)
|
||
|
||
// Apply Masteries
|
||
const mastery = evaluateMasteries(state, skill)
|
||
const masteryMult = mastery && mastery.active ? mastery.multiplier : 1.0
|
||
const masteryPct = mastery && mastery.active ? mastery.bonusPercent : 0
|
||
|
||
const finalMin = Math.floor(withSynergyMin * masteryMult)
|
||
const finalMax = Math.floor(withSynergyMax * masteryMult)
|
||
|
||
let weaponDamagePercent: number | undefined
|
||
if (skill.srcDamage > 0) {
|
||
weaponDamagePercent = Math.round((skill.srcDamage / 128) * 100)
|
||
}
|
||
|
||
return {
|
||
hasDamage: true,
|
||
type,
|
||
typeZh,
|
||
baseMin,
|
||
baseMax,
|
||
synergyMultiplier: syn.multiplier,
|
||
synergyBonusPct: syn.totalPercent,
|
||
withSynergyMin,
|
||
withSynergyMax,
|
||
masteryMultiplier: masteryMult,
|
||
masteryBonusPct: masteryPct,
|
||
finalMin,
|
||
finalMax,
|
||
durationSeconds: durationFrames > 0 ? Math.round((durationFrames / 25) * 10) / 10 : undefined,
|
||
weaponDamagePercent,
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Skill Effects & Tooltip Evaluator (SkillDesc.txt & SkillCalc.txt Ground Truth)
|
||
// ============================================================================
|
||
|
||
export interface FormattedDescLine {
|
||
readonly lineType: number
|
||
readonly textEn: string
|
||
readonly textZh: string
|
||
readonly value?: number | string | undefined
|
||
}
|
||
|
||
export function evalDiminishing(a: number, b: number, lvl: number): number {
|
||
if (b === a) return a
|
||
return Math.floor(((110 * lvl) * (b - a)) / (100 * (lvl + 6))) + a
|
||
}
|
||
|
||
export interface SkillFormulaContext {
|
||
readonly lvl: number
|
||
readonly params: Record<string, number>
|
||
readonly ln12: number
|
||
readonly ln34: number
|
||
readonly ln56: number
|
||
readonly ln78: number
|
||
readonly dm12: number
|
||
readonly dm34: number
|
||
readonly dm56: number
|
||
readonly dm78: number
|
||
readonly toht: number
|
||
readonly len: number
|
||
readonly edmn: number
|
||
readonly edmx: number
|
||
readonly mana: number
|
||
readonly hardPoints: Record<number, number>
|
||
readonly effLevels: Record<number, number>
|
||
}
|
||
|
||
export function evalSkillDescFormula(formula: string | undefined, ctx: SkillFormulaContext): number {
|
||
if (!formula) return 0
|
||
let s = String(formula).trim()
|
||
if (!s) return 0
|
||
if (s.startsWith('"') && s.endsWith('"')) {
|
||
s = s.slice(1, -1).trim()
|
||
}
|
||
|
||
// Cross-skill properties: skill('Skill Name'.prop)
|
||
s = s.replace(/skill\(['"]([^'"]+)['"]\.(blvl|lvl|par\d+|ln\d+)\)/g, (_match, skillName, prop) => {
|
||
const target = SKILLS_CATALOG.find(x => x.name.toLowerCase() === skillName.toLowerCase())
|
||
if (!target) return '0'
|
||
if (prop === 'blvl') return String(ctx.hardPoints?.[target.id] ?? 0)
|
||
if (prop === 'lvl') return String(ctx.effLevels?.[target.id] ?? 0)
|
||
if (prop.startsWith('par')) return String(target.params[prop] ?? 0)
|
||
if (prop === 'ln12') {
|
||
const slvl = Math.max(1, ctx.effLevels?.[target.id] ?? 1)
|
||
return String((target.params.par1 ?? 0) + (slvl - 1) * (target.params.par2 ?? 0))
|
||
}
|
||
if (prop === 'ln34') {
|
||
const slvl = Math.max(1, ctx.effLevels?.[target.id] ?? 1)
|
||
return String((target.params.par3 ?? 0) + (slvl - 1) * (target.params.par4 ?? 0))
|
||
}
|
||
if (prop === 'ln56') {
|
||
const slvl = Math.max(1, ctx.effLevels?.[target.id] ?? 1)
|
||
return String((target.params.par5 ?? 0) + (slvl - 1) * (target.params.par6 ?? 0))
|
||
}
|
||
return '0'
|
||
})
|
||
|
||
// Discard complex unresolved nested lookups safely
|
||
s = s.replace(/sklvl\([^)]+\)/g, '0')
|
||
s = s.replace(/miss\([^)]+\)/g, '0')
|
||
|
||
// Math functions
|
||
s = s.replace(/\bmin\s*\(/g, 'Math.min(')
|
||
s = s.replace(/\bmax\s*\(/g, 'Math.max(')
|
||
|
||
// Context tokens
|
||
s = s.replace(/\blvl\b/g, String(ctx.lvl))
|
||
for (let i = 1; i <= 8; i++) {
|
||
s = s.replace(new RegExp('\\bpar' + i + '\\b', 'g'), String(ctx.params['par' + i] ?? 0))
|
||
}
|
||
s = s.replace(/\bln12\b/g, String(ctx.ln12))
|
||
s = s.replace(/\bln34\b/g, String(ctx.ln34))
|
||
s = s.replace(/\bln56\b/g, String(ctx.ln56))
|
||
s = s.replace(/\bln78\b/g, String(ctx.ln78))
|
||
s = s.replace(/\bdm12\b/g, String(ctx.dm12))
|
||
s = s.replace(/\bdm34\b/g, String(ctx.dm34))
|
||
s = s.replace(/\bdm56\b/g, String(ctx.dm56))
|
||
s = s.replace(/\bdm78\b/g, String(ctx.dm78))
|
||
s = s.replace(/\btoht\b/g, String(ctx.toht))
|
||
s = s.replace(/\blen\b/g, String(ctx.len))
|
||
s = s.replace(/\bedln\b/g, String(ctx.len))
|
||
s = s.replace(/\bedmn\b/g, String(ctx.edmn))
|
||
s = s.replace(/\bedmx\b/g, String(ctx.edmx))
|
||
s = s.replace(/\bmps\b/g, String(ctx.ln12))
|
||
s = s.replace(/\busmc\b/g, String(ctx.mana))
|
||
|
||
// In Diablo II 1.13c, all formula arithmetic in D2Common.dll (0x6FD55000) uses 32-bit integer idiv.
|
||
// Replace integer divisions (e.g. lvl/3, ln56/2) with Math.floor
|
||
for (let iter = 0; iter < 3; iter++) {
|
||
s = s.replace(/(\b\d+\b|\([^\(\)]+\))\s*\/\s*(\b\d+\b|\([^\(\)]+\))/g, 'Math.floor(($1)/($2))')
|
||
}
|
||
|
||
try {
|
||
const fn = new Function('Math', 'return ' + s)
|
||
const res = fn(Math)
|
||
return Math.round(Number(res) || 0)
|
||
} catch {
|
||
return 0
|
||
}
|
||
}
|
||
|
||
export function evaluateSkillEffects(
|
||
state: SkillCalcState,
|
||
skill: SkillCatalogEntry,
|
||
lvl: number
|
||
): readonly FormattedDescLine[] {
|
||
const effLevels: Record<number, number> = {}
|
||
for (const s of SKILLS_CATALOG) {
|
||
effLevels[s.id] = getEffectiveLevel(state, s.id)
|
||
}
|
||
effLevels[skill.id] = lvl
|
||
|
||
const p = skill.params || {}
|
||
const dm12 = evalDiminishing(p.par1 ?? 0, p.par2 ?? 0, lvl)
|
||
const dm34 = evalDiminishing(p.par3 ?? 0, p.par4 ?? 0, lvl)
|
||
const dm56 = evalDiminishing(p.par5 ?? 0, p.par6 ?? 0, lvl)
|
||
const dm78 = evalDiminishing(p.par7 ?? 0, p.par8 ?? 0, lvl)
|
||
|
||
// Determine duration len using authentic 1.13c 3-band duration scaling
|
||
let durationFrames = compute3BandDuration(
|
||
lvl,
|
||
skill.eLen,
|
||
skill.eLevLen?.[0] ?? 0,
|
||
skill.eLevLen?.[1] ?? 0,
|
||
skill.eLevLen?.[2] ?? 0,
|
||
)
|
||
if (durationFrames === 0) {
|
||
if (
|
||
skill.id === 138 ||
|
||
skill.id === 149 ||
|
||
skill.id === 155 ||
|
||
skill.name === 'Battle Orders' ||
|
||
skill.name === 'Shout' ||
|
||
skill.name === 'Battle Command'
|
||
) {
|
||
const synShout = state.hardPoints[138] ?? 0
|
||
const synBC = state.hardPoints[155] ?? 0
|
||
const synBO = state.hardPoints[149] ?? 0
|
||
let synBonus = 0
|
||
if (skill.id === 149 || skill.name === 'Battle Orders') {
|
||
synBonus = (synShout + synBC) * (skill.params.par8 ?? 125)
|
||
durationFrames = (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0) + synBonus
|
||
} else if (skill.id === 138 || skill.name === 'Shout') {
|
||
synBonus = (synBO + synBC) * (skill.params.par8 ?? 125)
|
||
durationFrames = (skill.params.par3 ?? 0) + (lvl - 1) * (skill.params.par4 ?? 0) + synBonus
|
||
} else if (skill.id === 155 || skill.name === 'Battle Command') {
|
||
synBonus = (synShout + synBO) * (skill.params.par8 ?? 125)
|
||
durationFrames = (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0) + synBonus
|
||
}
|
||
}
|
||
}
|
||
|
||
const shiftMultiplier = 2 ** ((skill.hitshift ?? 8) - 8)
|
||
const baseCtx: SkillFormulaContext = {
|
||
lvl,
|
||
params: skill.params,
|
||
ln12: (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0),
|
||
ln34: (skill.params.par3 ?? 0) + (lvl - 1) * (skill.params.par4 ?? 0),
|
||
ln56: (skill.params.par5 ?? 0) + (lvl - 1) * (skill.params.par6 ?? 0),
|
||
ln78: (skill.params.par7 ?? 0) + (lvl - 1) * (skill.params.par8 ?? 0),
|
||
dm12,
|
||
dm34,
|
||
dm56,
|
||
dm78,
|
||
toht: 0,
|
||
len: durationFrames,
|
||
edmn: Math.round(evaluateLevelBand(skill.eMin, skill.eMinLev, lvl) * shiftMultiplier),
|
||
edmx: Math.round(evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl) * shiftMultiplier),
|
||
mana: calculateManaCost(skill, lvl),
|
||
hardPoints: state.hardPoints,
|
||
effLevels,
|
||
}
|
||
|
||
const toht = skill.toHitCalc
|
||
? evalSkillDescFormula(skill.toHitCalc, baseCtx)
|
||
: (skill.toHit ?? 0) + (lvl - 1) * (skill.toHitLev ?? 0)
|
||
|
||
const ctx: SkillFormulaContext = {
|
||
...baseCtx,
|
||
toht,
|
||
}
|
||
|
||
const results: FormattedDescLine[] = []
|
||
const lines = [...(skill.descLines || []), ...(skill.dsc2Lines || [])]
|
||
for (const line of lines) {
|
||
const valA = evalSkillDescFormula(line.calca, ctx)
|
||
const valB = evalSkillDescFormula(line.calcb, ctx)
|
||
const lt = line.lineType
|
||
|
||
let zh = ''
|
||
let en = ''
|
||
const isPct = line.textbKey === 'StrSkill23' || line.textbZh === '百分比' || line.textbEn?.toLowerCase() === 'percent'
|
||
const signA = valA > 0 ? '+' : ''
|
||
|
||
// Chinese label ergonomic adjustment for Attack Speed vs Attack Rating vs Damage %
|
||
let textaZh = line.textaZh || ''
|
||
if (line.textaKey === 'StrSkill106') {
|
||
textaZh = '攻擊速度:'
|
||
} else if (line.textaKey === 'StrSkill22') {
|
||
textaZh = '準確率:'
|
||
} else if (line.textaKey === 'StrSkill4' && isPct) {
|
||
textaZh = '傷害加成:'
|
||
} else if (line.textaKey === 'StrSkill62') {
|
||
textaZh = '冰凍時間:'
|
||
} else if (line.textaKey === 'StrSkill59') {
|
||
textaZh = '恐懼時間:'
|
||
}
|
||
|
||
if (lt === 2) {
|
||
zh = `${textaZh}${signA}${valA}${isPct ? '%' : ' ' + (line.textbZh || '')}`
|
||
en = `${line.textaEn || ''} ${signA}${valA}${isPct ? '%' : ' ' + (line.textbEn || '')}`
|
||
} else if (lt === 3) {
|
||
zh = `${textaZh}${signA}${valA}${isPct ? '%' : ' ' + (line.textbZh || '')}`
|
||
en = `${line.textaEn || ''} ${signA}${valA}${isPct ? '%' : ' ' + (line.textbEn || '')}`
|
||
} else if (lt === 4) {
|
||
zh = `${textaZh}${signA}${valA}`
|
||
en = `${line.textaEn || ''} ${signA}${valA}`
|
||
} else if (lt === 5) {
|
||
zh = `${textaZh}${valA}`
|
||
en = `${line.textaEn || ''} ${valA}`
|
||
} else if (lt === 6) {
|
||
zh = `+${valA}${textaZh || '%'}`
|
||
en = `+${valA}${line.textaEn || '%'}`
|
||
} else if (lt === 7) {
|
||
if (skill.id === 37) {
|
||
zh = `法力回復速度:+${valA}%`
|
||
en = `Mana Recovery Rate: +${valA}%`
|
||
} else {
|
||
zh = `+${valA}%`
|
||
en = `+${valA}%`
|
||
}
|
||
} else if (lt === 8) {
|
||
if (line.calca && valA !== 0) {
|
||
zh = `攻擊準確率:+${valA}%`
|
||
en = `To Attack Rating: +${valA}%`
|
||
}
|
||
} else if (lt === 12) {
|
||
const sec = valA > 50 ? Math.round((valA / 25) * 10) / 10 : valA
|
||
zh = `${textaZh || '持續:'}${sec} 秒`
|
||
en = `${line.textaEn || 'Duration:'} ${sec} seconds`
|
||
} else if (lt === 13) {
|
||
const baseLife = skill.minionStats?.minHP ?? 20
|
||
const life = Math.floor(((baseLife + valB) * (100 + valA)) / 100)
|
||
zh = `生命:${life}`
|
||
en = `Life: ${life}`
|
||
} else if (lt === 18) {
|
||
zh = `${textaZh}`
|
||
en = `${line.textaEn || ''}`
|
||
} else if (lt === 19 || lt === 37) {
|
||
const yards = (valA * (2 / 3)).toFixed(1)
|
||
zh = `${textaZh || '半徑:'}${yards} 碼`
|
||
en = `${line.textaEn || 'Radius:'} ${yards} yards`
|
||
} else if (lt === 34) {
|
||
const smLvl = getEffectiveLevel(state, 69)
|
||
const masteryDmg = smLvl * 2
|
||
const baseMin = (skill.minionStats?.minDam ?? 1) + masteryDmg
|
||
const baseMax = (skill.minionStats?.maxDam ?? 2) + masteryDmg
|
||
const bonusPct = lvl < 4 ? 0 : (lvl - 3) * 7
|
||
const finalMin = Math.floor((baseMin * (100 + bonusPct)) / 100)
|
||
const finalMax = Math.floor((baseMax * (100 + bonusPct)) / 100)
|
||
zh = `傷害:${finalMin} - ${finalMax}`
|
||
en = `Damage: ${finalMin} - ${finalMax}`
|
||
} else if (lt === 36) {
|
||
zh = `${textaZh || '總數'}:${valA}`
|
||
en = `${line.textaEn || 'Total'}: ${valA}`
|
||
} else if (lt === 39) {
|
||
const baseMin = skill.minionStats?.minDam ?? 2
|
||
const baseMax = skill.minionStats?.maxDam ?? 5
|
||
zh = `傷害:${baseMin} - ${baseMax}`
|
||
en = `Damage: ${baseMin} - ${baseMax}`
|
||
} else if (lt === 45) {
|
||
zh = `反彈物理傷害:${valA}%`
|
||
en = `Damage Returned: ${valA}%`
|
||
} else if (textaZh || line.textaEn) {
|
||
zh = `${textaZh}${valA > 0 ? ' +' + valA : ''}`
|
||
en = `${line.textaEn || ''}${valA > 0 ? ' +' + valA : ''}`
|
||
}
|
||
|
||
if (zh.trim() && en.trim()) {
|
||
results.push({ lineType: lt, textZh: zh.trim(), textEn: en.trim(), value: valA })
|
||
}
|
||
}
|
||
return results
|
||
}
|
||
|
||
// ============================================================================
|
||
// Tooltip View Model Generator
|
||
// ============================================================================
|
||
|
||
export interface TooltipViewModel {
|
||
readonly skill: SkillCatalogEntry
|
||
readonly hardPoints: number
|
||
readonly effectiveLevel: number
|
||
readonly isLearned: boolean
|
||
readonly canAllocateNow: boolean
|
||
readonly reqlevel: number
|
||
readonly levelMet: boolean
|
||
readonly prereqs: readonly {
|
||
readonly id: number
|
||
readonly name: string
|
||
readonly nameZh: string
|
||
readonly met: boolean
|
||
}[]
|
||
readonly current: {
|
||
readonly level: number
|
||
readonly manaCost: number
|
||
readonly delaySeconds: number
|
||
readonly damage: DetailedDamageResult
|
||
readonly effects: readonly FormattedDescLine[]
|
||
}
|
||
readonly nextLevel: {
|
||
readonly level: number
|
||
readonly manaCost: number
|
||
readonly damage: DetailedDamageResult
|
||
readonly effects: readonly FormattedDescLine[]
|
||
} | null
|
||
readonly synergiesReceived: readonly {
|
||
readonly providerId: number
|
||
readonly providerName: string
|
||
readonly providerNameZh: string
|
||
readonly percentPerLevel: number
|
||
readonly hardPoints: number
|
||
readonly currentBonusPct: number
|
||
}[]
|
||
readonly synergiesGiven: readonly {
|
||
readonly targetId: number
|
||
readonly targetName: string
|
||
readonly targetNameZh: string
|
||
readonly percentPerLevel: number
|
||
}[]
|
||
readonly masteryInfo: MasteryCalculationResult | null
|
||
readonly shortDesc: string
|
||
readonly shortDescZh: string
|
||
readonly longDesc: string
|
||
readonly longDescZh: string
|
||
}
|
||
|
||
/**
|
||
* Builds the complete view model for the hover tooltip.
|
||
*/
|
||
export function buildTooltipViewModel(
|
||
state: SkillCalcState,
|
||
skillId: number,
|
||
playerLevel: number = 99
|
||
): TooltipViewModel | null {
|
||
const skill = SKILLS_BY_ID[skillId]
|
||
if (!skill) return null
|
||
|
||
const hard = state.hardPoints[skillId] ?? 0
|
||
const eff = getEffectiveLevel(state, skillId)
|
||
const isLearned = eff > 0
|
||
const levelMet = playerLevel >= skill.reqlevel
|
||
const canAlloc = canAllocate(state, skillId)
|
||
|
||
// Prerequisite status
|
||
const prereqs = skill.reqskills.map(reqId => {
|
||
const reqSkill = SKILLS_BY_ID[reqId]
|
||
return {
|
||
id: reqId,
|
||
name: reqSkill?.name || `Skill ${reqId}`,
|
||
nameZh: reqSkill?.nameZh || reqSkill?.name || `技能 ${reqId}`,
|
||
met: (state.hardPoints[reqId] ?? 0) >= 1,
|
||
}
|
||
})
|
||
|
||
// Current Level stats (if unlearned, preview level 1)
|
||
const displayLevel = isLearned ? eff : 1
|
||
const currentMana = calculateManaCost(skill, displayLevel)
|
||
const delaySec = skill.delay > 0 ? Math.round((skill.delay / 25) * 10) / 10 : 0
|
||
const currentDamage = calculateDetailedDamage(state, skill, displayLevel)
|
||
const currentEffects = evaluateSkillEffects(state, skill, displayLevel)
|
||
|
||
// Next Level stats: only show when learned and not maxed
|
||
let nextLevel: TooltipViewModel['nextLevel'] = null
|
||
if (isLearned && hard < 20) {
|
||
const nextLvl = eff + 1
|
||
nextLevel = {
|
||
level: nextLvl,
|
||
manaCost: calculateManaCost(skill, nextLvl),
|
||
damage: calculateDetailedDamage(state, skill, nextLvl),
|
||
effects: evaluateSkillEffects(state, skill, nextLvl),
|
||
}
|
||
}
|
||
|
||
const synResult = evaluateSynergies(state, skill)
|
||
const masteryInfo = evaluateMasteries(state, skill)
|
||
|
||
return {
|
||
skill,
|
||
hardPoints: hard,
|
||
effectiveLevel: eff,
|
||
isLearned,
|
||
canAllocateNow: canAlloc,
|
||
reqlevel: skill.reqlevel,
|
||
levelMet,
|
||
prereqs,
|
||
current: {
|
||
level: displayLevel,
|
||
manaCost: currentMana,
|
||
delaySeconds: delaySec,
|
||
damage: currentDamage,
|
||
effects: currentEffects,
|
||
},
|
||
nextLevel,
|
||
synergiesReceived: synResult.list,
|
||
synergiesGiven: skill.synergiesGiven || [],
|
||
masteryInfo,
|
||
shortDesc: skill.shortDesc,
|
||
shortDescZh: skill.shortDescZh,
|
||
longDesc: skill.longDesc,
|
||
longDescZh: skill.longDescZh,
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Build Serialization (URL Hash)
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Serializes state into a compact URL hash: #ama:6=20,7=1;tab=0,1,0;all=2
|
||
*/
|
||
export function serializeBuildToHash(state: SkillCalcState): string {
|
||
const parts: string[] = []
|
||
const skillsStr = Object.entries(state.hardPoints)
|
||
.filter(([_, pts]) => pts > 0)
|
||
.map(([id, pts]) => `${id}:${pts}`)
|
||
.join(',')
|
||
parts.push(`c=${state.classCode}`)
|
||
if (skillsStr) parts.push(`s=${skillsStr}`)
|
||
|
||
const tabsStr = [state.tabSoftPoints[0] || 0, state.tabSoftPoints[1] || 0, state.tabSoftPoints[2] || 0].join(',')
|
||
if (tabsStr !== '0,0,0') parts.push(`t=${tabsStr}`)
|
||
if (state.allSkillsSoftPoints > 0) parts.push(`a=${state.allSkillsSoftPoints}`)
|
||
|
||
return '#' + parts.join(';')
|
||
}
|
||
|
||
/**
|
||
* Deserializes URL hash into state.
|
||
*/
|
||
export function deserializeBuildFromHash(hash: string): SkillCalcState | null {
|
||
if (!hash || !hash.startsWith('#')) return null
|
||
try {
|
||
const raw = hash.slice(1)
|
||
const tokens = raw.split(';')
|
||
let classCode: CharacterClassCode = 'ama'
|
||
const hardPoints: Record<number, number> = {}
|
||
const tabSoftPoints: Record<number, number> = { 0: 0, 1: 0, 2: 0 }
|
||
let allSkillsSoftPoints = 0
|
||
|
||
for (const token of tokens) {
|
||
const eqIdx = token.indexOf('=')
|
||
if (eqIdx === -1) continue
|
||
const key = token.slice(0, eqIdx)
|
||
const val = token.slice(eqIdx + 1)
|
||
if (key === 'c' && val) {
|
||
if (['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'].includes(val)) {
|
||
classCode = val as CharacterClassCode
|
||
}
|
||
} else if (key === 's' && val) {
|
||
const pairs = val.split(',')
|
||
for (const pair of pairs) {
|
||
const [idStr, ptsStr] = pair.includes(':') ? pair.split(':') : pair.split('=')
|
||
const id = Number(idStr)
|
||
const pts = Number(ptsStr)
|
||
if (id > 0 && pts > 0 && pts <= 20) {
|
||
hardPoints[id] = pts
|
||
}
|
||
}
|
||
} else if (key === 't' && val) {
|
||
const t = val.split(',').map(Number)
|
||
tabSoftPoints[0] = t[0] || 0
|
||
tabSoftPoints[1] = t[1] || 0
|
||
tabSoftPoints[2] = t[2] || 0
|
||
} else if (key === 'a' && val) {
|
||
allSkillsSoftPoints = Number(val) || 0
|
||
}
|
||
}
|
||
|
||
return {
|
||
classCode,
|
||
hardPoints,
|
||
tabSoftPoints,
|
||
allSkillsSoftPoints,
|
||
specificSoftPoints: {},
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|