diablo2-web/src/game/skill-calc-engine.ts

1004 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 {
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 = evaluateLevelBand(skill.eLen, skill.eLevLen, lvl)
}
} 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
let durationFrames = skill.eLen + (lvl - 1) * (skill.eLevLen?.[0] ?? 0)
if (durationFrames === 0 && (skill.params.par1 ?? 0) > 0 && (skill.params.par2 ?? 0) > 0) {
if (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) synBonus = (synShout + synBC) * (skill.params.par8 ?? 125)
else if (skill.id === 138) synBonus = (synBO + synBC) * (skill.params.par8 ?? 125)
else if (skill.id === 155) synBonus = (synShout + synBO) * (skill.params.par8 ?? 125)
durationFrames = (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0) + synBonus
}
}
const ctx: 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: (skill.params.tohit ?? 40) + (lvl - 1) * 5,
len: durationFrames,
edmn: evaluateLevelBand(skill.eMin, skill.eMinLev, lvl),
edmx: evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl),
mana: calculateManaCost(skill, lvl),
hardPoints: state.hardPoints,
effLevels,
}
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 = hard > 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
let nextLevel: TooltipViewModel['nextLevel'] = null
if (hard < 20) {
const nextLvl = isLearned ? eff + 1 : 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
}
}