829 lines
30 KiB
TypeScript
829 lines
30 KiB
TypeScript
import { describe, it, expect, beforeAll } from 'vitest'
|
|
import { ACT_BOSSES, BOSS_LIST, type BossDefinition } from '../src/boss.ts'
|
|
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
|
import {
|
|
executeDropPipeline,
|
|
resolveItemTypeFlags,
|
|
createDroppedItem,
|
|
rollAutoMagic,
|
|
rollStaffMods,
|
|
resolveAutoPrefixGroup,
|
|
resolveStaffModsClass,
|
|
type DropTables,
|
|
} from '../src/game/drop-pipeline.ts'
|
|
import {
|
|
NON_RANDOM_RANGE_CODES,
|
|
rollMagicAffixes,
|
|
rollRareAffixes,
|
|
} from '../src/game/affix-generator.ts'
|
|
import { formatItemTooltip, aggregateAndSortProperties } from '../src/game/item-tooltip.ts'
|
|
import { D2Rng } from '../src/game/d2-rng.ts'
|
|
import type { MagicAffix, AffixTable } from '../src/game/affixes.ts'
|
|
import type { RareNameTable } from '../src/game/rare-names.ts'
|
|
import type { UpgradableItemBase } from '../src/game/item-upgrade.ts'
|
|
import type { Item, ItemBase } from '../src/game/items.ts'
|
|
import { runParityAudit } from '../scripts/audit-parity.ts'
|
|
|
|
export type BaseTier = 'normal' | 'exceptional' | 'elite'
|
|
|
|
export function classifyItemBaseTier(item: Item): BaseTier | null {
|
|
if (item.base.kind !== 'weapon' && item.base.kind !== 'armor') {
|
|
return null
|
|
}
|
|
const b = item.base as UpgradableItemBase
|
|
const id = (b.id ?? '').trim().toLowerCase()
|
|
const uber = (b.ubercode ?? '').trim().toLowerCase()
|
|
const ultra = (b.ultracode ?? '').trim().toLowerCase()
|
|
|
|
if (ultra && ultra !== 'xxx' && id === ultra) {
|
|
return 'elite'
|
|
}
|
|
if (uber && uber !== 'xxx' && id === uber) {
|
|
return 'exceptional'
|
|
}
|
|
return 'normal'
|
|
}
|
|
|
|
describe('Diablo II 1.13c Parity Baseline & Boss monsterType=4 Upgrade Odds (Issue #126)', () => {
|
|
let dropTables: DropTables
|
|
|
|
beforeAll(() => {
|
|
dropTables = getEmbeddedDropTables()
|
|
})
|
|
|
|
it('1. BOSS_LIST and ACT_BOSSES define monsterType === 4 for all 5 Act Bosses', () => {
|
|
expect(BOSS_LIST).toBeDefined()
|
|
expect(BOSS_LIST).toHaveLength(5)
|
|
expect(BOSS_LIST).toBe(ACT_BOSSES)
|
|
|
|
for (const boss of BOSS_LIST) {
|
|
const def: BossDefinition = boss
|
|
expect(def.monsterType, `Boss ${def.id} must explicitly set monsterType: 4`).toBe(4)
|
|
}
|
|
})
|
|
|
|
it('2. Difficultylevels.txt Good odds (UberCodeOddsGood / UltraCodeOddsGood) exceed Normal odds in Nightmare and Hell', () => {
|
|
const nmOdds = dropTables.difficultyLevels.get('Nightmare')!
|
|
const hellOdds = dropTables.difficultyLevels.get('Hell')!
|
|
|
|
expect(nmOdds.uberCodeOddsGood).toBeGreaterThan(nmOdds.uberCodeOddsNormal)
|
|
expect(hellOdds.uberCodeOddsGood).toBeGreaterThan(hellOdds.uberCodeOddsNormal)
|
|
expect(hellOdds.ultraCodeOddsGood).toBeGreaterThan(hellOdds.ultraCodeOddsNormal)
|
|
})
|
|
|
|
it('3. monsterType=4 (Boss) yields significantly higher Exceptional/Elite base upgrades than monsterType=1 (Normal) over identical seeds', () => {
|
|
const sampleKills = 400
|
|
let normalMonsterUpgraded = 0
|
|
let normalMonsterEquipCount = 0
|
|
let bossMonsterUpgraded = 0
|
|
let bossMonsterEquipCount = 0
|
|
|
|
for (let i = 0; i < sampleKills; i++) {
|
|
const seed = (0x12600000 + i * 7919) >>> 0
|
|
|
|
// Run with monsterType = 1 (Normal monster: uses uberCodeOddsNormal=20, ultraCodeOddsNormal=30)
|
|
const dropsNormalType = executeDropPipeline({
|
|
tcName: 'Mephistoq (H)',
|
|
nLevel: 87,
|
|
monsterType: 1,
|
|
dropTables,
|
|
difficulty: 'hell',
|
|
playerMf: 250,
|
|
gamePlayers: 1,
|
|
partyPlayers: 1,
|
|
monsterRng: seed,
|
|
})
|
|
|
|
for (const item of dropsNormalType) {
|
|
const tier = classifyItemBaseTier(item)
|
|
if (tier !== null) {
|
|
normalMonsterEquipCount++
|
|
if (tier === 'exceptional' || tier === 'elite') {
|
|
normalMonsterUpgraded++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run with monsterType = 4 (Act Boss: uses uberCodeOddsGood=40, ultraCodeOddsGood=40)
|
|
const dropsBossType = executeDropPipeline({
|
|
tcName: 'Mephistoq (H)',
|
|
nLevel: 87,
|
|
monsterType: 4,
|
|
dropTables,
|
|
difficulty: 'hell',
|
|
playerMf: 250,
|
|
gamePlayers: 1,
|
|
partyPlayers: 1,
|
|
monsterRng: seed,
|
|
})
|
|
|
|
for (const item of dropsBossType) {
|
|
const tier = classifyItemBaseTier(item)
|
|
if (tier !== null) {
|
|
bossMonsterEquipCount++
|
|
if (tier === 'exceptional' || tier === 'elite') {
|
|
bossMonsterUpgraded++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(normalMonsterEquipCount).toBeGreaterThan(500)
|
|
expect(bossMonsterEquipCount).toBe(normalMonsterEquipCount)
|
|
|
|
const normalUpgradeRate = normalMonsterUpgraded / normalMonsterEquipCount
|
|
const bossUpgradeRate = bossMonsterUpgraded / bossMonsterEquipCount
|
|
|
|
// Boss (Good odds: 40/1024 + 40/1024) must strictly exceed Normal (20/1024 + 30/1024)
|
|
expect(bossMonsterUpgraded).toBeGreaterThan(normalMonsterUpgraded)
|
|
expect(bossUpgradeRate).toBeGreaterThan(normalUpgradeRate)
|
|
})
|
|
|
|
it('4. executeDropPipeline works properly with monsterType: 4 across all 5 Act Bosses in Hell', () => {
|
|
for (const boss of BOSS_LIST) {
|
|
const drops = executeDropPipeline({
|
|
tcName: boss.questTc.hell,
|
|
nLevel: boss.mlvl.hell,
|
|
monsterType: boss.monsterType ?? 4,
|
|
dropTables,
|
|
difficulty: 'hell',
|
|
playerMf: 250,
|
|
gamePlayers: 1,
|
|
partyPlayers: 1,
|
|
monsterRng: 1337 + boss.act,
|
|
})
|
|
|
|
expect(drops.length).toBeGreaterThan(0)
|
|
for (const item of drops) {
|
|
expect(item.ilvl).toBe(boss.mlvl.hell)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('5. runParityAudit generates structured JSON summary of quality, base upgrade, and affix distributions', () => {
|
|
const summary = runParityAudit({
|
|
kills: 100,
|
|
bossId: 'mephisto',
|
|
difficulty: 'hell',
|
|
playerMf: 250,
|
|
gamePlayers: 1,
|
|
})
|
|
|
|
expect(summary.config.boss).toBe('mephisto')
|
|
expect(summary.config.monsterType).toBe(4)
|
|
expect(summary.totalItems).toBeGreaterThan(0)
|
|
expect(summary.baseTierUpgrade.totalEquipment).toBeGreaterThan(0)
|
|
expect(summary.baseTierUpgrade.upgradedRatio).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('ItemTypes.txt Quality Flags & classSpecific Propagation (Issue #127 / A2)', () => {
|
|
let dropTables: DropTables
|
|
|
|
beforeAll(() => {
|
|
dropTables = getEmbeddedDropTables()
|
|
})
|
|
|
|
it('1. resolveItemTypeFlags returns isAlwaysMagic: true for rin, amu, jew, cm1, cm2, cm3', () => {
|
|
const alwaysMagicCodes = ['rin', 'amu', 'jew', 'cm1', 'cm2', 'cm3']
|
|
for (const code of alwaysMagicCodes) {
|
|
const base = dropTables.getBase(code)
|
|
expect(base, `Base ${code} must exist`).toBeDefined()
|
|
const flags = resolveItemTypeFlags(base!, dropTables.itemTypes, dropTables.isA)
|
|
expect(flags.isAlwaysMagic, `${code} must have isAlwaysMagic === true`).toBe(true)
|
|
expect(flags.isNormalOnly, `${code} must have isNormalOnly === false`).toBe(false)
|
|
if (code === 'cm1' || code === 'cm2' || code === 'cm3') {
|
|
expect(flags.canBeRare, `${code} charm must have canBeRare === false`).toBe(false)
|
|
} else {
|
|
expect(flags.canBeRare, `${code} must have canBeRare === true`).toBe(true)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('2. 200 rolls of Ring / Amulet / Small Charm at playerMf: -100 and 0, nLevel: 1 NEVER produce normal/superior/low (and cm1 NEVER produces rare)', () => {
|
|
const validQualities = new Set(['magic', 'rare', 'set', 'unique'])
|
|
const forbiddenQualities = new Set(['normal', 'superior', 'low'])
|
|
|
|
for (const mf of [-100, 0]) {
|
|
for (const code of ['rin', 'amu', 'cm1']) {
|
|
let countNormalOrSuperiorOrLow = 0
|
|
let countRare = 0
|
|
let totalGenerated = 0
|
|
|
|
for (let i = 0; i < 200; i++) {
|
|
const drops = executeDropPipeline({
|
|
tcName: code,
|
|
nLevel: 1,
|
|
monsterType: 1,
|
|
dropTables,
|
|
difficulty: 'normal',
|
|
playerMf: mf,
|
|
gamePlayers: 1,
|
|
partyPlayers: 1,
|
|
monsterRng: (0x12700000 + i * 1009 + (mf === 0 ? 500000 : 0)) >>> 0,
|
|
})
|
|
|
|
expect(drops.length).toBe(1)
|
|
const item = drops[0]!
|
|
totalGenerated++
|
|
|
|
const q = item.rarity ?? ''
|
|
if (forbiddenQualities.has(q)) {
|
|
countNormalOrSuperiorOrLow++
|
|
}
|
|
if (q === 'rare') {
|
|
countRare++
|
|
}
|
|
expect(validQualities.has(q), `Item ${item.name} (${code}) rolled invalid quality ${q} at MF=${mf}`).toBe(true)
|
|
expect(item.name).not.toMatch(/^Cracked /)
|
|
expect(item.name).not.toMatch(/^Superior /)
|
|
}
|
|
|
|
expect(totalGenerated).toBe(200)
|
|
expect(countNormalOrSuperiorOrLow).toBe(0)
|
|
if (code === 'cm1') {
|
|
expect(countRare).toBe(0)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('3. resolveItemTypeFlags identifies class-specific equipment bases (Sorceress Orb, Paladin Shield, Amazon Bow, etc.) with isClassSpecific === true', () => {
|
|
// Sorceress Orb: ob1 (Eagle Orb), obc (Eldritch Orb), obf (Dimensional Shard)
|
|
// Paladin Auric Shield: pa1 (Targe), paa (Akaran Targe), paf (Vortex Shield)
|
|
// Amazon Bow: am1 (Stag Bow), am6 (Ashwood Bow), amb (Matriarchal Bow)
|
|
// Barbarian Primal Helm: ba1 (Jawbone Cap)
|
|
// Druid Pelt: dr1 (Wolf Head)
|
|
// Necromancer Voodoo Head: ne1 (Preserved Head)
|
|
// Assassin Katar: ktr (Katar)
|
|
const classSpecificCodes = ['ob1', 'obf', 'pa1', 'paf', 'am1', 'amb', 'ba1', 'dr1', 'ne1', 'ktr']
|
|
for (const code of classSpecificCodes) {
|
|
const base = dropTables.getBase(code)
|
|
expect(base, `Class-specific base ${code} must exist`).toBeDefined()
|
|
const flags = resolveItemTypeFlags(base!, dropTables.itemTypes, dropTables.isA)
|
|
expect(flags.isClassSpecific, `${code} (${base!.name}) must have isClassSpecific === true`).toBe(true)
|
|
}
|
|
|
|
// Generic equipment bases must have isClassSpecific === false
|
|
const genericCodes = ['cap', 'uap', 'uit', 'lsd', 'crn', 'rin', 'amu', 'cm1']
|
|
for (const code of genericCodes) {
|
|
const base = dropTables.getBase(code)
|
|
expect(base, `Generic base ${code} must exist`).toBeDefined()
|
|
const flags = resolveItemTypeFlags(base!, dropTables.itemTypes, dropTables.isA)
|
|
expect(flags.isClassSpecific, `${code} (${base!.name}) must have isClassSpecific === false`).toBe(false)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Magic & Rare Affix NON_RANDOM_RANGE_CODES Range & Skill Parameter Protection (Issue #128)', () => {
|
|
const swordBase: ItemBase = {
|
|
id: 'crs',
|
|
name: 'Crystal Sword',
|
|
kind: 'weapon',
|
|
tags: ['swor', 'weap'],
|
|
damage: 15,
|
|
defense: 0,
|
|
value: 1200,
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
maxStack: 1,
|
|
level: 11,
|
|
}
|
|
|
|
const mockIsA = (type: string, target: string): boolean => {
|
|
if (type === target) return true
|
|
if (type === 'swor' && (target === 'weap' || target === 'mele')) return true
|
|
return false
|
|
}
|
|
|
|
const mockRareNames: { prefixes: RareNameTable; suffixes: RareNameTable } = {
|
|
prefixes: {
|
|
entries: [],
|
|
getEligible: () => [{ index: 0, name: 'Fiend', version: 100, itypes: ['weap'], etypes: [], add: 0, multiply: 0, divide: 0 }],
|
|
} as unknown as RareNameTable,
|
|
suffixes: {
|
|
entries: [],
|
|
getEligible: () => [{ index: 0, name: 'Fang', version: 100, itypes: ['weap'], etypes: [], add: 0, multiply: 0, divide: 0 }],
|
|
} as unknown as RareNameTable,
|
|
}
|
|
|
|
function makeAffix(
|
|
name: string,
|
|
isPrefix: boolean,
|
|
group: number,
|
|
mods: { code: string; param?: string; min: number; max: number }[],
|
|
): MagicAffix {
|
|
return {
|
|
name,
|
|
isPrefix,
|
|
version: 100,
|
|
spawnable: true,
|
|
rare: true,
|
|
level: 1,
|
|
maxlevel: 0,
|
|
levelreq: 12,
|
|
frequency: 10,
|
|
group,
|
|
mods,
|
|
transform: false,
|
|
itypes: ['swor'],
|
|
etypes: [],
|
|
}
|
|
}
|
|
|
|
it('1. exports NON_RANDOM_RANGE_CODES covering all elemental damage ranges, event procs, and charged skills', () => {
|
|
const expectedCodes = [
|
|
'dmg-fire',
|
|
'dmg-cold',
|
|
'dmg-ltng',
|
|
'dmg-mag',
|
|
'dmg-pois',
|
|
'dmg-norm',
|
|
'dmg-elem',
|
|
'hit-skill',
|
|
'att-skill',
|
|
'gethit-skill',
|
|
'kill-skill',
|
|
'death-skill',
|
|
'levelup-skill',
|
|
'charged',
|
|
'skill-rand',
|
|
]
|
|
for (const c of expectedCodes) {
|
|
expect(NON_RANDOM_RANGE_CODES.has(c), `NON_RANDOM_RANGE_CODES must include ${c}`).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('2. preserves dmg-fire (min=1, max=6) across rollMagicAffixes / rollRareAffixes -> createDroppedItem -> formatItemTooltip as "Adds 1-6 Fire Damage"', () => {
|
|
const fieryPrefix = makeAffix('Fiery', true, 101, [{ code: 'dmg-fire', min: 1, max: 6 }])
|
|
const auxPrefix1 = makeAffix('Bronze', true, 102, [{ code: 'att', min: 15, max: 15 }])
|
|
const auxPrefix2 = makeAffix('Sharp', true, 103, [{ code: 'dmg%', min: 20, max: 20 }])
|
|
const auxSuffix1 = makeAffix('of Craftsmanship', false, 201, [{ code: 'dmg-max', min: 1, max: 1 }])
|
|
const auxSuffix2 = makeAffix('of Worth', false, 202, [{ code: 'dmg-min', min: 2, max: 2 }])
|
|
const auxSuffix3 = makeAffix('of Strength', false, 203, [{ code: 'str', min: 5, max: 5 }])
|
|
|
|
// Magic roll with only Fiery prefix
|
|
const magicAffixes = rollMagicAffixes(
|
|
swordBase,
|
|
30,
|
|
{
|
|
prefixes: { all: [fieryPrefix] } as unknown as AffixTable,
|
|
suffixes: { all: [] } as unknown as AffixTable,
|
|
},
|
|
mockIsA,
|
|
new D2Rng(12345),
|
|
)
|
|
|
|
// Find a seed that selects prefix (slotChoice 0 or 2)
|
|
let magicResult = magicAffixes
|
|
for (let s = 1; s <= 20; s++) {
|
|
const candidate = rollMagicAffixes(
|
|
swordBase,
|
|
30,
|
|
{
|
|
prefixes: { all: [fieryPrefix] } as unknown as AffixTable,
|
|
suffixes: { all: [] } as unknown as AffixTable,
|
|
},
|
|
mockIsA,
|
|
new D2Rng(s),
|
|
)
|
|
if (candidate.prefix) {
|
|
magicResult = candidate
|
|
break
|
|
}
|
|
}
|
|
|
|
expect(magicResult.prefix).toBeDefined()
|
|
expect(magicResult.prefix!.mods[0]).toEqual({
|
|
code: 'dmg-fire',
|
|
min: 1,
|
|
max: 6,
|
|
value: 1,
|
|
})
|
|
|
|
const magicItem = createDroppedItem(swordBase, 'magic', {
|
|
magicAffixes: magicResult,
|
|
ilvl: 30,
|
|
dwInitSeed: 777,
|
|
})
|
|
expect(magicItem.rolledProps).toContainEqual({
|
|
code: 'dmg-fire',
|
|
param: undefined,
|
|
min: 1,
|
|
max: 6,
|
|
value: 1,
|
|
})
|
|
|
|
const magicTooltip = formatItemTooltip(magicItem)
|
|
const magicLines = magicTooltip.lines.map(l => l.text)
|
|
expect(magicLines).toContain('Adds 1-6 Fire Damage')
|
|
|
|
// Rare roll where Fiery is guaranteed to be chosen (3 prefixes, 3 suffixes)
|
|
const rareResult = rollRareAffixes(
|
|
swordBase,
|
|
30,
|
|
{
|
|
prefixes: { all: [fieryPrefix] } as unknown as AffixTable,
|
|
suffixes: { all: [auxSuffix1, auxSuffix2] } as unknown as AffixTable,
|
|
},
|
|
mockRareNames,
|
|
mockIsA,
|
|
new D2Rng(98765),
|
|
)
|
|
|
|
const fieryRolled = rareResult.affixes.find(a => a.name === 'Fiery')
|
|
expect(fieryRolled).toBeDefined()
|
|
expect(fieryRolled!.mods[0]).toEqual({
|
|
code: 'dmg-fire',
|
|
min: 1,
|
|
max: 6,
|
|
value: 1,
|
|
})
|
|
|
|
const rareItem = createDroppedItem(swordBase, 'rare', {
|
|
rareAffixes: rareResult,
|
|
ilvl: 30,
|
|
dwInitSeed: 888,
|
|
})
|
|
expect(rareItem.rolledProps).toContainEqual({
|
|
code: 'dmg-fire',
|
|
param: undefined,
|
|
min: 1,
|
|
max: 6,
|
|
value: 1,
|
|
})
|
|
|
|
const rareTooltip = formatItemTooltip(rareItem)
|
|
const rareLines = rareTooltip.lines.map(l => l.text)
|
|
expect(rareLines).toContain('Adds 1-6 Fire Damage')
|
|
|
|
// Also verify when only rolledProps is present (without rolledMagicAffixes / rolledRareAffixes)
|
|
const rarePropsOnlyItem = { ...rareItem, rolledRareAffixes: undefined } as Item
|
|
expect(formatItemTooltip(rarePropsOnlyItem).lines.map(l => l.text)).toContain('Adds 1-6 Fire Damage')
|
|
})
|
|
|
|
it('3. preserves hit-skill (param="Amplify Damage"/66, min=5, max=1) and charged (param="Teleport"/54, min=20, max=2) across Magic and Rare generation and tooltip rendering', () => {
|
|
const ampSuffix = makeAffix('of Amplify Damage', false, 301, [
|
|
{ code: 'hit-skill', param: '66', min: 5, max: 1 },
|
|
])
|
|
const teleSuffix = makeAffix('of Teleportation', false, 302, [
|
|
{ code: 'charged', param: '54', min: 20, max: 2 },
|
|
])
|
|
const bronzePrefix = makeAffix('Bronze', true, 101, [{ code: 'att', min: 10, max: 10 }])
|
|
|
|
// Magic item with prefix=Bronze (or hit-skill) and suffix=charged
|
|
const ampPrefix = makeAffix('Cursing', true, 102, [
|
|
{ code: 'hit-skill', param: 'Amplify Damage', min: 5, max: 1 },
|
|
])
|
|
|
|
let magicResult = rollMagicAffixes(
|
|
swordBase,
|
|
40,
|
|
{
|
|
prefixes: { all: [ampPrefix] } as unknown as AffixTable,
|
|
suffixes: { all: [teleSuffix] } as unknown as AffixTable,
|
|
},
|
|
mockIsA,
|
|
new D2Rng(1),
|
|
)
|
|
for (let s = 1; s <= 30; s++) {
|
|
const candidate = rollMagicAffixes(
|
|
swordBase,
|
|
40,
|
|
{
|
|
prefixes: { all: [ampPrefix] } as unknown as AffixTable,
|
|
suffixes: { all: [teleSuffix] } as unknown as AffixTable,
|
|
},
|
|
mockIsA,
|
|
new D2Rng(s),
|
|
)
|
|
if (candidate.prefix && candidate.suffix) {
|
|
magicResult = candidate
|
|
break
|
|
}
|
|
}
|
|
|
|
expect(magicResult.prefix?.mods[0]).toEqual({
|
|
code: 'hit-skill',
|
|
param: 'Amplify Damage',
|
|
min: 5,
|
|
max: 1,
|
|
value: 5,
|
|
})
|
|
expect(magicResult.suffix?.mods[0]).toEqual({
|
|
code: 'charged',
|
|
param: '54',
|
|
min: 20,
|
|
max: 2,
|
|
value: 20,
|
|
})
|
|
|
|
const magicItem = createDroppedItem(swordBase, 'magic', {
|
|
magicAffixes: magicResult,
|
|
ilvl: 40,
|
|
dwInitSeed: 555,
|
|
})
|
|
|
|
const magicTooltip = formatItemTooltip(magicItem)
|
|
const magicLines = magicTooltip.lines.map(l => l.text)
|
|
expect(magicLines.some(l => /^5% Chance to cast Level 1 Amplify Damage on striking$/i.test(l))).toBe(true)
|
|
expect(magicLines).toContain('Level 2 Teleport (20/20 Charges)')
|
|
|
|
// Rare item with both suffixes (66 and 54) + prefix
|
|
const rareResult = rollRareAffixes(
|
|
swordBase,
|
|
40,
|
|
{
|
|
prefixes: { all: [bronzePrefix] } as unknown as AffixTable,
|
|
suffixes: { all: [ampSuffix, teleSuffix] } as unknown as AffixTable,
|
|
},
|
|
mockRareNames,
|
|
mockIsA,
|
|
new D2Rng(42),
|
|
)
|
|
|
|
const rareItem = createDroppedItem(swordBase, 'rare', {
|
|
rareAffixes: rareResult,
|
|
ilvl: 40,
|
|
dwInitSeed: 666,
|
|
})
|
|
|
|
const rareTooltip = formatItemTooltip(rareItem)
|
|
const rareLines = rareTooltip.lines.map(l => l.text)
|
|
expect(rareLines.some(l => /^5% Chance to cast Level 1 Amplify Damage on striking$/i.test(l))).toBe(true)
|
|
expect(rareLines).toContain('Level 2 Teleport (20/20 Charges)')
|
|
})
|
|
})
|
|
|
|
describe('AutoMagic.txt & 1.13c StaffMods Drop Pipeline & Tooltip Integration (Issue #129)', () => {
|
|
let dropTables: DropTables
|
|
|
|
beforeAll(() => {
|
|
dropTables = getEmbeddedDropTables()
|
|
})
|
|
|
|
it('1. loads dropTables.autoMagic with 36 canonical entries and parses autoPrefix on class-specific bases', () => {
|
|
expect(dropTables.autoMagic).toBeDefined()
|
|
expect(dropTables.autoMagic!.length).toBe(36)
|
|
expect(dropTables.autoMagic!.entries.length).toBe(36)
|
|
|
|
const targe = dropTables.getBase('pa1')!
|
|
const vortexShield = dropTables.getBase('paf')!
|
|
const maidenJavelin = dropTables.getBase('am5')!
|
|
const eagleOrb = dropTables.getBase('ob1')!
|
|
const stagBow = dropTables.getBase('am1')!
|
|
const preservedHead = dropTables.getBase('ne1')!
|
|
|
|
expect(resolveAutoPrefixGroup(targe, dropTables.itemTypes, dropTables.isA)).toBe(304)
|
|
expect(resolveAutoPrefixGroup(vortexShield, dropTables.itemTypes, dropTables.isA)).toBe(304)
|
|
expect(resolveAutoPrefixGroup(maidenJavelin, dropTables.itemTypes, dropTables.isA)).toBe(302)
|
|
expect(resolveAutoPrefixGroup(eagleOrb, dropTables.itemTypes, dropTables.isA)).toBe(303)
|
|
expect(resolveAutoPrefixGroup(stagBow, dropTables.itemTypes, dropTables.isA)).toBe(300)
|
|
expect(resolveAutoPrefixGroup(preservedHead, dropTables.itemTypes, dropTables.isA)).toBe(305)
|
|
})
|
|
|
|
it('2. Paladin Shields (pa1 / paf) and Amazon Javelins (am5) automatically carry AutoMagic affixes across Normal, Magic, and Rare drops and render in formatItemTooltip', () => {
|
|
// Test Paladin Shield (paf Vortex Shield / pa1 Targe) across Normal, Magic, Rare
|
|
const seenPalQualities = new Set<string>()
|
|
for (let i = 0; i < 250; i++) {
|
|
const mf = i < 40 ? -100 : i < 100 ? 100 : 800
|
|
const drops = executeDropPipeline({
|
|
tcName: i % 2 === 0 ? 'paf' : 'pa1',
|
|
nLevel: 85,
|
|
monsterType: 1,
|
|
dropTables,
|
|
difficulty: 'hell',
|
|
playerMf: mf,
|
|
gamePlayers: 1,
|
|
partyPlayers: 1,
|
|
monsterRng: (0x12900000 + i * 997) >>> 0,
|
|
})
|
|
expect(drops.length).toBe(1)
|
|
const item = drops[0]!
|
|
const q = item.rarity ?? 'normal'
|
|
if (q === 'normal' || q === 'magic' || q === 'rare') {
|
|
seenPalQualities.add(q)
|
|
expect((item as any).rolledAutoMagic).toBeDefined()
|
|
expect((item as any).rolledAutoMagic.group).toBe(304)
|
|
|
|
const tooltip = formatItemTooltip(item)
|
|
const texts = tooltip.lines.map(l => l.text)
|
|
const hasResAll = texts.some(t => /^All Resistances \+\d+/.test(t) || /Resist \+\d+%/.test(t))
|
|
const hasEdAndAr =
|
|
texts.some(t => /\+\d+% Enhanced Damage/.test(t)) &&
|
|
texts.some(t => /\+\d+ to Attack Rating/.test(t))
|
|
expect(
|
|
hasResAll || hasEdAndAr,
|
|
`Paladin shield (${item.base.id}, quality=${q}) tooltip lines ${JSON.stringify(texts)} must include AutoMagic res-all or dmg%/att`,
|
|
).toBe(true)
|
|
}
|
|
}
|
|
expect(seenPalQualities.has('normal')).toBe(true)
|
|
expect(seenPalQualities.has('magic')).toBe(true)
|
|
expect(seenPalQualities.has('rare')).toBe(true)
|
|
|
|
// Test Amazon Javelin (am5 Maiden Javelin) across Normal, Magic, Rare
|
|
const seenJavQualities = new Set<string>()
|
|
for (let i = 0; i < 250; i++) {
|
|
const mf = i < 40 ? -100 : i < 100 ? 100 : 800
|
|
const drops = executeDropPipeline({
|
|
tcName: 'am5',
|
|
nLevel: 85,
|
|
monsterType: 1,
|
|
dropTables,
|
|
difficulty: 'hell',
|
|
playerMf: mf,
|
|
gamePlayers: 1,
|
|
partyPlayers: 1,
|
|
monsterRng: (0x12950000 + i * 1013) >>> 0,
|
|
})
|
|
expect(drops.length).toBe(1)
|
|
const item = drops[0]!
|
|
const q = item.rarity ?? 'normal'
|
|
if (q === 'normal' || q === 'magic' || q === 'rare') {
|
|
seenJavQualities.add(q)
|
|
expect((item as any).rolledAutoMagic).toBeDefined()
|
|
expect((item as any).rolledAutoMagic.group).toBe(302)
|
|
|
|
const tooltip = formatItemTooltip(item)
|
|
const texts = tooltip.lines.map(l => l.text)
|
|
expect(
|
|
texts.some(t => /^\+[1-9]\d* to Javelin and Spear Skills \(Amazon Only\)$/.test(t)),
|
|
`Amazon javelin (am5, quality=${q}) tooltip lines ${JSON.stringify(texts)} must include +X to Javelin and Spear Skills (Amazon Only)`,
|
|
).toBe(true)
|
|
}
|
|
}
|
|
expect(seenJavQualities.has('normal')).toBe(true)
|
|
expect(seenJavQualities.has('magic')).toBe(true)
|
|
expect(seenJavQualities.has('rare')).toBe(true)
|
|
})
|
|
|
|
it('3. rollStaffMods generates +1..+3 to [Skill] ([Class] Only) for Sorceress Orb (ob1), Necromancer Wand (wnd), and Paladin Scepter (scp) by ilvl and renders in formatItemTooltip', () => {
|
|
const orbBase = dropTables.getBase('ob1')!
|
|
const wandBase = dropTables.getBase('wnd')!
|
|
const scepterBase = dropTables.getBase('scp')!
|
|
|
|
expect(resolveStaffModsClass(orbBase, dropTables.itemTypes)).toBe('sor')
|
|
expect(resolveStaffModsClass(wandBase, dropTables.itemTypes)).toBe('nec')
|
|
expect(resolveStaffModsClass(scepterBase, dropTables.itemTypes)).toBe('pal')
|
|
|
|
const checkStaffModsForClass = (
|
|
base: ItemBase,
|
|
expectedClassLabel: string,
|
|
minSkillId: number,
|
|
maxSkillId: number,
|
|
) => {
|
|
// Low ilvl (ilvl = 1 -> baseTier = 1, max tier = 2 -> first 10 skills of class)
|
|
let lowIlvlModsFound = 0
|
|
for (let s = 1; s <= 40; s++) {
|
|
const mods = rollStaffMods(base, 1, dropTables.itemTypes, new D2Rng(s * 31337))
|
|
for (const m of mods) {
|
|
lowIlvlModsFound++
|
|
expect(m.code).toBe('skill')
|
|
const skillId = Number(m.param)
|
|
expect(skillId).toBeGreaterThanOrEqual(minSkillId)
|
|
expect(skillId).toBeLessThanOrEqual(minSkillId + 9) // Tier 1..2 only at ilvl 1
|
|
expect(m.value).toBeGreaterThanOrEqual(1)
|
|
expect(m.value).toBeLessThanOrEqual(3)
|
|
}
|
|
}
|
|
expect(lowIlvlModsFound).toBeGreaterThan(0)
|
|
|
|
// High ilvl (ilvl = 50 -> baseTier = 5, max tier = 6 -> up to 30th skill of class)
|
|
let highTierSkillFound = false
|
|
let renderedTooltipVerified = false
|
|
for (let s = 1; s <= 60; s++) {
|
|
const drops = executeDropPipeline({
|
|
tcName: base.id,
|
|
nLevel: 50,
|
|
monsterType: 1,
|
|
dropTables,
|
|
difficulty: 'nightmare',
|
|
playerMf: 50,
|
|
gamePlayers: 1,
|
|
partyPlayers: 1,
|
|
monsterRng: (0x12980000 + s * 2027) >>> 0,
|
|
})
|
|
expect(drops.length).toBe(1)
|
|
const item = drops[0]!
|
|
const staffMods = (item as any).staffMods as { code: string; param: number; value: number }[] | undefined
|
|
if (staffMods && staffMods.length > 0) {
|
|
for (const sm of staffMods) {
|
|
const skillId = Number(sm.param)
|
|
expect(skillId).toBeGreaterThanOrEqual(minSkillId)
|
|
expect(skillId).toBeLessThanOrEqual(maxSkillId)
|
|
if (skillId >= minSkillId + 15) {
|
|
highTierSkillFound = true
|
|
}
|
|
}
|
|
const tooltip = formatItemTooltip(item)
|
|
const texts = tooltip.lines.map(l => l.text)
|
|
const classRegex = new RegExp(`^\\+[1-9]\\d* to .+ \\(${expectedClassLabel} Only\\)$`)
|
|
expect(
|
|
texts.some(t => classRegex.test(t)),
|
|
`Expected ${base.id} tooltip ${JSON.stringify(texts)} to contain +X to [Skill] (${expectedClassLabel} Only)`,
|
|
).toBe(true)
|
|
renderedTooltipVerified = true
|
|
}
|
|
}
|
|
expect(highTierSkillFound).toBe(true)
|
|
expect(renderedTooltipVerified).toBe(true)
|
|
}
|
|
|
|
checkStaffModsForClass(orbBase, 'Sorceress', 36, 65)
|
|
checkStaffModsForClass(wandBase, 'Necromancer', 66, 95)
|
|
checkStaffModsForClass(scepterBase, 'Paladin', 96, 125)
|
|
})
|
|
})
|
|
|
|
describe('dgrp Exact-Equality Aggregation Rule (Issue #130 / C2)', () => {
|
|
const amuletBase: ItemBase = {
|
|
id: 'amu',
|
|
name: 'Amulet',
|
|
kind: 'armor',
|
|
tags: ['amul'],
|
|
damage: 0,
|
|
defense: 0,
|
|
value: 100,
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
maxStack: 1,
|
|
level: 1,
|
|
}
|
|
|
|
it('1. folds dgrp=2 into All Resistances +X ONLY when all 4 resistances are positive and strictly equal', () => {
|
|
// 4 equal individual resistances (+15 each)
|
|
const itemFromFour = createDroppedItem(amuletBase, 'normal', { ilvl: 80, dwInitSeed: 100 })
|
|
;(itemFromFour as any).rolledProps = [
|
|
{ code: 'res-fire', min: 15, max: 15, value: 15 },
|
|
{ code: 'res-cold', min: 15, max: 15, value: 15 },
|
|
{ code: 'res-ltng', min: 15, max: 15, value: 15 },
|
|
{ code: 'res-pois', min: 15, max: 15, value: 15 },
|
|
]
|
|
expect(formatItemTooltip(itemFromFour).lines.map(l => l.text)).toEqual(['All Resistances +15'])
|
|
|
|
// Pure res-all (+15)
|
|
const itemFromResAll = createDroppedItem(amuletBase, 'normal', { ilvl: 80, dwInitSeed: 101 })
|
|
;(itemFromResAll as any).rolledProps = [{ code: 'res-all', min: 15, max: 15, value: 15 }]
|
|
expect(formatItemTooltip(itemFromResAll).lines.map(l => l.text)).toEqual(['All Resistances +15'])
|
|
})
|
|
|
|
it('2. unfolds dgrp=2 into 4 separate resistance lines when res-all stacks with a single elemental resistance (+15 res-all + +20 res-fire)', () => {
|
|
const item = createDroppedItem(amuletBase, 'normal', { ilvl: 80, dwInitSeed: 102 })
|
|
;(item as any).rolledProps = [
|
|
{ code: 'res-all', min: 15, max: 15, value: 15 },
|
|
{ code: 'res-fire', min: 20, max: 20, value: 20 },
|
|
]
|
|
|
|
const texts = formatItemTooltip(item).lines.map(l => l.text)
|
|
expect(texts).toEqual([
|
|
'Cold Resist +15%',
|
|
'Lightning Resist +15%',
|
|
'Fire Resist +35%',
|
|
'Poison Resist +15%',
|
|
])
|
|
expect(texts.some(t => t.startsWith('All Resistances'))).toBe(false)
|
|
})
|
|
|
|
it('3. unfolds dgrp=1 into 4 separate attribute lines when all-stats stacks with a single attribute (+10 all-stats + +15 str), and folds ONLY when all 4 are strictly equal', () => {
|
|
// Strictly equal (+10 to all 4)
|
|
const equalAttrItem = createDroppedItem(amuletBase, 'normal', { ilvl: 80, dwInitSeed: 103 })
|
|
;(equalAttrItem as any).rolledProps = [{ code: 'all-stats', min: 10, max: 10, value: 10 }]
|
|
expect(formatItemTooltip(equalAttrItem).lines.map(l => l.text)).toEqual(['+10 to All Attributes'])
|
|
|
|
// Stacked (+10 all-stats + +15 str -> str=25, dex=10, vit=10, enr=10)
|
|
const unequalAttrItem = createDroppedItem(amuletBase, 'normal', { ilvl: 80, dwInitSeed: 104 })
|
|
;(unequalAttrItem as any).rolledProps = [
|
|
{ code: 'all-stats', min: 10, max: 10, value: 10 },
|
|
{ code: 'str', min: 15, max: 15, value: 15 },
|
|
]
|
|
const texts = formatItemTooltip(unequalAttrItem).lines.map(l => l.text)
|
|
expect(texts).toEqual([
|
|
'+25 to Strength',
|
|
'+10 to Dexterity',
|
|
'+10 to Vitality',
|
|
'+10 to Energy',
|
|
])
|
|
expect(texts.some(t => t.includes('All Attributes'))).toBe(false)
|
|
|
|
// Verify aggregateAndSortProperties never puts 'all-res' or 'all-stats' into otherMerged when unequal
|
|
const rawMerged = aggregateAndSortProperties([
|
|
{ code: 'all-res', min: 12, max: 12, value: 12 },
|
|
{ code: 'res-ltng', min: 18, max: 18, value: 18 },
|
|
{ code: 'all-stats', min: 5, max: 5, value: 5 },
|
|
{ code: 'dex', min: 7, max: 7, value: 7 },
|
|
])
|
|
expect(rawMerged.map(p => `${p.code}:${p.value}`)).toEqual([
|
|
'str:5',
|
|
'dex:12',
|
|
'vit:5',
|
|
'enr:5',
|
|
'res-cold:12',
|
|
'res-ltng:30',
|
|
'res-fire:12',
|
|
'res-pois:12',
|
|
])
|
|
})
|
|
})
|
|
|