199 lines
7.3 KiB
TypeScript
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)
|
|
})
|
|
})
|