192 lines
9.4 KiB
TypeScript
192 lines
9.4 KiB
TypeScript
import { describe, expect, it, beforeAll } from 'vitest'
|
|
import * as fs from 'fs'
|
|
import { MpqArchive } from '../src/mpq/archive.ts'
|
|
import { fileSource } from '../src/mpq/file-source.ts'
|
|
import { MountedArchives } from '../src/mpq/mount.ts'
|
|
import { parseTable } from '../src/game/acts.ts'
|
|
import { loadUniqueItems } from '../src/game/unique-items.ts'
|
|
import {
|
|
parseSetItemsTable,
|
|
parseSetsTable,
|
|
CANONICAL_SET_ITEMS_TSV,
|
|
CANONICAL_SETS_TSV,
|
|
} from '../src/game/set-items.ts'
|
|
import { loadMagicAffixes } from '../src/game/affixes.ts'
|
|
import { loadAutoMagic } from '../src/game/automagic.ts'
|
|
import { formatPropertyCode, formatItemTooltip, resolveSkillInfo } from '../src/game/item-tooltip.ts'
|
|
import { createDroppedItem } from '../src/game/drop-pipeline.ts'
|
|
import type { ItemBase } from '../src/game/items.ts'
|
|
|
|
describe('Issue #124: Canonical 1.13c Property Descriptions & Ground Truth Templates', () => {
|
|
it('formats rep-dur using D2Client.dll descFunc=11 (seconds = Math.floor(100 / par)) and ModStre9t/ModStre9u', () => {
|
|
// Sandstorm Trek / Demonlimb / Steel Carapice / of Fast Repair: par = 5 -> 100 / 5 = 20 seconds
|
|
expect(formatPropertyCode('rep-dur', 0, 0, 0, 5)).toBe('Repairs 1 durability in 20 seconds')
|
|
expect(formatPropertyCode('rep-dur', 5, 0, 0, 5)).toBe('Repairs 1 durability in 20 seconds')
|
|
|
|
// Skullder's Ire / Ginther's Rift: par = 20 -> 100 / 20 = 5 seconds
|
|
expect(formatPropertyCode('rep-dur', 0, 0, 0, 20)).toBe('Repairs 1 durability in 5 seconds')
|
|
|
|
// Warlord's Trust: par = 25 -> 100 / 25 = 4 seconds
|
|
expect(formatPropertyCode('rep-dur', 0, 0, 0, 25)).toBe('Repairs 1 durability in 4 seconds')
|
|
|
|
// Hone Sundan: par = 10 -> 100 / 10 = 10 seconds
|
|
expect(formatPropertyCode('rep-dur', 0, 0, 0, 10)).toBe('Repairs 1 durability in 10 seconds')
|
|
|
|
// of Self-Repair: param = '3' -> Math.floor(100 / 3) = 33 seconds
|
|
expect(formatPropertyCode('rep-dur', 0, 0, 0, '3')).toBe('Repairs 1 durability in 33 seconds')
|
|
|
|
// High rate >= 100 -> ModStre9t ("Repairs 1 durability per second")
|
|
expect(formatPropertyCode('rep-dur', 0, 0, 0, 100)).toBe('Repairs 1 durability per second')
|
|
})
|
|
|
|
it('resolves internal Skills.txt names and numeric IDs to 1.13c .tbl display names and class restrictions', () => {
|
|
expect(resolveSkillInfo('Wearwolf')).toEqual({ name: 'Werewolf', classOnly: '(Druid Only)' })
|
|
expect(resolveSkillInfo('Summon Fenris')).toEqual({ name: 'Summon Dire Wolf', classOnly: '(Druid Only)' })
|
|
expect(resolveSkillInfo('Plague Poppy')).toEqual({ name: 'Poison Creeper', classOnly: '(Druid Only)' })
|
|
expect(resolveSkillInfo('Eruption')).toEqual({ name: 'Fissure', classOnly: '(Druid Only)' })
|
|
expect(resolveSkillInfo('Wake of Fire Sentry')).toEqual({ name: 'Wake of Fire', classOnly: '(Assassin Only)' })
|
|
expect(resolveSkillInfo('BloodGolem')).toEqual({ name: 'Blood Golem', classOnly: '(Necromancer Only)' })
|
|
expect(resolveSkillInfo('IronGolem')).toEqual({ name: 'Iron Golem', classOnly: '(Necromancer Only)' })
|
|
expect(resolveSkillInfo(197)).toEqual({ name: 'Firestorm' })
|
|
expect(resolveSkillInfo(54)).toEqual({ name: 'Teleport', classOnly: '(Sorceress Only)' })
|
|
|
|
// Single class skill (descFunc=27) vs oskill (descFunc=28)
|
|
expect(formatPropertyCode('skill', 3, 3, 3, 54)).toBe('+3 to Teleport (Sorceress Only)')
|
|
expect(formatPropertyCode('oskill', 4, 3, 6, 'Wearwolf')).toBe('+4 to Werewolf')
|
|
expect(formatPropertyCode('aura', 12, 10, 13, 'Sanctuary')).toBe('Level 12 Sanctuary Aura When Equipped')
|
|
})
|
|
|
|
it('formats skilltab (0..20), event procs (descFunc=15), charged skills (descFunc=24), and reanimate (descFunc=23)', () => {
|
|
// CharStats.txt StrSkillTab1..3 + StrClassOnly
|
|
expect(formatPropertyCode('skilltab', 2, 2, 2, 0)).toBe('+2 to Bow and Crossbow Skills (Amazon Only)')
|
|
expect(formatPropertyCode('skilltab', 2, 2, 2, 6)).toBe('+2 to Curses (Necromancer Only)')
|
|
expect(formatPropertyCode('skilltab', 2, 2, 2, 10)).toBe('+2 to Offensive Auras (Paladin Only)')
|
|
expect(formatPropertyCode('skilltab', 2, 2, 2, 14)).toBe('+2 to Warcries (Barbarian Only)')
|
|
expect(formatPropertyCode('skilltab', 1, 1, 1, 16)).toBe('+1 to Shape Shifting Skills (Druid Only)')
|
|
expect(formatPropertyCode('skilltab', 2, 2, 2, 19)).toBe('+2 to Shadow Disciplines (Assassin Only)')
|
|
|
|
// Event procs (min = chance %, max = slvl, par = skill)
|
|
expect(formatPropertyCode('hit-skill', 5, 5, 10, 197)).toBe('5% Chance to cast level 10 Firestorm on striking')
|
|
expect(formatPropertyCode('gethit-skill', 25, 25, 1, 54)).toBe('25% Chance to cast level 1 Teleport when struck')
|
|
expect(formatPropertyCode('death-skill', 100, 100, 47, 'Chain Lightning')).toBe(
|
|
'100% Chance to cast level 47 Chain Lightning when you Die',
|
|
)
|
|
expect(formatPropertyCode('levelup-skill', 100, 100, 41, 'Nova')).toBe(
|
|
'100% Chance to cast level 41 Nova when you Level-Up',
|
|
)
|
|
|
|
// Charged skill (min = charges, max = slvl, par = skill)
|
|
expect(formatPropertyCode('charged', 69, 69, 11, 54)).toBe('Level 11 Teleport (69/69 Charges)')
|
|
expect(formatPropertyCode('charged', 15, 15, 21, 'Plague Poppy')).toBe('Level 21 Poison Creeper (15/15 Charges)')
|
|
|
|
// Tomb Reaver reanimate (par = 1 -> Returned)
|
|
expect(formatPropertyCode('reanimate', 10, 10, 10, 1)).toBe('10% Reanimate as: Returned')
|
|
})
|
|
|
|
it('formats speed steps, damage reduction, pierce/mastery, and end-to-end dropped unique items', () => {
|
|
expect(formatPropertyCode('swing2', 20)).toBe('+20% Increased Attack Speed')
|
|
expect(formatPropertyCode('cast3', 20)).toBe('+20% Faster Cast Rate')
|
|
expect(formatPropertyCode('balance2', 25)).toBe('+25% Faster Hit Recovery')
|
|
expect(formatPropertyCode('move3', 40)).toBe('+40% Faster Run/Walk')
|
|
expect(formatPropertyCode('block2', 30)).toBe('+30% Faster Block Rate')
|
|
expect(formatPropertyCode('ease', -20)).toBe('Requirements -20%')
|
|
expect(formatPropertyCode('red-dmg', 7)).toBe('Damage Reduced by 7')
|
|
expect(formatPropertyCode('red-dmg%', 20)).toBe('Damage Reduced by 20%')
|
|
expect(formatPropertyCode('red-mag', 15)).toBe('Magic Damage Reduced by 15')
|
|
expect(formatPropertyCode('pierce-fire', 15)).toBe('-15% to Enemy Fire Resistance')
|
|
expect(formatPropertyCode('extra-cold', 15)).toBe('+15% to Cold Skill Damage')
|
|
|
|
// End-to-end createDroppedItem + formatItemTooltip for Sandstorm Trek (rep-dur par=5 -> 20s)
|
|
const base: ItemBase = {
|
|
id: 'uvb',
|
|
name: 'Scarabshell Boots',
|
|
kind: 'armor',
|
|
tags: ['boot', 'armo'],
|
|
damage: 0,
|
|
defense: 65,
|
|
value: 1000,
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
maxStack: 1,
|
|
level: 66,
|
|
}
|
|
const sandstormTrek = createDroppedItem(base, 'unique', {
|
|
ilvl: 87,
|
|
dwInitSeed: 12345,
|
|
uniqueItem: {
|
|
id: 260,
|
|
index: 'Sandstorm Trek',
|
|
version: 100,
|
|
enabled: true,
|
|
ladder: false,
|
|
rarity: 1,
|
|
nolimit: false,
|
|
lvl: 72,
|
|
levelreq: 64,
|
|
code: 'uvb',
|
|
carry1: false,
|
|
costMult: 5,
|
|
costAdd: 5000,
|
|
props: [
|
|
{ code: 'ac%', min: 140, max: 170 },
|
|
{ code: 'move2', min: 20, max: 20 },
|
|
{ code: 'balance2', min: 20, max: 20 },
|
|
{ code: 'rep-dur', par: 5, min: 0, max: 0 },
|
|
],
|
|
},
|
|
})
|
|
|
|
const tooltip = formatItemTooltip(sandstormTrek)
|
|
const texts = tooltip.lines.map(l => l.text)
|
|
expect(texts).toContain('Repairs 1 durability in 20 seconds')
|
|
expect(texts).toContain('+20% Faster Run/Walk')
|
|
expect(texts).toContain('+20% Faster Hit Recovery')
|
|
})
|
|
|
|
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
|
|
it.skipIf(!hasD2)('verifies 100% of all 180 property codes across 1.13c MPQ tables have 0 unmapped fallbacks', async () => {
|
|
const archives = new MountedArchives()
|
|
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
|
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
|
}
|
|
const uniques = await loadUniqueItems(archives)
|
|
const setItems = parseSetItemsTable(parseTable(new TextEncoder().encode(CANONICAL_SET_ITEMS_TSV)))
|
|
const sets = parseSetsTable(parseTable(new TextEncoder().encode(CANONICAL_SETS_TSV)), setItems)
|
|
const affixes = await loadMagicAffixes(archives)
|
|
const automagic = await loadAutoMagic(archives)
|
|
|
|
const usedCodes = new Map<string, any>()
|
|
const record = (code: string, ex: any) => {
|
|
const c = (code || '').trim()
|
|
if (!c || c.startsWith('*')) return
|
|
if (!usedCodes.has(c)) usedCodes.set(c, ex)
|
|
}
|
|
|
|
for (const u of uniques.all || []) for (const p of u.props) record(p.code, p)
|
|
for (const s of setItems.rows || []) {
|
|
for (const p of s.props) record(p.code, p)
|
|
for (const p of s.aprops) record(p.code, p)
|
|
}
|
|
for (const s of sets.rows || []) {
|
|
for (const p of [...(s.pCodes || []), ...(s.fCodes || [])]) record(p.code, p)
|
|
}
|
|
for (const a of [...(affixes.prefixes.all || []), ...(affixes.suffixes.all || [])]) {
|
|
for (const m of a.mods) record(m.code, m)
|
|
}
|
|
for (const a of automagic.entries || []) {
|
|
for (const m of a.mods) record(m.code, m)
|
|
}
|
|
|
|
expect(usedCodes.size).toBe(182)
|
|
|
|
const unmapped: string[] = []
|
|
for (const [code, ex] of usedCodes.entries()) {
|
|
const formatted = formatPropertyCode(code, ex.min || 1, ex.min || 1, ex.max || 1, ex.par ?? ex.param)
|
|
if (formatted.toLowerCase().startsWith(`${code.toLowerCase()}:`)) {
|
|
unmapped.push(`${code} -> ${formatted}`)
|
|
}
|
|
}
|
|
expect(unmapped).toEqual([])
|
|
})
|
|
})
|