586 lines
21 KiB
TypeScript
586 lines
21 KiB
TypeScript
import { describe, it, expect, beforeAll } from 'vitest'
|
|
import * as fs from 'fs'
|
|
import {
|
|
parseRunesTable,
|
|
loadRunewords,
|
|
checkRunewordMatch,
|
|
getRunewordDisplayName,
|
|
normalizeRuneCode,
|
|
parseSocketedRunes,
|
|
isNormalQuality,
|
|
getBaseItemSocketCount,
|
|
getItemTypeCodes,
|
|
CANONICAL_RUNES_TABLE,
|
|
RUNES_TABLE_PATH,
|
|
RUNE_NAME_TO_CODE,
|
|
RUNE_CODE_TO_NAME,
|
|
type RunewordTable,
|
|
type RunewordDefinition,
|
|
type RunewordMatchResult,
|
|
} from '../src/game/runeword.ts'
|
|
import { parseTable, type D2Table } from '../src/game/acts.ts'
|
|
import { ItemQuality, type Item, type ItemBase } from '../src/game/items.ts'
|
|
import { MountedArchives } from '../src/mpq/mount.ts'
|
|
import { MpqArchive } from '../src/mpq/archive.ts'
|
|
import { fileSource } from '../src/mpq/file-source.ts'
|
|
import { decodeTbl } from '../src/formats/tbl.ts'
|
|
|
|
const hasD2 = fs.existsSync('samples/d2/d2exp.mpq')
|
|
|
|
describe('Runes.txt Loading and Runeword Assembly (Issue #105)', () => {
|
|
const shieldBase: ItemBase = {
|
|
id: 'kit',
|
|
name: 'Kite Shield',
|
|
kind: 'armor',
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
maxStack: 1,
|
|
value: 100,
|
|
damage: 0,
|
|
defense: 18,
|
|
tags: ['shld'],
|
|
level: 15,
|
|
}
|
|
|
|
const swordBase: ItemBase = {
|
|
id: 'crs',
|
|
name: 'Crystal Sword',
|
|
kind: 'weapon',
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
maxStack: 1,
|
|
value: 250,
|
|
damage: 15,
|
|
defense: 0,
|
|
tags: ['swor', 'weap'],
|
|
level: 11,
|
|
}
|
|
|
|
const armorBase: ItemBase = {
|
|
id: 'fld',
|
|
name: 'Field Plate',
|
|
kind: 'armor',
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
maxStack: 1,
|
|
value: 400,
|
|
damage: 0,
|
|
defense: 105,
|
|
tags: ['tors', 'armo'],
|
|
level: 15,
|
|
}
|
|
|
|
function createTestShield(totalSockets = 3, quality = ItemQuality.NORMAL, extra: Partial<Item> = {}): Item {
|
|
return {
|
|
base: shieldBase,
|
|
prefix: null,
|
|
suffix: null,
|
|
level: 25,
|
|
name: 'Kite Shield',
|
|
stats: {},
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
stack: 1,
|
|
value: 100,
|
|
totalSockets,
|
|
quality,
|
|
...extra,
|
|
}
|
|
}
|
|
|
|
describe('Table Parsing & Counts (1.13c Gold Standard)', () => {
|
|
it('parses exactly 169 rows, 78 complete runewords, and 23 ladder runewords from canonical data', () => {
|
|
const table = CANONICAL_RUNES_TABLE
|
|
expect(table.all.length).toBe(169)
|
|
expect(table.rows.length).toBe(169)
|
|
expect(table.length).toBe(169)
|
|
|
|
expect(table.complete.length).toBe(78)
|
|
expect(table.ladder.length).toBe(23)
|
|
|
|
// All ladder runewords should be in the complete list
|
|
for (const ladderItem of table.ladder) {
|
|
expect(ladderItem.server).toBe(true)
|
|
expect(ladderItem.ladder).toBe(true)
|
|
expect(ladderItem.complete).toBe(true)
|
|
expect(ladderItem.enabled).toBe(true)
|
|
}
|
|
|
|
// 55 non-ladder complete runewords (78 - 23)
|
|
const nonLadderComplete = table.complete.filter(r => !r.server)
|
|
expect(nonLadderComplete.length).toBe(55)
|
|
})
|
|
|
|
it('parses row 0 Ancient\'s Pledge properly', () => {
|
|
const ap = CANONICAL_RUNES_TABLE.get('Runeword1')
|
|
expect(ap).toBeDefined()
|
|
expect(ap?.name).toBe('Runeword1')
|
|
expect(ap?.runeName).toBe("Ancient's Pledge")
|
|
expect(ap?.complete).toBe(true)
|
|
expect(ap?.server).toBe(false)
|
|
expect(ap?.runewordId).toBe(1)
|
|
expect(ap?.itypes).toEqual(['shld'])
|
|
expect(ap?.etypes).toEqual([])
|
|
expect(ap?.runes).toEqual(['r08', 'r09', 'r07']) // Ral, Ort, Tal
|
|
|
|
expect(ap?.t1Codes.length).toBe(4)
|
|
expect(ap?.t1Codes[0]).toEqual({ code: 'res-cold', min: 30, max: 30 })
|
|
expect(ap?.t1Codes[1]).toEqual({ code: 'res-all', min: 13, max: 13 })
|
|
expect(ap?.t1Codes[2]).toEqual({ code: 'ac%', min: 50, max: 50 })
|
|
expect(ap?.t1Codes[3]).toEqual({ code: 'dmg-to-mana', min: 10, max: 10 })
|
|
|
|
expect(ap?.stats['res-cold']).toBe(30)
|
|
expect(ap?.stats['res-all']).toBe(13)
|
|
expect(ap?.stats['ac%']).toBe(50)
|
|
expect(ap?.stats['dmg-to-mana']).toBe(10)
|
|
})
|
|
|
|
it('parses ladder runeword Spirit (Runeword130, row 49)', () => {
|
|
const spirit = CANONICAL_RUNES_TABLE.get("Spirit")
|
|
expect(spirit).toBeDefined()
|
|
expect(spirit?.name).toBe('Runeword130')
|
|
expect(spirit?.server).toBe(true)
|
|
expect(spirit?.ladder).toBe(true)
|
|
expect(spirit?.complete).toBe(true)
|
|
expect(spirit?.itypes).toEqual(['swor', 'shld'])
|
|
expect(spirit?.runes).toEqual(['r07', 'r10', 'r09', 'r11']) // Tal, Thul, Ort, Amn
|
|
})
|
|
|
|
it('parses non-empty params in modifiers (e.g. charged skill on Black / Runeword6)', () => {
|
|
const black = CANONICAL_RUNES_TABLE.get('Black')
|
|
expect(black).toBeDefined()
|
|
expect(black?.name).toBe('Runeword6')
|
|
const chargedProp = black?.t1Codes.find(p => p.code === 'charged')
|
|
expect(chargedProp).toBeDefined()
|
|
expect(chargedProp?.param).toBe('74')
|
|
expect(chargedProp?.min).toBe(12)
|
|
expect(chargedProp?.max).toBe(4)
|
|
})
|
|
})
|
|
|
|
describe('Rune Normalization and Parsing Helpers', () => {
|
|
it('normalizes rune names and codes to standard r01..r33 codes', () => {
|
|
expect(normalizeRuneCode('Ral')).toBe('r08')
|
|
expect(normalizeRuneCode('ral')).toBe('r08')
|
|
expect(normalizeRuneCode('Ral Rune')).toBe('r08')
|
|
expect(normalizeRuneCode('r08')).toBe('r08')
|
|
expect(normalizeRuneCode('Ort')).toBe('r09')
|
|
expect(normalizeRuneCode('ort')).toBe('r09')
|
|
expect(normalizeRuneCode('Tal')).toBe('r07')
|
|
expect(normalizeRuneCode('tal')).toBe('r07')
|
|
expect(normalizeRuneCode('Zod')).toBe('r33')
|
|
expect(normalizeRuneCode('El')).toBe('r01')
|
|
expect(normalizeRuneCode('r1')).toBe('r01')
|
|
})
|
|
|
|
it('parses arrays and concatenated rune strings like RalOrtTal', () => {
|
|
expect(parseSocketedRunes(['Ral', 'Ort', 'Tal'])).toEqual(['Ral', 'Ort', 'Tal'])
|
|
expect(parseSocketedRunes(['r08', 'r09', 'r07'])).toEqual(['r08', 'r09', 'r07'])
|
|
expect(parseSocketedRunes('RalOrtTal')).toEqual(['Ral', 'Ort', 'Tal'])
|
|
expect(parseSocketedRunes(['RalOrtTal'])).toEqual(['Ral', 'Ort', 'Tal'])
|
|
expect(parseSocketedRunes('TalThulOrtAmn')).toEqual(['Tal', 'Thul', 'Ort', 'Amn'])
|
|
expect(parseSocketedRunes('JahIthBer')).toEqual(['Jah', 'Ith', 'Ber'])
|
|
})
|
|
|
|
it('verifies RUNE_NAME_TO_CODE and RUNE_CODE_TO_NAME tables', () => {
|
|
expect(Object.keys(RUNE_NAME_TO_CODE).length).toBe(33)
|
|
expect(Object.keys(RUNE_CODE_TO_NAME).length).toBe(33)
|
|
expect(RUNE_NAME_TO_CODE['ber']).toBe('r30')
|
|
expect(RUNE_CODE_TO_NAME['r30']).toBe('Ber')
|
|
})
|
|
})
|
|
|
|
describe('Quality and Socket Extraction Helpers', () => {
|
|
it('identifies normal, superior, and low quality items as valid', () => {
|
|
const normalItem = createTestShield(3, ItemQuality.NORMAL)
|
|
const superiorItem = createTestShield(3, ItemQuality.SUPERIOR)
|
|
const lowItem = createTestShield(3, ItemQuality.LOW)
|
|
expect(isNormalQuality(normalItem)).toBe(true)
|
|
expect(isNormalQuality(superiorItem)).toBe(true)
|
|
expect(isNormalQuality(lowItem)).toBe(true)
|
|
})
|
|
|
|
it('rejects magic, rare, set, unique, crafted, and tempered items', () => {
|
|
expect(isNormalQuality(createTestShield(3, ItemQuality.MAGIC))).toBe(false)
|
|
expect(isNormalQuality(createTestShield(3, ItemQuality.RARE))).toBe(false)
|
|
expect(isNormalQuality(createTestShield(3, ItemQuality.SET))).toBe(false)
|
|
expect(isNormalQuality(createTestShield(3, ItemQuality.UNIQUE))).toBe(false)
|
|
expect(isNormalQuality(createTestShield(3, ItemQuality.CRAFTED))).toBe(false)
|
|
expect(isNormalQuality(createTestShield(3, ItemQuality.TEMPERED))).toBe(false)
|
|
|
|
const affixItem = createTestShield(3, ItemQuality.NORMAL, {
|
|
prefix: { id: 'test', name: 'Sturdy', kind: 'prefix', level: 1, itemTypes: [], modifiers: [] },
|
|
})
|
|
expect(isNormalQuality(affixItem)).toBe(false)
|
|
|
|
const rarityItem = createTestShield(3, undefined, { rarity: 'magic' })
|
|
expect(isNormalQuality(rarityItem)).toBe(false)
|
|
})
|
|
|
|
it('extracts socket count from various item representations', () => {
|
|
expect(getBaseItemSocketCount({ ...shieldBase, totalSockets: 3 } as any)).toBe(3)
|
|
expect(getBaseItemSocketCount({ ...shieldBase, sockets: 4 } as any)).toBe(4)
|
|
expect(getBaseItemSocketCount({ ...shieldBase, stats: { sockets: 2 } } as any)).toBe(2)
|
|
expect(getBaseItemSocketCount({ base: { ...shieldBase, totalSockets: 5 } } as any)).toBe(5)
|
|
expect(getBaseItemSocketCount(createTestShield(3))).toBe(3)
|
|
})
|
|
|
|
it('extracts item type codes from item and base tags', () => {
|
|
const shield = createTestShield(3)
|
|
const codes = getItemTypeCodes(shield)
|
|
expect(codes).toContain('shld')
|
|
expect(codes).toContain('kit')
|
|
expect(codes).toContain('armo')
|
|
})
|
|
})
|
|
|
|
describe('Runeword Assembly: checkRunewordMatch', () => {
|
|
const isAIdentity = (type: string, target: string) => type.toLowerCase() === target.toLowerCase()
|
|
|
|
it('valid assembly: Ancient\'s Pledge (RalOrtTal in 3-socket shield)', () => {
|
|
const shield = createTestShield(3, ItemQuality.NORMAL)
|
|
const runes = ['Ral', 'Ort', 'Tal']
|
|
|
|
const match = checkRunewordMatch(shield, runes, isAIdentity, CANONICAL_RUNES_TABLE)
|
|
expect(match).toBeDefined()
|
|
expect(match?.name).toBe('Runeword1')
|
|
expect(match?.runeName).toBe("Ancient's Pledge")
|
|
expect(match?.runewordId).toBe(1)
|
|
expect(match?.complete).toBe(true)
|
|
expect(match?.server).toBe(false)
|
|
expect(match?.runes).toEqual(['r08', 'r09', 'r07'])
|
|
|
|
// Aggregated stats from T1Code1..4
|
|
expect(match?.stats['res-cold']).toBe(30)
|
|
expect(match?.stats['res-all']).toBe(13)
|
|
expect(match?.stats['ac%']).toBe(50)
|
|
expect(match?.stats['dmg-to-mana']).toBe(10)
|
|
})
|
|
|
|
it('valid assembly with item codes (r08, r09, r07)', () => {
|
|
const shield = createTestShield(3, ItemQuality.NORMAL)
|
|
const match = checkRunewordMatch(shield, ['r08', 'r09', 'r07'], isAIdentity, CANONICAL_RUNES_TABLE)
|
|
expect(match).toBeDefined()
|
|
expect(match?.name).toBe('Runeword1')
|
|
})
|
|
|
|
it('valid assembly with concatenated string (RalOrtTal)', () => {
|
|
const shield = createTestShield(3, ItemQuality.NORMAL)
|
|
const match = checkRunewordMatch(shield, ['RalOrtTal'], isAIdentity, CANONICAL_RUNES_TABLE)
|
|
expect(match).toBeDefined()
|
|
expect(match?.name).toBe('Runeword1')
|
|
})
|
|
|
|
it('socket count mismatch rejects (e.g. 2 runes in 3-socket base)', () => {
|
|
const shield3 = createTestShield(3, ItemQuality.NORMAL)
|
|
// Only 2 runes in 3-socket base
|
|
const matchUnder = checkRunewordMatch(shield3, ['Ral', 'Ort'], isAIdentity, CANONICAL_RUNES_TABLE)
|
|
expect(matchUnder).toBeUndefined()
|
|
|
|
// 4 runes in 3-socket base
|
|
const matchOver = checkRunewordMatch(shield3, ['Ral', 'Ort', 'Tal', 'Thul'], isAIdentity, CANONICAL_RUNES_TABLE)
|
|
expect(matchOver).toBeUndefined()
|
|
|
|
// 3 runes in 4-socket base
|
|
const shield4 = createTestShield(4, ItemQuality.NORMAL)
|
|
const match4 = checkRunewordMatch(shield4, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)
|
|
expect(match4).toBeUndefined()
|
|
})
|
|
|
|
it('rune sequence mismatch rejects', () => {
|
|
const shield = createTestShield(3, ItemQuality.NORMAL)
|
|
// Wrong sequence (Tal, Ort, Ral instead of Ral, Ort, Tal)
|
|
const match1 = checkRunewordMatch(shield, ['Tal', 'Ort', 'Ral'], isAIdentity, CANONICAL_RUNES_TABLE)
|
|
expect(match1).toBeUndefined()
|
|
|
|
// Wrong sequence (Ral, Tal, Ort)
|
|
const match2 = checkRunewordMatch(shield, ['Ral', 'Tal', 'Ort'], isAIdentity, CANONICAL_RUNES_TABLE)
|
|
expect(match2).toBeUndefined()
|
|
|
|
// Completely wrong runes
|
|
const match3 = checkRunewordMatch(shield, ['El', 'Eld', 'Tir'], isAIdentity, CANONICAL_RUNES_TABLE)
|
|
expect(match3).toBeUndefined()
|
|
})
|
|
|
|
it('quality mismatch rejects (e.g. magic base with sockets)', () => {
|
|
const magicShield = createTestShield(3, ItemQuality.MAGIC)
|
|
expect(checkRunewordMatch(magicShield, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined()
|
|
|
|
const rareShield = createTestShield(3, ItemQuality.RARE)
|
|
expect(checkRunewordMatch(rareShield, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined()
|
|
|
|
const setShield = createTestShield(3, ItemQuality.SET)
|
|
expect(checkRunewordMatch(setShield, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined()
|
|
|
|
const uniqueShield = createTestShield(3, ItemQuality.UNIQUE)
|
|
expect(checkRunewordMatch(uniqueShield, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined()
|
|
|
|
const craftedShield = createTestShield(3, ItemQuality.CRAFTED)
|
|
expect(checkRunewordMatch(craftedShield, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined()
|
|
})
|
|
|
|
it('item type mismatch rejects (e.g. RalOrtTal in 3-socket sword or armor)', () => {
|
|
const swordItem: Item = {
|
|
base: swordBase,
|
|
prefix: null,
|
|
suffix: null,
|
|
level: 25,
|
|
name: 'Crystal Sword',
|
|
stats: {},
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
stack: 1,
|
|
value: 250,
|
|
totalSockets: 3,
|
|
quality: ItemQuality.NORMAL,
|
|
}
|
|
expect(checkRunewordMatch(swordItem, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined()
|
|
|
|
const armorItem: Item = {
|
|
base: armorBase,
|
|
prefix: null,
|
|
suffix: null,
|
|
level: 25,
|
|
name: 'Field Plate',
|
|
stats: {},
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
stack: 1,
|
|
value: 400,
|
|
totalSockets: 3,
|
|
quality: ItemQuality.NORMAL,
|
|
}
|
|
expect(checkRunewordMatch(armorItem, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined()
|
|
})
|
|
|
|
it('item type inheritance via isA allows Paladin shield (pash -> shld)', () => {
|
|
const palaShieldBase: ItemBase = {
|
|
id: 'pa1',
|
|
name: 'Targe',
|
|
kind: 'armor',
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
maxStack: 1,
|
|
value: 150,
|
|
damage: 0,
|
|
defense: 14,
|
|
tags: ['pash'], // Paladin shield sub-type
|
|
level: 3,
|
|
}
|
|
const palaShield: Item = {
|
|
base: palaShieldBase,
|
|
prefix: null,
|
|
suffix: null,
|
|
level: 20,
|
|
name: 'Targe',
|
|
stats: {},
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
stack: 1,
|
|
value: 150,
|
|
totalSockets: 3,
|
|
quality: ItemQuality.NORMAL,
|
|
}
|
|
|
|
// Without inheritance: fails
|
|
expect(checkRunewordMatch(palaShield, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined()
|
|
|
|
// With isA inheritance: pash is a shld
|
|
const isAWithPala = (type: string, target: string) => {
|
|
if (type === 'pash' && target === 'shld') return true
|
|
return type === target
|
|
}
|
|
const match = checkRunewordMatch(palaShield, ['Ral', 'Ort', 'Tal'], isAWithPala, CANONICAL_RUNES_TABLE)
|
|
expect(match).toBeDefined()
|
|
expect(match?.name).toBe('Runeword1')
|
|
})
|
|
|
|
it('handles excluded item types (etypes)', () => {
|
|
// Create a synthetic table with an excluded type
|
|
const rawTable: D2Table = {
|
|
header: [
|
|
'Name', 'Rune Name', 'complete', 'server',
|
|
'itype1', 'itype2', 'itype3', 'itype4', 'itype5', 'itype6',
|
|
'etype1', 'etype2', 'etype3',
|
|
'*runes',
|
|
'Rune1', 'Rune2', 'Rune3', 'Rune4', 'Rune5', 'Rune6',
|
|
'T1Code1', 'T1Param1', 'T1Min1', 'T1Max1',
|
|
'eol',
|
|
],
|
|
rows: [
|
|
[
|
|
'Runeword999', 'Test Word', '1', '',
|
|
'weap', '', '', '', '', '',
|
|
'swor', '', '',
|
|
'',
|
|
'r01', 'r02', '', '', '', '',
|
|
'dmg%', '', '50', '50',
|
|
'0',
|
|
],
|
|
],
|
|
}
|
|
const testTable = parseRunesTable(rawTable)
|
|
|
|
const swordItem: Item = {
|
|
base: swordBase,
|
|
prefix: null,
|
|
suffix: null,
|
|
level: 10,
|
|
name: 'Crystal Sword',
|
|
stats: {},
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
stack: 1,
|
|
value: 200,
|
|
totalSockets: 2,
|
|
quality: ItemQuality.NORMAL,
|
|
}
|
|
|
|
// Should be excluded because sword has tag 'swor', matching etype1 'swor'
|
|
expect(checkRunewordMatch(swordItem, ['El', 'Eld'], isAIdentity, testTable)).toBeUndefined()
|
|
|
|
// An axe (tags: ['axe', 'weap']) is not excluded
|
|
const axeBase: ItemBase = {
|
|
id: 'hax',
|
|
name: 'Hand Axe',
|
|
kind: 'weapon',
|
|
invWidth: 1,
|
|
invHeight: 3,
|
|
maxStack: 1,
|
|
value: 100,
|
|
damage: 5,
|
|
defense: 0,
|
|
tags: ['axe', 'weap'],
|
|
level: 3,
|
|
}
|
|
const axeItem: Item = {
|
|
base: axeBase,
|
|
prefix: null,
|
|
suffix: null,
|
|
level: 10,
|
|
name: 'Hand Axe',
|
|
stats: {},
|
|
invWidth: 1,
|
|
invHeight: 3,
|
|
stack: 1,
|
|
value: 100,
|
|
totalSockets: 2,
|
|
quality: ItemQuality.NORMAL,
|
|
}
|
|
const matchAxe = checkRunewordMatch(axeItem, ['El', 'Eld'], isAIdentity, testTable)
|
|
expect(matchAxe).toBeDefined()
|
|
expect(matchAxe?.name).toBe('Runeword999')
|
|
})
|
|
|
|
it('enforces ladder-only restriction (server === 1)', () => {
|
|
// Spirit (Runeword130, server === 1, TalThulOrtAmn in 4-socket sword)
|
|
const sword4: Item = {
|
|
base: swordBase,
|
|
prefix: null,
|
|
suffix: null,
|
|
level: 30,
|
|
name: 'Crystal Sword',
|
|
stats: {},
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
stack: 1,
|
|
value: 500,
|
|
totalSockets: 4,
|
|
quality: ItemQuality.NORMAL,
|
|
}
|
|
const spiritRunes = ['Tal', 'Thul', 'Ort', 'Amn']
|
|
|
|
// In non-ladder or without options: rejected
|
|
expect(checkRunewordMatch(sword4, spiritRunes, isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined()
|
|
expect(checkRunewordMatch(sword4, spiritRunes, isAIdentity, CANONICAL_RUNES_TABLE, { isLadder: false })).toBeUndefined()
|
|
|
|
// In ladder mode: accepted
|
|
const match = checkRunewordMatch(sword4, spiritRunes, isAIdentity, CANONICAL_RUNES_TABLE, { isLadder: true })
|
|
expect(match).toBeDefined()
|
|
expect(match?.name).toBe('Runeword130')
|
|
expect(match?.runeName).toBe('Spirit')
|
|
expect(match?.server).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Display Name Resolution: Name Key to string.tbl Key', () => {
|
|
it('display name uses Name key (Runeword26 -> string.tbl key)', () => {
|
|
const doom = CANONICAL_RUNES_TABLE.get('Runeword26')
|
|
expect(doom).toBeDefined()
|
|
expect(doom?.name).toBe('Runeword26')
|
|
expect(doom?.runewordId).toBe(26)
|
|
|
|
// Mock string table representing expansionstring.tbl
|
|
const mockStringTable = new Map<string, string>([
|
|
['Runeword1', "Ancients' Pledge"],
|
|
['Runeword26', 'Doom'],
|
|
['Runeword33', 'Enigma'],
|
|
['Runeword130', 'Spirit'],
|
|
])
|
|
|
|
expect(getRunewordDisplayName(doom!, mockStringTable)).toBe('Doom')
|
|
expect(getRunewordDisplayName('Runeword26', mockStringTable)).toBe('Doom')
|
|
|
|
const ap = CANONICAL_RUNES_TABLE.get('Runeword1')!
|
|
expect(getRunewordDisplayName(ap, mockStringTable)).toBe("Ancients' Pledge")
|
|
|
|
// Fallback when string table is not provided
|
|
expect(getRunewordDisplayName(doom!)).toBe('Doomsayer') // fallback to runeName
|
|
expect(getRunewordDisplayName('UnknownKey')).toBe('UnknownKey')
|
|
})
|
|
})
|
|
|
|
describe.skipIf(!hasD2)('1.13c MPQ Integration Tests', () => {
|
|
let archives: MountedArchives
|
|
let mpqTable: RunewordTable
|
|
|
|
beforeAll(async () => {
|
|
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}`)))
|
|
}
|
|
mpqTable = await loadRunewords(archives)
|
|
})
|
|
|
|
it('verifies RUNES_TABLE_PATH constant', () => {
|
|
expect(RUNES_TABLE_PATH).toBe('data\\global\\excel\\Runes.txt')
|
|
})
|
|
|
|
it('loads exactly 169 rows, 78 complete, and 23 ladder runewords from MPQ', () => {
|
|
expect(mpqTable.all.length).toBe(169)
|
|
expect(mpqTable.complete.length).toBe(78)
|
|
expect(mpqTable.ladder.length).toBe(23)
|
|
})
|
|
|
|
it('verifies exact parity between MPQ parsed table and CANONICAL_RUNES_TABLE', () => {
|
|
for (let i = 0; i < mpqTable.all.length; i++) {
|
|
const a = mpqTable.all[i]!
|
|
const b = CANONICAL_RUNES_TABLE.all[i]!
|
|
expect(a.name).toBe(b.name)
|
|
expect(a.runeName).toBe(b.runeName)
|
|
expect(a.complete).toBe(b.complete)
|
|
expect(a.server).toBe(b.server)
|
|
expect(a.runes).toEqual(b.runes)
|
|
expect(a.itypes).toEqual(b.itypes)
|
|
expect(a.etypes).toEqual(b.etypes)
|
|
expect(a.t1Codes).toEqual(b.t1Codes)
|
|
expect(a.stats).toEqual(b.stats)
|
|
}
|
|
})
|
|
|
|
it('resolves real expansionstring.tbl display names for Runeword26 and Runeword1', async () => {
|
|
const tblBytes = await archives.read('data\\local\\lng\\eng\\expansionstring.tbl')
|
|
const tbl = decodeTbl(tblBytes)
|
|
|
|
const doom = mpqTable.get('Runeword26')!
|
|
expect(getRunewordDisplayName(doom, tbl)).toBe('Doom')
|
|
|
|
const ap = mpqTable.get('Runeword1')!
|
|
expect(getRunewordDisplayName(ap, tbl)).toBe("Ancients' Pledge")
|
|
|
|
const enigma = mpqTable.get('Runeword33')!
|
|
expect(getRunewordDisplayName(enigma, tbl)).toBe('Enigma')
|
|
})
|
|
})
|
|
})
|