fix(skills): reconstruct skill tooltips to 1.13c formula and next level preview (Fixes #152)

This commit is contained in:
troytt 2026-09-22 07:13:00 +00:00
parent 1f1996f65b
commit 9fac33a1e2
6 changed files with 1609 additions and 264 deletions

View File

@ -0,0 +1,168 @@
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadActTables } from '../src/game/acts.ts'
import { SKILLS_CATALOG } from '../src/data/skills-catalog.ts'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
async function run() {
console.log('Mounting MPQs to extract 1.13c ToHit and auraLenCalc...')
const archives = new MountedArchives()
for (const m of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(m, await MpqArchive.open(await fileSource('samples/d2/' + m)))
}
const tables = await loadActTables(archives)
const st = tables.skills!
const toHitIdx = st.header.indexOf('ToHit')
const levToHitIdx = st.header.indexOf('LevToHit')
const toHitCalcIdx = st.header.indexOf('ToHitCalc')
const auraLenCalcIdx = st.header.indexOf('auralencalc')
const startmanaIdx = st.header.indexOf('startmana')
const updatedCatalog = SKILLS_CATALOG.map(skill => {
const row = st.rows.find(r => Number(r[1]) === skill.id)
const th = row ? (Number(row[toHitIdx]) || 0) : 0
const lth = row ? (Number(row[levToHitIdx]) || 0) : 0
const rawThc = row ? row[toHitCalcIdx] : undefined
const thc = rawThc && rawThc.trim() !== '' ? rawThc.trim() : undefined
const rawAlc = row ? row[auraLenCalcIdx] : undefined
const alc = rawAlc && rawAlc.trim() !== '' ? rawAlc.trim() : undefined
const sm = row ? (Number(row[startmanaIdx]) || 0) : 0
return {
...skill,
toHit: th,
toHitLev: lth,
...(thc ? { toHitCalc: thc } : {}),
...(alc ? { auraLenCalc: alc } : {}),
startmana: sm,
}
})
const tsContent = `// Auto-generated Diablo II 1.13c 210-Skill Catalog
// Ground truth parsed from Skills.txt, SkillDesc.txt, string.tbl, and MPQs.
import type { CharacterClassCode } from '../game/classes.ts'
export interface SynergyBonusInfo {
readonly providerId: number
readonly providerName: string
readonly percentPerLevel: number
readonly type: 'damage' | 'elemental' | 'duration'
}
export interface SynergyGivenInfo {
readonly targetId: number
readonly targetName: string
readonly targetNameZh: string
readonly percentPerLevel: number
}
export interface DescLineDefinition {
readonly lineType: number
readonly textaKey?: string | undefined
readonly textbKey?: string | undefined
readonly textaEn?: string | undefined
readonly textaZh?: string | undefined
readonly textbEn?: string | undefined
readonly textbZh?: string | undefined
readonly calca?: string | undefined
readonly calcb?: string | undefined
}
export interface MinionBaseStats {
readonly code: string
readonly minHP: number
readonly maxHP: number
readonly minDam: number
readonly maxDam: number
}
export interface SkillCatalogEntry {
readonly id: number
readonly name: string
readonly nameZh: string
readonly classCode: CharacterClassCode
readonly classNameZh: string
readonly tabIndex: number
readonly tabName: string
readonly tabNameZh: string
readonly row: number
readonly col: number
readonly reqlevel: number
readonly reqskills: readonly number[]
readonly dependents: readonly number[]
readonly mana: number
readonly lvlmana: number
readonly minmana: number
readonly manashift: number
readonly delay: number
readonly descdam?: string | undefined
readonly hitshift: number
readonly srcDamage: number
readonly minDam: number
readonly maxDam: number
readonly minLevDam: readonly number[]
readonly maxLevDam: readonly number[]
readonly eType: string
readonly eMin: number
readonly eMax: number
readonly eMinLev: readonly number[]
readonly eMaxLev: readonly number[]
readonly eLen: number
readonly eLevLen: readonly number[]
readonly toHit: number
readonly toHitLev: number
readonly toHitCalc?: string | undefined
readonly auraLenCalc?: string | undefined
readonly startmana: number
readonly params: Readonly<Record<string, number>>
readonly synergyFormulas: {
readonly physical?: string | undefined
readonly elemental?: string | undefined
readonly duration?: string | undefined
}
readonly synergiesReceived: readonly SynergyBonusInfo[]
readonly synergiesGiven: readonly SynergyGivenInfo[]
readonly masteries: readonly number[]
readonly descLines?: readonly DescLineDefinition[] | undefined
readonly dsc2Lines?: readonly DescLineDefinition[] | undefined
readonly minionStats?: MinionBaseStats | undefined
readonly shortDesc: string
readonly shortDescZh: string
readonly longDesc: string
readonly longDescZh: string
readonly icon: string
readonly iconActive: string
}
export const SKILLS_CATALOG: readonly SkillCatalogEntry[] = Object.freeze(${JSON.stringify(updatedCatalog, null, 2)})
export const SKILLS_BY_ID: Readonly<Record<number, SkillCatalogEntry>> = Object.freeze(
SKILLS_CATALOG.reduce<Record<number, SkillCatalogEntry>>((acc, cur) => {
acc[cur.id] = cur
return acc
}, {})
)
export const SKILLS_BY_CLASS: Readonly<Record<CharacterClassCode, readonly SkillCatalogEntry[]>> = Object.freeze({
ama: SKILLS_CATALOG.filter(s => s.classCode === 'ama'),
sor: SKILLS_CATALOG.filter(s => s.classCode === 'sor'),
nec: SKILLS_CATALOG.filter(s => s.classCode === 'nec'),
pal: SKILLS_CATALOG.filter(s => s.classCode === 'pal'),
bar: SKILLS_CATALOG.filter(s => s.classCode === 'bar'),
dru: SKILLS_CATALOG.filter(s => s.classCode === 'dru'),
ass: SKILLS_CATALOG.filter(s => s.classCode === 'ass'),
})
`
const outPath = join(process.cwd(), 'src', 'data', 'skills-catalog.ts')
writeFileSync(outPath, tsContent, 'utf-8')
console.log(`Successfully updated ${outPath} with 1.13c ToHit and auraLenCalc!`)
}
run().catch(err => {
console.error(err)
process.exit(1)
})

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
*/
import type { CharacterClassCode } from './classes.ts'
import { compute3BandDuration } from './engine/calc-ast.ts'
import {
SKILLS_CATALOG,
SKILLS_BY_ID,
@ -480,7 +481,13 @@ export function calculateDetailedDamage(
baseMax = evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl)
if (skill.eLen > 0 || (skill.eLevLen && skill.eLevLen.length > 0)) {
durationFrames = evaluateLevelBand(skill.eLen, skill.eLevLen, lvl)
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'
@ -658,22 +665,42 @@ export function evaluateSkillEffects(
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') {
// 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) 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
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 ctx: SkillFormulaContext = {
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),
@ -684,15 +711,24 @@ export function evaluateSkillEffects(
dm34,
dm56,
dm78,
toht: (skill.params.tohit ?? 40) + (lvl - 1) * 5,
toht: 0,
len: durationFrames,
edmn: evaluateLevelBand(skill.eMin, skill.eMinLev, lvl),
edmx: evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl),
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) {
@ -861,7 +897,7 @@ export function buildTooltipViewModel(
const hard = state.hardPoints[skillId] ?? 0
const eff = getEffectiveLevel(state, skillId)
const isLearned = hard > 0
const isLearned = eff > 0
const levelMet = playerLevel >= skill.reqlevel
const canAlloc = canAllocate(state, skillId)
@ -883,10 +919,10 @@ export function buildTooltipViewModel(
const currentDamage = calculateDetailedDamage(state, skill, displayLevel)
const currentEffects = evaluateSkillEffects(state, skill, displayLevel)
// Next Level stats
// Next Level stats: only show when learned and not maxed
let nextLevel: TooltipViewModel['nextLevel'] = null
if (hard < 20) {
const nextLvl = isLearned ? eff + 1 : 1
if (isLearned && hard < 20) {
const nextLvl = eff + 1
nextLevel = {
level: nextLvl,
manaCost: calculateManaCost(skill, nextLvl),

View File

@ -8,7 +8,20 @@
* point allocation, prerequisite checks, and rich hover tooltips.
*/
import type { D2FontRenderer } from './font.ts'
import type { D2FontRenderer, D2ColorCode } from './font.ts'
import type { CharacterClassCode } from '../game/classes.ts'
import {
buildTooltipViewModel,
type TooltipViewModel,
type SkillCalcState,
} from '../game/skill-calc-engine.ts'
export interface FormattedTooltipLine {
readonly text: string
readonly color: D2ColorCode
readonly font: 'fontexocet10' | 'font8'
readonly align?: 'left' | 'center'
}
export interface SkillNodeDef {
readonly skillId: number
@ -74,6 +87,8 @@ export class SkillTreePanel {
activeTab: 0 | 1 | 2 = 2
unspentSkillPoints = 12
bonusAllSkills = 8
characterClass: CharacterClassCode = 'sor'
characterLevel = 85
readonly hardPoints = new Map<number, number>([
[39, 1], [40, 1], [44, 1], [45, 5], [55, 10], [59, 20], [64, 20], [65, 10],
[38, 1], [42, 1], [43, 1], [49, 5], [54, 1],
@ -107,6 +122,24 @@ export class SkillTreePanel {
return hard > 0 ? hard + this.bonusAllSkills : 0
}
getSkillCalcState(): SkillCalcState {
const hardPoints: Record<number, number> = {}
for (const [id, pts] of this.hardPoints.entries()) {
if (pts > 0) hardPoints[id] = pts
}
return {
classCode: this.characterClass,
hardPoints,
tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
allSkillsSoftPoints: this.bonusAllSkills,
specificSoftPoints: {},
}
}
getTooltipViewModel(skillId: number): TooltipViewModel | null {
return buildTooltipViewModel(this.getSkillCalcState(), skillId, this.characterLevel)
}
canAllocate(skillId: number, charLevel = 85): boolean {
if (this.unspentSkillPoints <= 0) return false
const node = SORCERESS_SKILL_TREE.find(n => n.skillId === skillId)
@ -317,51 +350,259 @@ export class SkillTreePanel {
// 6. Hover Tooltip for Skill Node
if (this.hoveredSkill) {
const s = this.hoveredSkill
const pos = SkillTreePanel.nodeScreenPos(s)
const eff = Math.max(1, this.getEffectiveLevel(s.skillId))
const hard = this.getHardPoints(s.skillId)
const minDmg = s.baseDmgMin + (eff - 1) * s.dmgPerLevel
const maxDmg = s.baseDmgMax + (eff - 1) * Math.round(s.dmgPerLevel * 1.25)
const tw = 240
const th = 112
const tx = Math.max(404, Math.min(800 - tw - 8, pos.x - 80))
const ty = Math.max(12, pos.y - th - 6)
this.drawSkillTooltip(ctx, font)
}
}
ctx.fillStyle = 'rgba(6, 6, 6, 0.94)'
ctx.fillRect(tx, ty, tw, th)
ctx.strokeStyle = '#c8a15a'
ctx.strokeRect(tx + 0.5, ty + 0.5, tw - 1, th - 1)
static formatSkillTooltipLines(vm: TooltipViewModel): FormattedTooltipLine[] {
const lines: FormattedTooltipLine[] = []
font.drawText(ctx, `${s.nameZh} (${s.nameEn})`, tx + tw / 2, ty + 20, {
font: 'fontexocet10',
color: 'gold',
align: 'center',
})
font.drawText(ctx, s.descZh, tx + tw / 2, ty + 38, {
// 1. Title: Skill Name in Gold (#c8a15a, fontexocet10, center)
lines.push({
text: `${vm.skill.nameZh} (${vm.skill.name})`,
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
// 2. Requirements: Red (#ff4d4d) if player level < reqlevel or unmet prereqs
if (!vm.levelMet) {
lines.push({
text: `需要角色等级: ${vm.reqlevel}`,
color: 'red',
font: 'font8',
color: 'white',
align: 'center',
})
font.drawText(ctx, `当前等级: ${eff} (基础 ${hard} + 装备 ${this.bonusAllSkills})`, tx + tw / 2, ty + 58, {
font: 'font8',
color: 'blue',
align: 'center',
})
if (s.baseDmgMax > 0) {
font.drawText(ctx, `法术效果/伤害: ${minDmg} - ${maxDmg}`, tx + tw / 2, ty + 76, {
}
for (const prereq of vm.prereqs) {
if (!prereq.met) {
lines.push({
text: `需要前置技能: ${prereq.nameZh}`,
color: 'red',
font: 'font8',
color: 'white',
align: 'center',
})
}
font.drawText(
ctx,
`法力消耗: ${s.baseMana} · 需要等级: ${s.reqLevel} (左键点击加点)`,
tx + tw / 2,
ty + 96,
{ font: 'font8', color: 'tan', align: 'center' },
)
}
// 3. Narrative: Short/Long description in White (#e8e4dc, font8)
const desc = vm.skill.shortDescZh || vm.shortDescZh || vm.skill.longDescZh || vm.longDescZh
if (desc) {
lines.push({
text: desc,
color: 'white',
font: 'font8',
align: 'center',
})
}
// 4. Current Level header: Blue/White
if (vm.isLearned) {
const soft = vm.effectiveLevel - vm.hardPoints
const lvlText = `当前技能等级: ${vm.effectiveLevel}${soft > 0 ? ` (基础 ${vm.hardPoints} + 装备 +${soft})` : ''}`
lines.push({
text: lvlText,
color: soft > 0 ? 'blue' : 'white',
font: 'font8',
align: 'center',
})
} else {
lines.push({
text: '未习得 (预览 1 级效果)',
color: 'gray',
font: 'font8',
align: 'center',
})
}
// 5. Current Stats: Mana cost, damage ranges (with synergies and masteries), attack bonus, duration, cast delay in Blue/White (#6868ff / #e8e4dc, font8)
if (vm.current.manaCost > 0 || vm.skill.mana > 0) {
lines.push({
text: `法力消耗: ${vm.current.manaCost}`,
color: 'white',
font: 'font8',
align: 'center',
})
}
if (vm.current.delaySeconds > 0) {
lines.push({
text: `施法延迟: ${vm.current.delaySeconds} 秒`,
color: 'white',
font: 'font8',
align: 'center',
})
}
if (vm.current.damage.hasDamage) {
const d = vm.current.damage
lines.push({
text: `${d.typeZh || '伤害'}: ${d.finalMin} - ${d.finalMax}`,
color: 'blue',
font: 'font8',
align: 'center',
})
if (d.durationSeconds !== undefined && d.durationSeconds > 0) {
lines.push({
text: `持续时间: ${d.durationSeconds} 秒`,
color: 'white',
font: 'font8',
align: 'center',
})
}
if (d.weaponDamagePercent !== undefined && d.weaponDamagePercent > 0) {
lines.push({
text: `武器伤害: ${d.weaponDamagePercent}%`,
color: 'white',
font: 'font8',
align: 'center',
})
}
}
for (const eff of vm.current.effects) {
if (eff.textZh) {
lines.push({
text: eff.textZh,
color: 'blue',
font: 'font8',
align: 'center',
})
}
}
// 6. Next Level Preview: Header [下一等级] (Lv X) in Green (#00fc00, font8), followed by next level values. Omitted if unlearned or maxed (20 base points).
if (vm.nextLevel && vm.isLearned && vm.hardPoints < 20) {
lines.push({
text: `[下一等级] (Lv ${vm.nextLevel.level})`,
color: 'green',
font: 'font8',
align: 'center',
})
if (vm.nextLevel.manaCost !== vm.current.manaCost) {
lines.push({
text: `法力消耗: ${vm.nextLevel.manaCost}`,
color: 'green',
font: 'font8',
align: 'center',
})
}
if (vm.nextLevel.damage.hasDamage) {
const nd = vm.nextLevel.damage
lines.push({
text: `${nd.typeZh || '伤害'}: ${nd.finalMin} - ${nd.finalMax}`,
color: 'green',
font: 'font8',
align: 'center',
})
if (nd.durationSeconds !== undefined && nd.durationSeconds > 0 && nd.durationSeconds !== vm.current.damage.durationSeconds) {
lines.push({
text: `持续时间: ${nd.durationSeconds} 秒`,
color: 'green',
font: 'font8',
align: 'center',
})
}
if (nd.weaponDamagePercent !== undefined && nd.weaponDamagePercent !== vm.current.damage.weaponDamagePercent) {
lines.push({
text: `武器伤害: ${nd.weaponDamagePercent}%`,
color: 'green',
font: 'font8',
align: 'center',
})
}
}
for (const eff of vm.nextLevel.effects) {
if (eff.textZh) {
lines.push({
text: eff.textZh,
color: 'green',
font: 'font8',
align: 'center',
})
}
}
} else if (vm.hardPoints >= 20) {
lines.push({
text: '[已达技能点投入上限 (20/20)]',
color: 'gold',
font: 'font8',
align: 'center',
})
}
// 7. Synergies Section: Header [协同技能加成] in Blue (fontexocet10), followed by each synergy provider line: • <Name>: 每个有效技能点增加 <X>% 伤害 (当前 +<Y>%)
if (vm.synergiesReceived && vm.synergiesReceived.length > 0) {
lines.push({
text: '[协同技能加成]',
color: 'blue',
font: 'fontexocet10',
align: 'center',
})
for (const syn of vm.synergiesReceived) {
const text = syn.hardPoints > 0
? `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害 (当前 +${syn.currentBonusPct}%)`
: `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害`
lines.push({
text,
color: syn.hardPoints > 0 ? 'blue' : 'gray',
font: 'font8',
align: 'left',
})
}
}
if (vm.masteryInfo && vm.masteryInfo.active) {
lines.push({
text: `• ${vm.masteryInfo.descriptionZh}`,
color: 'blue',
font: 'font8',
align: 'left',
})
}
return lines
}
static computeTooltipPlacement(
pos: { x: number; y: number; w: number; h: number },
lines: readonly FormattedTooltipLine[],
font: D2FontRenderer,
): { x: number; y: number; w: number; h: number } {
const maxLineWidth = lines.reduce((max, l) => {
const w = typeof font?.measureText === 'function' ? font.measureText(l.text, l.font) : l.text.length * 8
return Math.max(max, w)
}, 0)
const boxW = Math.max(240, maxLineWidth + 28)
const boxH = lines.length * 17 + 24
const rawX = pos.x - boxW - 8
const clampedX = Math.max(8, Math.min(800 - boxW - 8, rawX))
const rawY = Math.round(pos.y + 24 - boxH / 2)
const clampedY = Math.max(8, Math.min(552 - boxH, rawY))
return { x: clampedX, y: clampedY, w: boxW, h: boxH }
}
drawSkillTooltip(ctx: CanvasRenderingContext2D, font: D2FontRenderer): void {
if (!this.hoveredSkill) return
const pos = SkillTreePanel.nodeScreenPos(this.hoveredSkill)
const vm = this.getTooltipViewModel(this.hoveredSkill.skillId)
if (!vm) return
const lines = SkillTreePanel.formatSkillTooltipLines(vm)
if (lines.length === 0) return
const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, font)
ctx.fillStyle = 'rgba(4, 4, 4, 0.94)'
ctx.fillRect(placement.x, placement.y, placement.w, placement.h)
ctx.strokeStyle = '#8c7244'
ctx.lineWidth = 1
ctx.strokeRect(placement.x + 0.5, placement.y + 0.5, placement.w - 1, placement.h - 1)
lines.forEach((l, idx) => {
const lineY = placement.y + 12 + 10 + idx * 17
const lineX = l.align === 'center' ? placement.x + Math.round(placement.w / 2) : placement.x + 14
font.drawText(ctx, l.text, lineX, lineY, {
font: l.font,
color: l.color,
align: l.align ?? 'center',
})
})
}
}

View File

@ -268,7 +268,100 @@ describe('Diablo II Skill Calculator Engine (Issue #26)', () => {
expect(vm?.prereqs[0].name).toBe('Fire Bolt')
expect(vm?.prereqs[0].met).toBe(false)
expect(vm?.current.damage.hasDamage).toBe(true)
// For slvl === 0 (unlearned), nextLevel must be null while main body previews Lv 1
expect(vm?.isLearned).toBe(false)
expect(vm?.current.level).toBe(1)
expect(vm?.nextLevel).toBeNull()
})
it('previews nextLevel when learned (slvl >= 1) and hardPoints < 20', () => {
allocatePoint(state, 36, 1) // Fire Bolt prerequisite
allocatePoint(state, 47, 5) // Fire Ball lvl 5
state.allSkillsSoftPoints = 2 // +2 from gear -> effLevel 7
const vm = buildTooltipViewModel(state, 47, 99)
expect(vm).not.toBeNull()
expect(vm?.isLearned).toBe(true)
expect(vm?.hardPoints).toBe(5)
expect(vm?.effectiveLevel).toBe(7)
expect(vm?.current.level).toBe(7)
expect(vm?.nextLevel).not.toBeNull()
expect(vm?.nextLevel?.level).toBe(8)
expect(vm?.nextLevel?.damage.finalMin).toBeGreaterThan(vm!.current.damage.finalMin)
})
it('omits nextLevel preview when skill has reached maximum base allocation (hardPoints === 20)', () => {
allocatePoint(state, 36, 1)
allocatePoint(state, 47, 20) // Max 20 hard points
state.allSkillsSoftPoints = 5 // +5 gear -> effLevel 25
const vm = buildTooltipViewModel(state, 47, 99)
expect(vm).not.toBeNull()
expect(vm?.isLearned).toBe(true)
expect(vm?.hardPoints).toBe(20)
expect(vm?.effectiveLevel).toBe(25)
expect(vm?.current.level).toBe(25)
expect(vm?.nextLevel).toBeNull()
})
it('evaluates authentic 1.13c Attack Rating (ToHit) scaling for combat skills', () => {
// Jab (10): ToHit = 10, LevToHit = 9 -> 10 + (lvl - 1) * 9
const amaState = createInitialState('ama')
amaState.hardPoints[10] = 1
let vmLvl1 = buildTooltipViewModel(amaState, 10, 99)
let arLine1 = vmLvl1?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
expect(arLine1?.textZh).toBe('準確率:+10%')
amaState.hardPoints[10] = 10
let vmLvl10 = buildTooltipViewModel(amaState, 10, 99)
let arLine10 = vmLvl10?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
expect(arLine10?.textZh).toBe('準確率:+91%')
amaState.hardPoints[10] = 20
let vmLvl20 = buildTooltipViewModel(amaState, 10, 99)
let arLine20 = vmLvl20?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
expect(arLine20?.textZh).toBe('準確率:+181%')
// Zeal (106): ToHit = 10, LevToHit = 10 -> 10 + (lvl - 1) * 10 = +200%
const palState = createInitialState('pal')
palState.hardPoints[106] = 20
const vmZeal = buildTooltipViewModel(palState, 106, 99)
const zealAr = vmZeal?.current.effects.find(e => e.textZh.includes('攻擊加成') || e.textZh.includes('準確率'))
expect(zealAr?.textZh).toBe('攻擊加成:+200%')
// Sacrifice (96): ToHit = 20, LevToHit = 7 -> 20 + (lvl - 1) * 7 = +83%
palState.hardPoints[96] = 10
const vmSacrifice = buildTooltipViewModel(palState, 96, 99)
const sacAr = vmSacrifice?.current.effects.find(e => e.textZh.includes('準確率'))
expect(sacAr?.textZh).toBe('變動準確率:+83%')
})
it('evaluates authentic 1.13c Shout warcry duration using Param3/Param4 (ln34)', () => {
const barState = createInitialState('bar')
barState.hardPoints[138] = 1 // Shout Level 1: Param3 = 500 frames = 20 seconds
let vmShout = buildTooltipViewModel(barState, 138, 99)
let durLine = vmShout?.current.effects.find(e => e.lineType === 12)
expect(durLine?.textZh).toBe('持續:20 秒')
barState.hardPoints[138] = 20 // Shout Level 20: 500 + 19 * 250 = 5250 frames = 210 seconds
vmShout = buildTooltipViewModel(barState, 138, 99)
durLine = vmShout?.current.effects.find(e => e.lineType === 12)
expect(durLine?.textZh).toBe('持續:210 秒')
})
it('strictly enforces that synergies ignore soft gear points (1.13c Marrowwalk fix)', () => {
const sorState1 = createInitialState('sor')
sorState1.hardPoints[36] = 20 // 20 hard points in Fire Bolt
const vmHard = buildTooltipViewModel(sorState1, 47, 99)
const synHard = vmHard?.synergiesReceived.find(s => s.providerId === 36)
expect(synHard?.hardPoints).toBe(20)
expect(synHard?.currentBonusPct).toBe(280) // 20 * 14% = 280%
const sorState2 = createInitialState('sor')
sorState2.hardPoints[36] = 0 // 0 hard points
sorState2.allSkillsSoftPoints = 20 // +20 soft points from gear
const vmSoft = buildTooltipViewModel(sorState2, 47, 99)
const synSoft = vmSoft?.synergiesReceived.find(s => s.providerId === 36)
expect(synSoft?.hardPoints).toBe(0)
expect(synSoft?.currentBonusPct).toBe(0) // Soft points give 0% synergy
})
})

View File

@ -37,6 +37,11 @@ import {
LEVEL_NAMES_ZH,
LEVEL_NAMES_EN,
} from '../src/game/level-names-zh.ts'
import {
buildTooltipViewModel,
createInitialState,
allocatePoint,
} from '../src/game/skill-calc-engine.ts'
describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
it('verifies offline DC6 UI asset extraction manifest has 0 decode failures', () => {
@ -635,4 +640,122 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
expect(sceneNameZh(null as any)).toBe('Unknown Area')
expect(sceneNameEn(null as any)).toBe('Unknown Area')
})
it('renders authentic multi-line skill tooltips with 1.13c next-level preview and styling rules (Issue #152)', () => {
// 1. Unlearned skill with requirements not met (Sorceress lvl 1 looking at Fire Ball lvl 12)
const sorcBuild = createInitialState('sor')
const unlearnedFireBall = buildTooltipViewModel(sorcBuild, 47, 1) // Fire Ball requires clvl 12, Fire Bolt
expect(unlearnedFireBall).not.toBeNull()
const unlearnedLines = SkillTreePanel.formatSkillTooltipLines(unlearnedFireBall!)
// Title: Fire Ball in Gold
expect(unlearnedLines[0]).toEqual({ text: '火球 (Fire Ball)', color: 'gold', font: 'fontexocet10', align: 'center' })
// Required Level: red when character level < reqlevel
const reqLevelLine = unlearnedLines.find(l => l.text.includes('需要角色等级: 12'))
expect(reqLevelLine).toBeDefined()
expect(reqLevelLine?.color).toBe('red')
// Prerequisites: red when unmet
const prereqLine = unlearnedLines.find(l => l.text.includes('需要前置技能: 火彈'))
expect(prereqLine).toBeDefined()
expect(prereqLine?.color).toBe('red')
// Unlearned skill MUST NOT include [下一等级] preview
expect(unlearnedLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
// 2. Unlearned skill with requirements met (Sorceress lvl 1 looking at Fire Bolt lvl 1)
const fireBolt = buildTooltipViewModel(sorcBuild, 36, 1)
expect(fireBolt).not.toBeNull()
const fireBoltLines = SkillTreePanel.formatSkillTooltipLines(fireBolt!)
// When level is met, reqlevel line is omitted from tooltip
expect(fireBoltLines.some(l => l.text.includes('需要角色等级'))).toBe(false)
expect(fireBoltLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
// 3. Learned skill (slvl = 1) with next-level preview in green
const learnedBuild = createInitialState('sor')
allocatePoint(learnedBuild, 36) // Fire Bolt +1
const learnedFireBolt = buildTooltipViewModel(learnedBuild, 36, 1)
expect(learnedFireBolt).not.toBeNull()
const learnedLines = SkillTreePanel.formatSkillTooltipLines(learnedFireBolt!)
const currLevelLine = learnedLines.find(l => l.text.includes('当前技能等级: 1'))
expect(currLevelLine).toBeDefined()
expect(currLevelLine?.color).toBe('white')
const nextLevelHeader = learnedLines.find(l => l.text.includes('[下一等级] (Lv 2)'))
expect(nextLevelHeader).toBeDefined()
expect(nextLevelHeader?.color).toBe('green')
// Next level damage preview in green
const nextLevelDmg = learnedLines.find(l => l.color === 'green' && l.text.includes('火焰伤害'))
expect(nextLevelDmg).toBeDefined()
// 4. Maxed skill (hardPoints === 20): omits [下一等级] and shows max allocation notice in gold
const maxedBuild = createInitialState('sor')
allocatePoint(maxedBuild, 36, 20)
const maxedFireBolt = buildTooltipViewModel(maxedBuild, 36, 20)
expect(maxedFireBolt).not.toBeNull()
const maxedLines = SkillTreePanel.formatSkillTooltipLines(maxedFireBolt!)
expect(maxedLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
const maxedNotice = maxedLines.find(l => l.text.includes('[已达技能点投入上限 (20/20)]'))
expect(maxedNotice).toBeDefined()
expect(maxedNotice?.color).toBe('gold')
// 5. Synergy section formatting
const fireballBuild = createInitialState('sor')
allocatePoint(fireballBuild, 36) // Fire Bolt +1
const fbVm = buildTooltipViewModel(fireballBuild, 47, 12)
const fbLines = SkillTreePanel.formatSkillTooltipLines(fbVm!)
const synHeader = fbLines.find(l => l.text.includes('[协同技能加成]'))
expect(synHeader).toBeDefined()
expect(synHeader?.color).toBe('blue')
const synFireBolt = fbLines.find(l => l.text.includes('火彈: 每个有效技能点增加 14% 伤害'))
expect(synFireBolt).toBeDefined()
expect(synFireBolt?.color).toBe('blue') // active synergy (Fire Bolt has 1 hard point)
const synMeteor = fbLines.find(l => l.text.includes('隕石: 每个有效技能点增加 14% 伤害'))
expect(synMeteor).toBeDefined()
expect(synMeteor?.color).toBe('gray') // inactive synergy (Meteor has 0 hard points)
})
it('clamps tooltip placement within canvas viewport and strictly above the control bar (Issue #152)', () => {
const dummyLines = [
{ text: '火球', color: 'white' as const, font: 'fontexocet10' as const, align: 'center' as const },
{ text: '当前等级: 1', color: 'white' as const, font: 'font8' as const, align: 'center' as const },
{ text: '火焰伤害: 6 - 14', color: 'blue' as const, font: 'font8' as const, align: 'center' as const },
{ text: '[下一等级] (Lv 2)', color: 'green' as const, font: 'font8' as const, align: 'center' as const },
{ text: '火焰伤害: 10 - 20', color: 'green' as const, font: 'font8' as const, align: 'center' as const },
{ text: '[协同技能加成]', color: 'blue' as const, font: 'fontexocet10' as const, align: 'center' as const },
{ text: '• 火弹: 每个有效技能点增加 14% 伤害 (当前 +14%)', color: 'blue' as const, font: 'font8' as const, align: 'left' as const },
{ text: '• 陨石: 每个有效技能点增加 14% 伤害', color: 'gray' as const, font: 'font8' as const, align: 'left' as const },
]
const mockFont = {
measureText: (txt: string) => txt.length * 8,
} as any
// Test extreme positions across the 800x600 screen
const testPositions = [
{ x: 420, y: 80, w: 48, h: 48 }, // Top-left node in right-dock panel
{ x: 700, y: 120, w: 48, h: 48 }, // Far-right node
{ x: 450, y: 400, w: 48, h: 48 }, // Bottom node
{ x: 500, y: 500, w: 48, h: 48 }, // Extreme bottom node near control bar
{ x: 50, y: 200, w: 48, h: 48 }, // Far left node
]
for (const pos of testPositions) {
const placement = SkillTreePanel.computeTooltipPlacement(pos, dummyLines, mockFont)
// Viewport bounds: x >= 8, x + w <= 792 (800 - 8)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
// Vertical bounds: y >= 8, y + h <= 552 (strictly above control bar y=552)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
// Minimum width is 240
expect(placement.w).toBeGreaterThanOrEqual(240)
// Height matches line count * 17 + 24
expect(placement.h).toBe(dummyLines.length * 17 + 24)
}
})
})