diablo2-web/tests/sorceress-appearance.test.ts

199 lines
7.3 KiB
TypeScript

import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { COLOR_CODES, GEM_TRANSFORM_BY_CODE } from '../src/game/gems-data.ts'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import { resolveItemColorTransform } from '../src/ui/item-bridge.ts'
import {
SorceressLayerCompositor,
appearanceKey,
isDefaultUnarmedAppearance,
resolveSorceressAppearance,
type ResolvableEquippedItem,
} from '../src/game/char-appearance.ts'
function makeItem(overrides: Partial<ResolvableEquippedItem> & { code: string; name: string }): ResolvableEquippedItem {
return {
quality: 'normal',
identified: true,
...overrides,
}
}
describe('Sorceress Equipment Appearance & Palette Shift (1.13c D2Composit / ITEMS_GetColor)', () => {
const dropTables = getEmbeddedDropTables()
it('resolves item color transforms per 1.13c ITEMS_GetColor (Items.cpp:3699)', () => {
expect(COLOR_CODES).toHaveLength(21)
expect(COLOR_CODES[0]).toBe('whit')
expect(COLOR_CODES[20]).toBe('bwht')
expect(Object.keys(GEM_TRANSFORM_BY_CODE)).toHaveLength(35)
// Unidentified items never dye
expect(
resolveItemColorTransform({ name: 'Harlequin Crest', identified: false } as any, 'unique', 'uap', dropTables)
.chrtransform,
).toBeUndefined()
// Unique items
expect(
resolveItemColorTransform({ name: 'Harlequin Crest', identified: true } as any, 'unique', 'uap', dropTables)
.chrtransform,
).toBe('cgrn')
expect(
resolveItemColorTransform({ name: 'Skin of the Vipermagi', identified: true } as any, 'unique', 'xea', dropTables)
.chrtransform,
).toBe('dblu')
// Set items (including display name vs internal index fallback)
expect(
resolveItemColorTransform(
{ name: "Tal Rasha's Horadric Crest", identified: true } as any,
'set',
'xsk',
dropTables,
).chrtransform,
).toBe('dpur')
expect(
resolveItemColorTransform(
{ name: "Tal Rasha's Guardianship", identified: true } as any,
'set',
'uth',
dropTables,
).chrtransform,
).toBe('dpur')
// Magic/Rare/Crafted: suffix overrides prefix
expect(
resolveItemColorTransform(
{
name: 'Cobalt Cap of the Mammoth',
identified: true,
rolledMagicAffixes: {
prefixes: [{ name: 'Cobalt', transform: true, transformColor: 'cblu' }],
suffixes: [{ name: 'of the Mammoth', transform: true, transformColor: 'cred' }],
},
} as any,
'magic',
'cap',
dropTables,
).chrtransform,
).toBe('cred')
// Socketed gems vs Runeword exclusion
expect(
resolveItemColorTransform(
{
name: 'Socketed Cap',
identified: true,
socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }],
} as any,
'normal',
'cap',
dropTables,
).chrtransform,
).toBe('whit')
expect(
resolveItemColorTransform(
{
name: 'Enigma',
identified: true,
runewordRunes: "'JahIthBer'",
socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }],
} as any,
'rune',
'utp',
dropTables,
).chrtransform,
).toBeUndefined()
})
it('maps equipped gear to 10 Sorceress paperdoll components and weapon classes', () => {
const unarmed = resolveSorceressAppearance({}, dropTables)
expect(unarmed.wclass).toBe('hth')
expect(isDefaultUnarmedAppearance(unarmed)).toBe(true)
// Circlet exemption (D2Inventory.cpp:2624)
const circletSpec = resolveSorceressAppearance(
{ helm: makeItem({ code: 'ci3', name: "Griffon's Eye", quality: 'unique', chrtransform: 'cblu' }) },
dropTables,
)
expect(circletSpec.layers.hd.variant).toBe('lit')
expect(circletSpec.layers.hd.color).toBeNull()
// Asymmetric Studded Leather (stu)
const stuSpec = resolveSorceressAppearance(
{ armor: makeItem({ code: 'stu', name: 'Studded Leather', chrtransform: 'cgrn' }) },
dropTables,
)
expect(stuSpec.layers.tr).toEqual({ variant: 'lit', transform: 7, color: 'cgrn' })
expect(stuSpec.layers.lg).toEqual({ variant: 'med', transform: 7, color: 'cgrn' })
expect(stuSpec.layers.ra).toEqual({ variant: 'med', transform: 7, color: 'cgrn' })
expect(stuSpec.layers.la).toEqual({ variant: 'lit', transform: 7, color: 'cgrn' })
expect(stuSpec.layers.s1).toEqual({ variant: 'med', transform: 7, color: 'cgrn' })
expect(stuSpec.layers.s2).toEqual({ variant: 'med', transform: 7, color: 'cgrn' })
// Bow on LH vs Crossbow on RH
const bowSpec = resolveSorceressAppearance(
{ weapon1: makeItem({ code: 'sbw', name: 'Short Bow' }) },
dropTables,
)
expect(bowSpec.wclass).toBe('bow')
expect(bowSpec.layers.lh.variant).toBe('sbw')
expect(bowSpec.layers.rh.variant).toBeNull()
const xbwSpec = resolveSorceressAppearance(
{ weapon1: makeItem({ code: 'lxb', name: 'Light Crossbow' }) },
dropTables,
)
expect(xbwSpec.wclass).toBe('xbw')
expect(xbwSpec.layers.rh.variant).toBe('lxb')
expect(xbwSpec.layers.lh.variant).toBeNull()
})
it('composites Sorceress layers from offline pack with palette shift tables', async () => {
const packDir = resolve(process.cwd(), 'samples/d2-packs/chars/so')
const compositor = new SorceressLayerCompositor({
async loadJson<T>(relPath: string): Promise<T> {
const raw = await readFile(join(packDir, relPath), 'utf8')
return JSON.parse(raw) as T
},
async loadBin(relPath: string): Promise<Uint8Array> {
const buf = await readFile(join(packDir, relPath))
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
},
})
const redSpec = resolveSorceressAppearance(
{
helm: makeItem({ code: 'crn', name: 'Ruby Crown', quality: 'magic', chrtransform: 'cred' }),
armor: makeItem({ code: 'fld', name: 'Ruby Field Plate', quality: 'magic', chrtransform: 'cred' }),
weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique', chrtransform: 'whit' }),
weapon2: makeItem({ code: 'uit', name: 'Monarch', quality: 'magic', chrtransform: 'cred' }),
},
dropTables,
)
const blueSpec = resolveSorceressAppearance(
{
helm: makeItem({ code: 'crn', name: 'Cobalt Crown', quality: 'magic', chrtransform: 'cblu' }),
armor: makeItem({ code: 'fld', name: 'Cobalt Field Plate', quality: 'magic', chrtransform: 'cblu' }),
weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique', chrtransform: 'whit' }),
weapon2: makeItem({ code: 'uit', name: 'Monarch', quality: 'magic', chrtransform: 'cblu' }),
},
dropTables,
)
expect(appearanceKey(redSpec)).not.toBe(appearanceKey(blueSpec))
const redAtlas = await compositor.composite(redSpec)
const blueAtlas = await compositor.composite(blueSpec)
expect(redAtlas.width).toBe(blueAtlas.width)
expect(redAtlas.height).toBe(blueAtlas.height)
let diffPixels = 0
for (let i = 0; i < redAtlas.r8.length; i += 1) {
if (redAtlas.r8[i] !== blueAtlas.r8[i]) diffPixels += 1
}
expect(diffPixels).toBeGreaterThan(100000)
})
})