diablo2-web/tests/classes.test.ts

420 lines
19 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import * as fs from 'fs'
import {
CHARACTER_CLASS_CODES,
CHARACTER_SPRITE_TOKENS,
CHARACTER_CLASS_NAMES,
TOKEN_ALIASES,
CANONICAL_CHARSTATS,
resolveCharacterClass,
getAllCharacterClasses,
parseCharStatsTable,
parseCharStatsRow,
calculateVitalsAtLevel,
} from '../src/game/classes.ts'
import type { CharacterClassCode, CharacterSpriteToken } from '../src/game/classes.ts'
import { loadActTables, parseTable } from '../src/game/acts.ts'
import type { ActTables, D2Table } from '../src/game/acts.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadCharacterSheet } from '../src/game/character.ts'
const hasRealData = fs.existsSync('samples/d2/d2char.mpq') && fs.existsSync('samples/d2/Patch_D2.mpq')
describe('Diablo II Character Classes & 1.13c Data Specification (Milestone M1)', () => {
describe('7 Canonical Classes Catalog & Identifiers', () => {
it('defines exactly 7 canonical classes with matching codes and tokens', () => {
expect(CHARACTER_CLASS_CODES).toHaveLength(7)
expect(CHARACTER_SPRITE_TOKENS).toHaveLength(7)
expect(CHARACTER_CLASS_NAMES).toHaveLength(7)
const expectedCodes: CharacterClassCode[] = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass']
const expectedTokens: CharacterSpriteToken[] = ['am', 'so', 'ne', 'pa', 'ba', 'dz', 'ai']
const expectedNames = ['Amazon', 'Sorceress', 'Necromancer', 'Paladin', 'Barbarian', 'Druid', 'Assassin']
expect(CHARACTER_CLASS_CODES).toEqual(expectedCodes)
expect(CHARACTER_SPRITE_TOKENS).toEqual(expectedTokens)
expect(CHARACTER_CLASS_NAMES).toEqual(expectedNames)
})
it('getAllCharacterClasses returns all 7 classes in canonical Blizzard order', () => {
const classes = getAllCharacterClasses()
expect(classes).toHaveLength(7)
expect(classes.map(c => c.name)).toEqual([
'Amazon', 'Sorceress', 'Necromancer', 'Paladin', 'Barbarian', 'Druid', 'Assassin',
])
expect(classes.map(c => c.code)).toEqual(['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'])
expect(classes.map(c => c.token)).toEqual(['am', 'so', 'ne', 'pa', 'ba', 'dz', 'ai'])
})
})
describe('Multi-Key Class Resolution (Name, 3-Letter Code, 2-Letter Token)', () => {
const testCases: Array<{
name: string
code: CharacterClassCode
token: CharacterSpriteToken
queries: string[]
}> = [
{
name: 'Amazon',
code: 'ama',
token: 'am',
queries: ['Amazon', 'amazon', 'AMAZON', 'ama', 'AMA', 'am', 'AM'],
},
{
name: 'Sorceress',
code: 'sor',
token: 'so',
queries: ['Sorceress', 'sorceress', 'SORCERESS', 'sor', 'SOR', 'so', 'SO'],
},
{
name: 'Necromancer',
code: 'nec',
token: 'ne',
queries: ['Necromancer', 'necromancer', 'NECROMANCER', 'nec', 'NEC', 'ne', 'NE'],
},
{
name: 'Paladin',
code: 'pal',
token: 'pa',
queries: ['Paladin', 'paladin', 'PALADIN', 'pal', 'PAL', 'pa', 'PA'],
},
{
name: 'Barbarian',
code: 'bar',
token: 'ba',
queries: ['Barbarian', 'barbarian', 'BARBARIAN', 'bar', 'BAR', 'ba', 'BA'],
},
{
name: 'Druid',
code: 'dru',
token: 'dz',
queries: ['Druid', 'druid', 'DRUID', 'dru', 'DRU', 'dz', 'DZ', 'dr', 'DR'],
},
{
name: 'Assassin',
code: 'ass',
token: 'ai',
queries: ['Assassin', 'assassin', 'ASSASSIN', 'ass', 'ASS', 'ai', 'AI', 'as', 'AS'],
},
]
for (const tc of testCases) {
it(`resolves ${tc.name} from all accepted queries (${tc.queries.join(', ')})`, () => {
for (const query of tc.queries) {
const resolved = resolveCharacterClass(query)
expect(resolved.name).toBe(tc.name)
expect(resolved.code).toBe(tc.code)
expect(resolved.token).toBe(tc.token)
}
})
}
it('maps token alias "as" to canonical "ai" (Assassin)', () => {
const fromAlias = resolveCharacterClass('as')
const fromCanonical = resolveCharacterClass('ai')
expect(fromAlias).toBe(fromCanonical)
expect(fromAlias.name).toBe('Assassin')
expect(fromAlias.token).toBe('ai')
expect(TOKEN_ALIASES['as']).toBe('ai')
})
it('throws descriptive error on invalid or unknown class input', () => {
expect(() => resolveCharacterClass('monk')).toThrowError(/Unknown character class: "monk"/)
expect(() => resolveCharacterClass('wizard')).toThrowError(/Unknown character class: "wizard"/)
expect(() => resolveCharacterClass('paladin123')).toThrowError(/Unknown character class: "paladin123"/)
expect(() => resolveCharacterClass('')).toThrowError(/Invalid empty character class input/)
expect(() => resolveCharacterClass(' ')).toThrowError(/Invalid empty character class input/)
expect(() => resolveCharacterClass('constructor')).toThrowError(/Unknown character class: "constructor"/)
expect(() => resolveCharacterClass('__proto__')).toThrowError(/Unknown character class: "__proto__"/)
expect(() => resolveCharacterClass('valueOf')).toThrowError(/Unknown character class: "valueOf"/)
})
})
describe('1.13c CharStats.txt Starting Attributes & Base Vitals Ground Truth', () => {
it('Amazon starting stats match 1.13c ground truth', () => {
const c = CANONICAL_CHARSTATS.ama
expect(c.baseAttributes).toEqual({ strength: 20, dexterity: 25, vitality: 20, energy: 15 })
expect(c.baseVitals).toEqual({ life: 50, mana: 15, stamina: 84 }) // life = vit + 30
expect(c.perLevel).toEqual({ life: 2.0, mana: 1.5, stamina: 1.0, statPoints: 5 })
expect(c.perStatPoint).toEqual({ lifePerVitality: 3.0, staminaPerVitality: 1.0, manaPerEnergy: 1.5 })
expect(c.blockFactor).toBe(25)
expect(c.runDrain).toBe(20)
expect(c.toHitFactor).toBe(5)
expect(c.manaRegenSeconds).toBe(120)
expect(c.baseWClass).toBe('hth')
})
it('Sorceress starting stats match 1.13c ground truth', () => {
const c = CANONICAL_CHARSTATS.sor
expect(c.baseAttributes).toEqual({ strength: 10, dexterity: 25, vitality: 10, energy: 35 })
expect(c.baseVitals).toEqual({ life: 40, mana: 35, stamina: 74 })
expect(c.perLevel).toEqual({ life: 1.0, mana: 2.0, stamina: 1.0, statPoints: 5 })
expect(c.perStatPoint).toEqual({ lifePerVitality: 2.0, staminaPerVitality: 1.0, manaPerEnergy: 2.0 })
expect(c.blockFactor).toBe(20)
expect(c.runDrain).toBe(20)
expect(c.toHitFactor).toBe(-15)
expect(c.startSkill).toBe('fire bolt')
})
it('Necromancer starting stats match 1.13c ground truth', () => {
const c = CANONICAL_CHARSTATS.nec
expect(c.baseAttributes).toEqual({ strength: 15, dexterity: 25, vitality: 15, energy: 25 })
expect(c.baseVitals).toEqual({ life: 45, mana: 25, stamina: 79 })
expect(c.perLevel).toEqual({ life: 1.5, mana: 2.0, stamina: 1.0, statPoints: 5 })
expect(c.perStatPoint).toEqual({ lifePerVitality: 2.0, staminaPerVitality: 1.0, manaPerEnergy: 2.0 })
expect(c.blockFactor).toBe(20)
expect(c.runDrain).toBe(20)
expect(c.toHitFactor).toBe(-10)
expect(c.startSkill).toBe('raise skeleton')
})
it('Paladin starting stats match 1.13c ground truth', () => {
const c = CANONICAL_CHARSTATS.pal
expect(c.baseAttributes).toEqual({ strength: 25, dexterity: 20, vitality: 25, energy: 15 })
expect(c.baseVitals).toEqual({ life: 55, mana: 15, stamina: 89 })
expect(c.perLevel).toEqual({ life: 2.0, mana: 1.5, stamina: 1.0, statPoints: 5 })
expect(c.perStatPoint).toEqual({ lifePerVitality: 3.0, staminaPerVitality: 1.0, manaPerEnergy: 1.5 })
expect(c.blockFactor).toBe(30)
expect(c.runDrain).toBe(20)
expect(c.toHitFactor).toBe(20)
})
it('Barbarian starting stats match 1.13c ground truth', () => {
const c = CANONICAL_CHARSTATS.bar
expect(c.baseAttributes).toEqual({ strength: 30, dexterity: 20, vitality: 25, energy: 10 })
expect(c.baseVitals).toEqual({ life: 55, mana: 10, stamina: 92 })
expect(c.perLevel).toEqual({ life: 2.0, mana: 1.0, stamina: 1.0, statPoints: 5 })
expect(c.perStatPoint).toEqual({ lifePerVitality: 4.0, staminaPerVitality: 1.0, manaPerEnergy: 1.0 })
expect(c.blockFactor).toBe(25)
expect(c.runDrain).toBe(20)
expect(c.toHitFactor).toBe(20)
})
it('Druid starting stats match 1.13c ground truth', () => {
const c = CANONICAL_CHARSTATS.dru
expect(c.baseAttributes).toEqual({ strength: 15, dexterity: 20, vitality: 25, energy: 20 })
expect(c.baseVitals).toEqual({ life: 55, mana: 20, stamina: 84 })
expect(c.perLevel).toEqual({ life: 1.5, mana: 2.0, stamina: 1.0, statPoints: 5 })
expect(c.perStatPoint).toEqual({ lifePerVitality: 2.0, staminaPerVitality: 1.0, manaPerEnergy: 2.0 })
expect(c.blockFactor).toBe(20)
expect(c.runDrain).toBe(20)
expect(c.toHitFactor).toBe(5)
})
it('Assassin starting stats match 1.13c ground truth', () => {
const c = CANONICAL_CHARSTATS.ass
expect(c.baseAttributes).toEqual({ strength: 20, dexterity: 20, vitality: 20, energy: 25 })
expect(c.baseVitals).toEqual({ life: 50, mana: 25, stamina: 95 })
expect(c.perLevel).toEqual({ life: 2.0, mana: 1.5, stamina: 1.25, statPoints: 5 })
expect(c.perStatPoint).toEqual({ lifePerVitality: 3.0, staminaPerVitality: 1.25, manaPerEnergy: 1.75 })
expect(c.blockFactor).toBe(25)
expect(c.runDrain).toBe(15) // Assassin has unique 15 runDrain (vs 20)
expect(c.toHitFactor).toBe(15)
})
})
describe('Level Progression & Vitals Formula Evaluation', () => {
it('evaluates Level 1 base vitals without additional stat allocation', () => {
const bar = CANONICAL_CHARSTATS.bar
const vitals = calculateVitalsAtLevel(bar, 1)
expect(vitals).toEqual({ life: 55, mana: 10, stamina: 92 })
})
it('evaluates Level 2 Barbarian vitals scaling with perLevel gains', () => {
const bar = CANONICAL_CHARSTATS.bar
const vitals = calculateVitalsAtLevel(bar, 2)
// Level 2 Barbarian gains +2 Life, +1 Mana, +1 Stamina
expect(vitals).toEqual({ life: 57, mana: 11, stamina: 93 })
})
it('evaluates Barbarian vitals with allocated Vitality stat points', () => {
const bar = CANONICAL_CHARSTATS.bar
// Barbarian receives +4 Life per Vitality point, +1 Stamina per Vitality
const vitals = calculateVitalsAtLevel(bar, 1, { vitality: 10 })
expect(vitals.life).toBe(55 + 10 * 4) // 95
expect(vitals.stamina).toBe(92 + 10 * 1) // 102
expect(vitals.mana).toBe(10)
})
it('evaluates fractional vitals scaling for Assassin (1.25 stamina per level)', () => {
const ass = CANONICAL_CHARSTATS.ass
const lvl2 = calculateVitalsAtLevel(ass, 2)
expect(lvl2.stamina).toBe(95 + 1.25)
const lvl5 = calculateVitalsAtLevel(ass, 5)
expect(lvl5.stamina).toBe(95 + 4 * 1.25) // 100
expect(lvl5.life).toBe(50 + 4 * 2.0) // 58
expect(lvl5.mana).toBe(25 + 4 * 1.5) // 31
})
it('evaluates fractional vitals scaling for Necromancer (1.5 life per level)', () => {
const nec = CANONICAL_CHARSTATS.nec
const lvl3 = calculateVitalsAtLevel(nec, 3)
expect(lvl3.life).toBe(45 + 2 * 1.5) // 48
expect(lvl3.mana).toBe(25 + 2 * 2.0) // 29
})
})
describe('CharStats.txt Dynamic Table Parser', () => {
it('skips Expansion separator row and non-class rows', () => {
const mockTable: D2Table = {
header: ['class', 'str', 'dex', 'int', 'vit', 'stamina', 'hpadd'],
rows: [
['Expansion', '0', '0', '0', '0', '0', '0'],
['', '0', '0', '0', '0', '0', '0'],
['Paladin', '25', '20', '15', '25', '89', '30'],
],
}
expect(parseCharStatsRow(mockTable, mockTable.rows[0]!)).toBeUndefined()
expect(parseCharStatsRow(mockTable, mockTable.rows[1]!)).toBeUndefined()
const palParsed = parseCharStatsRow(mockTable, mockTable.rows[2]!)
expect(palParsed).toBeDefined()
expect(palParsed!.name).toBe('Paladin')
expect(palParsed!.baseAttributes.strength).toBe(25)
})
it('parseCharStatsTable parses valid rows and backfills missing classes from canonical defaults', () => {
const mockTable: D2Table = {
header: ['class', 'str', 'dex', 'int', 'vit', 'stamina', 'hpadd'],
rows: [
['Barbarian', '35', '25', '10', '30', '95', '30'], // modified barbarian
],
}
const catalog = parseCharStatsTable(mockTable)
expect(catalog.bar.baseAttributes.strength).toBe(35)
expect(catalog.bar.baseAttributes.dexterity).toBe(25)
expect(catalog.bar.baseVitals.life).toBe(60) // 30 vit + 30 hpadd
// Unspecified classes should still be populated from canonical fallback
expect(catalog.ama.name).toBe('Amazon')
expect(catalog.sor.name).toBe('Sorceress')
expect(catalog.nec.name).toBe('Necromancer')
expect(catalog.pal.name).toBe('Paladin')
expect(catalog.dru.name).toBe('Druid')
expect(catalog.ass.name).toBe('Assassin')
})
})
describe('ActTables Optional Table Loading & Fallback Resilience', () => {
it('loads ActTables with undefined optional tables when archives lack CharStats / Skills / SkillDesc', async () => {
// Construct a mock MountedArchives that has the 11 required tables, but no optional tables
const mockLevelsTsv = 'Id\tName\tPal\tLevelType\tWaypoint\tSizeX\tSizeY\tVis0\tWarp0\r\n1\tAct 1 - Town\t0\t1\t255\t10\t10\t0\t-1\r\n'
const mockLvlTypesTsv = 'Id\tName\tFile 1\r\n1\tAct 1 - Town\tAct1/Town/town.dt1\r\n'
const mockLvlPrestTsv = 'Def\tLevelId\tFile1\tDt1Mask\r\n1\t1\tAct1/Town/town.ds1\t0\r\n'
const mockEmptyTsv = 'Id\r\n'
const mockArchives = {
has(member: string) {
const lower = member.toLowerCase()
return !lower.includes('charstats') && !lower.includes('skills') && !lower.includes('skilldesc')
},
async read(member: string) {
const lower = member.toLowerCase()
if (lower.includes('levels.txt')) return new TextEncoder().encode(mockLevelsTsv)
if (lower.includes('lvltypes.txt')) return new TextEncoder().encode(mockLvlTypesTsv)
if (lower.includes('lvlprest.txt')) return new TextEncoder().encode(mockLvlPrestTsv)
if (lower.includes('charstats') || lower.includes('skills') || lower.includes('skilldesc')) {
throw new Error(`no member ${member}`)
}
return new TextEncoder().encode(mockEmptyTsv)
},
} as unknown as MountedArchives
const tables = await loadActTables(mockArchives)
expect(tables.levels).toBeDefined()
expect(tables.lvltypes).toBeDefined()
expect(tables.lvlprest).toBeDefined()
expect(tables.charstats).toBeUndefined()
expect(tables.skills).toBeUndefined()
expect(tables.skilldesc).toBeUndefined()
// When passed to class resolver, it still resolves without errors via canonical fallback
const resolved = resolveCharacterClass('bar', tables)
expect(resolved.name).toBe('Barbarian')
expect(resolved.baseAttributes.strength).toBe(30)
})
})
describe.runIf(hasRealData)('Authoritative MPQ Integration & Sprite Asset Verification', () => {
let archives: MountedArchives
it('loads Patch_D2.mpq and d2char.mpq successfully', async () => {
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}`)))
}
expect(archives.size).toBe(4)
})
it('loads real CharStats.txt and confirms 100% parity with CANONICAL_CHARSTATS', async () => {
const tables = await loadActTables(archives)
expect(tables.charstats).toBeDefined()
expect(tables.skills).toBeDefined()
expect(tables.skilldesc).toBeDefined()
const parsedCatalog = parseCharStatsTable(tables.charstats!)
for (const code of CHARACTER_CLASS_CODES) {
const parsed = parsedCatalog[code]
const canonical = CANONICAL_CHARSTATS[code]
expect(parsed.name).toBe(canonical.name)
expect(parsed.code).toBe(canonical.code)
expect(parsed.token).toBe(canonical.token)
expect(parsed.baseAttributes).toEqual(canonical.baseAttributes)
expect(parsed.baseVitals).toEqual(canonical.baseVitals)
expect(parsed.perLevel).toEqual(canonical.perLevel)
expect(parsed.perStatPoint).toEqual(canonical.perStatPoint)
expect(parsed.blockFactor).toBe(canonical.blockFactor)
expect(parsed.runDrain).toBe(canonical.runDrain)
expect(parsed.toHitFactor).toBe(canonical.toHitFactor)
expect(parsed.manaRegenSeconds).toBe(canonical.manaRegenSeconds)
expect(parsed.baseWClass).toBe(canonical.baseWClass)
}
})
it('verifies all 7 classes load character walk sheets (wl / hth) in 16 directions', async () => {
for (const code of CHARACTER_CLASS_CODES) {
const classDef = resolveCharacterClass(code)
const sheet = await loadCharacterSheet(archives, classDef.token, 'wl', 'hth')
expect(sheet.directions).toBe(16)
expect(sheet.framesPerDirection).toBeGreaterThanOrEqual(8)
expect(sheet.layers).toBeGreaterThanOrEqual(8)
}
})
it('verifies Assassin sprite assets load with "ai" token and alias "as" resolves to "ai" without asset errors', async () => {
// 1. Direct load of 'ai' succeeds
const aiWalk = await loadCharacterSheet(archives, 'ai', 'wl', 'hth')
expect(aiWalk.directions).toBe(16)
expect(aiWalk.framesPerDirection).toBe(8)
expect(aiWalk.layers).toBe(10)
const aiTownNeutral = await loadCharacterSheet(archives, 'ai', 'tn', 'hth')
expect(aiTownNeutral.directions).toBe(16)
expect(aiTownNeutral.framesPerDirection).toBe(12)
// 2. Unaliased 'as' does NOT exist in d2char.mpq
expect(archives.has('data\\global\\chars\\as\\cof\\aswlhth.cof')).toBe(false)
// 3. Resolving class via alias 'as' yields canonical 'ai' token, successfully loading animations
const resolvedFromAlias = resolveCharacterClass('as')
expect(resolvedFromAlias.token).toBe('ai')
const walkFromAlias = await loadCharacterSheet(archives, resolvedFromAlias.token, 'wl', 'hth')
expect(walkFromAlias.directions).toBe(16)
expect(walkFromAlias.framesPerDirection).toBe(8)
})
it('verifies Druid sprite assets load with "dz" token without asset errors', async () => {
const dzWalk = await loadCharacterSheet(archives, 'dz', 'wl', 'hth')
expect(dzWalk.directions).toBe(16)
expect(dzWalk.framesPerDirection).toBe(8)
const dzTownNeutral = await loadCharacterSheet(archives, 'dz', 'tn', 'hth')
expect(dzTownNeutral.directions).toBe(16)
expect(dzTownNeutral.framesPerDirection).toBe(10)
})
})
})