144 lines
5.7 KiB
TypeScript
144 lines
5.7 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { HERO_SPECS } from '../scripts/pack-characters.ts'
|
|
import { HERO_CLASSES, normalizeHeroToken } from '../src/scene/act-scene.ts'
|
|
|
|
describe('7 Playable Character Classes Artwork & Selection Parity', () => {
|
|
const packRoot = join(process.cwd(), 'samples', 'd2-packs')
|
|
const globalDir = join(packRoot, 'entities')
|
|
const hasGlobalEntities = existsSync(globalDir)
|
|
|
|
it('verifies all 7 hero classes are defined in HERO_CLASSES with 1.13c ground truth stats', () => {
|
|
expect(HERO_SPECS).toHaveLength(7)
|
|
for (const spec of HERO_SPECS) {
|
|
const hero = HERO_CLASSES[spec.token]
|
|
expect(hero).toBeDefined()
|
|
expect(hero.token).toBe(spec.token)
|
|
expect(hero.code).toBe(spec.code)
|
|
expect(hero.nameZh).toBe(spec.nameZh)
|
|
expect(hero.nameEn).toBe(spec.nameEn)
|
|
expect(hero.charIdentity.str).toBeGreaterThan(0)
|
|
expect(hero.charIdentity.dex).toBeGreaterThan(0)
|
|
expect(hero.charIdentity.vit).toBeGreaterThan(0)
|
|
expect(hero.charIdentity.ene).toBeGreaterThan(0)
|
|
expect(hero.charIdentity.baseMaxHp).toBeGreaterThan(0)
|
|
expect(hero.charIdentity.baseMaxMana).toBeGreaterThan(0)
|
|
expect(hero.defaultLeftSkill).toBeGreaterThanOrEqual(0)
|
|
expect(hero.defaultRightSkill).toBeGreaterThanOrEqual(0)
|
|
}
|
|
})
|
|
|
|
it('normalizes various class identifiers (token, code, names, aliases) correctly', () => {
|
|
// Sorceress
|
|
expect(normalizeHeroToken('so')).toBe('so')
|
|
expect(normalizeHeroToken('sor')).toBe('so')
|
|
expect(normalizeHeroToken('Sorceress')).toBe('so')
|
|
expect(normalizeHeroToken('sorceress')).toBe('so')
|
|
expect(normalizeHeroToken('SO')).toBe('so')
|
|
|
|
// Amazon
|
|
expect(normalizeHeroToken('am')).toBe('am')
|
|
expect(normalizeHeroToken('ama')).toBe('am')
|
|
expect(normalizeHeroToken('Amazon')).toBe('am')
|
|
|
|
// Necromancer
|
|
expect(normalizeHeroToken('ne')).toBe('ne')
|
|
expect(normalizeHeroToken('nec')).toBe('ne')
|
|
expect(normalizeHeroToken('Necromancer')).toBe('ne')
|
|
|
|
// Paladin
|
|
expect(normalizeHeroToken('pa')).toBe('pa')
|
|
expect(normalizeHeroToken('pal')).toBe('pa')
|
|
expect(normalizeHeroToken('Paladin')).toBe('pa')
|
|
|
|
// Barbarian
|
|
expect(normalizeHeroToken('ba')).toBe('ba')
|
|
expect(normalizeHeroToken('bar')).toBe('ba')
|
|
expect(normalizeHeroToken('Barbarian')).toBe('ba')
|
|
|
|
// Druid (both canonical 'dz' and common alias 'dr')
|
|
expect(normalizeHeroToken('dz')).toBe('dz')
|
|
expect(normalizeHeroToken('dru')).toBe('dz')
|
|
expect(normalizeHeroToken('dr')).toBe('dz')
|
|
expect(normalizeHeroToken('Druid')).toBe('dz')
|
|
|
|
// Assassin (both canonical 'ai' and common alias 'as')
|
|
expect(normalizeHeroToken('ai')).toBe('ai')
|
|
expect(normalizeHeroToken('ass')).toBe('ai')
|
|
expect(normalizeHeroToken('as')).toBe('ai')
|
|
expect(normalizeHeroToken('Assassin')).toBe('ai')
|
|
|
|
// Unknown fallback to 'so'
|
|
expect(normalizeHeroToken('unknown')).toBe('so')
|
|
expect(normalizeHeroToken('')).toBe('so')
|
|
})
|
|
|
|
it.skipIf(!hasGlobalEntities)('validates global Schema 2 raw R8 textures for all 7 hero classes', () => {
|
|
for (const spec of HERO_SPECS) {
|
|
const jsonPath = join(globalDir, `char-${spec.token}.json`)
|
|
const r8Path = join(globalDir, `char-${spec.token}.r8`)
|
|
|
|
expect(existsSync(jsonPath), `missing json for ${spec.token}`).toBe(true)
|
|
expect(existsSync(r8Path), `missing r8 for ${spec.token}`).toBe(true)
|
|
|
|
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
|
expect(meta.schema).toBe(2)
|
|
expect(meta.file).toBe(`char-${spec.token}.r8`)
|
|
expect(meta.format).toBe('r8')
|
|
expect(meta.directions).toBe(16)
|
|
expect([8, 10]).toContain(meta.walk)
|
|
expect(meta.walkOffset).toBe(0)
|
|
expect(meta.standOffset).toBe(16)
|
|
expect(meta.members).toBeGreaterThanOrEqual(14)
|
|
// In 1.13c, Assassin's death/dead animations have 8 directions; all other classes have 16
|
|
const expectedGroups = spec.token === 'ai' ? 160 : 176
|
|
expect(meta.groups.length).toBe(expectedGroups)
|
|
|
|
// Standard player modes present
|
|
expect(meta.clips).toBeDefined()
|
|
expect(meta.clips.nu).toBeDefined()
|
|
expect(meta.clips.wl).toBeDefined()
|
|
expect(meta.clips.rn).toBeDefined()
|
|
expect(meta.clips.a1).toBeDefined()
|
|
expect(meta.clips.gh).toBeDefined()
|
|
expect(meta.clips.dt).toBeDefined()
|
|
|
|
const r8 = readFileSync(r8Path)
|
|
expect(r8.byteLength).toBe(meta.width * meta.height)
|
|
|
|
let nonZero = 0
|
|
for (let i = 0; i < r8.length; i += 1) {
|
|
if (r8[i] !== 0) nonZero += 1
|
|
}
|
|
expect(nonZero).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it.skipIf(!hasGlobalEntities)('validates per-Act fallback PNG atlases for all 7 hero classes across Acts 1..5', () => {
|
|
for (let act = 1; act <= 5; act += 1) {
|
|
const actDir = join(packRoot, `act${act}`, 'entities')
|
|
for (const spec of HERO_SPECS) {
|
|
const jsonPath = join(actDir, `char-${spec.token}.json`)
|
|
const pngPath = join(actDir, `char-${spec.token}.png`)
|
|
|
|
expect(existsSync(jsonPath), `missing act${act} json for ${spec.token}`).toBe(true)
|
|
expect(existsSync(pngPath), `missing act${act} png for ${spec.token}`).toBe(true)
|
|
|
|
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
|
expect(meta.schema).toBe(2)
|
|
expect(meta.file).toBe(`char-${spec.token}.png`)
|
|
expect(meta.directions).toBe(16)
|
|
const expectedGroups = spec.token === 'ai' ? 160 : 176
|
|
expect(meta.groups.length).toBe(expectedGroups)
|
|
|
|
const png = readFileSync(pngPath)
|
|
expect(png[0]).toBe(137)
|
|
expect(png[1]).toBe(80) // P
|
|
expect(png[2]).toBe(78) // N
|
|
expect(png[3]).toBe(71) // G
|
|
}
|
|
}
|
|
})
|
|
})
|