/** * 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 // skillId -> 0..20 tabSoftPoints: Record // tabIndex 0..2 -> 0..99 allSkillsSoftPoints: number // 0..99 specificSoftPoints: Record // 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() 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 = { 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 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 readonly effLevels: Record } 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 = {} 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 = {} const tabSoftPoints: Record = { 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 } }