diablo2-web/tests/drop-pipeline.test.ts

440 lines
16 KiB
TypeScript

import { describe, it, expect, beforeAll } from 'vitest'
import * as fs from 'fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import {
loadDropTables,
executeDropPipeline,
createDroppedItem,
canItemBeRare,
resolveItemTypeFlags,
type DropTables,
} from '../src/game/drop-pipeline.ts'
import { formatItemTooltip } from '../src/game/item-tooltip.ts'
import {
getMonsterTreasureClass,
type MonsterKind,
} from '../src/game/monsters.ts'
import { resolveTreasureClassGroup } from '../src/game/treasure-class.ts'
import { GameEngine, type GameEngineOptions } from '../src/game/engine.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from '../src/game/demo-data.ts'
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
describe('TreasureClass Drop Pipeline (Issue #107)', () => {
let dropTables: DropTables
beforeAll(async () => {
if (!hasD2) {
throw new Error('samples/d2 MPQ files required for Drop Pipeline test')
}
const 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}`)))
}
dropTables = await loadDropTables(archives)
})
describe('1. DropTables Loading & Fail Loudly', () => {
it('loads all required drop tables from mounted archives', () => {
expect(dropTables).toBeDefined()
expect(dropTables.tcTable.all.length).toBeGreaterThan(0)
expect(dropTables.autoTcTable.size).toBe(100)
expect(dropTables.weapons.all.length).toBeGreaterThan(0)
expect(dropTables.armor.all.length).toBeGreaterThan(0)
expect(dropTables.misc.all.length).toBeGreaterThan(0)
expect(dropTables.itemRatio.length).toBeGreaterThan(0)
expect(dropTables.difficultyLevels.all.length).toBeGreaterThan(0)
expect(dropTables.uniques.all.length).toBeGreaterThan(0)
expect(dropTables.sets.items.length).toBeGreaterThan(0)
expect(dropTables.sets.sets.length).toBeGreaterThan(0)
expect(dropTables.magicAffixes.prefixes.length).toBeGreaterThan(0)
expect(dropTables.magicAffixes.suffixes.length).toBeGreaterThan(0)
expect(dropTables.rareNames.prefixes.length).toBeGreaterThan(0)
expect(dropTables.rareNames.suffixes.length).toBeGreaterThan(0)
expect(dropTables.itemTypes.types.length).toBeGreaterThan(0)
expect(dropTables.monsterKinds.size).toBeGreaterThan(0)
})
it('fails loudly when archives have missing tables', async () => {
const emptyArchives = new MountedArchives()
await expect(loadDropTables(emptyArchives)).rejects.toThrow()
})
})
describe('2. MonStats TC Column Mapping (12 columns across 3 difficulties)', () => {
it('maps Fallen (fallen1) correctly for Normal difficulty', () => {
const fallen = dropTables.monsterKinds.get('fallen1')
expect(fallen).toBeDefined()
// MonsterType 1: Normal
const tcNormal = getMonsterTreasureClass(fallen!, 'normal', 1)
expect(tcNormal).toBe('Act 1 H2H A')
// MonsterType 2: Champion
const tcChamp = getMonsterTreasureClass(fallen!, 'normal', 2)
expect(tcChamp).toBe('Act 1 Champ A')
// MonsterType 3: Unique
const tcUnique = getMonsterTreasureClass(fallen!, 'normal', 3)
expect(tcUnique).toBe('Act 1 Unique A')
// MonsterType 4: Boss / Quest
const tcBoss = getMonsterTreasureClass(fallen!, 'normal', 4)
expect(tcBoss).toBeDefined()
})
it('maps Fallen (fallen1) correctly for Nightmare difficulty', () => {
const fallen = dropTables.monsterKinds.get('fallen1')
expect(fallen).toBeDefined()
// MonsterType 1: Normal (N)
const tcNormal = getMonsterTreasureClass(fallen!, 'nightmare', 1)
expect(tcNormal).toBe('Act 1 (N) H2H A')
// MonsterType 2: Champion (N)
const tcChamp = getMonsterTreasureClass(fallen!, 'nightmare', 2)
expect(tcChamp).toBe('Act 1 (N) Champ A')
// MonsterType 3: Unique (N)
const tcUnique = getMonsterTreasureClass(fallen!, 'nightmare', 3)
expect(tcUnique).toBe('Act 1 (N) Unique A')
})
it('maps Fallen (fallen1) correctly for Hell difficulty', () => {
const fallen = dropTables.monsterKinds.get('fallen1')
expect(fallen).toBeDefined()
// MonsterType 1: Normal (H)
const tcNormal = getMonsterTreasureClass(fallen!, 'hell', 1)
expect(tcNormal).toBe('Act 1 (H) H2H A')
// MonsterType 2: Champion (H)
const tcChamp = getMonsterTreasureClass(fallen!, 'hell', 2)
expect(tcChamp).toBe('Act 1 (H) Champ A')
// MonsterType 3: Unique (H)
const tcUnique = getMonsterTreasureClass(fallen!, 'hell', 3)
expect(tcUnique).toBe('Act 1 (H) Unique A')
})
})
describe('3. Top-Level TC Group Upgrade (D2Game!6FC32D60)', () => {
it('returns the base node when nLevel does not meet next tier level', () => {
const node = resolveTreasureClassGroup(dropTables.tcTable, 'Act 1 H2H A', 1)
expect(node).toBeDefined()
expect(node?.name).toBe('Act 1 H2H A')
})
it('upgrades TC node within the group when monster nLevel is higher', () => {
// Act 1 H2H A has group = 1, level = 1
// Act 1 H2H B has group = 1, level = 4
// Act 1 H2H C has group = 1, level = 7
const upgradedAt5 = resolveTreasureClassGroup(dropTables.tcTable, 'Act 1 H2H A', 5)
expect(upgradedAt5).toBeDefined()
expect(upgradedAt5?.name).toBe('Act 1 H2H B')
const upgradedAt10 = resolveTreasureClassGroup(dropTables.tcTable, 'Act 1 H2H A', 10)
expect(upgradedAt10).toBeDefined()
expect(upgradedAt10?.name).toBe('Act 1 H2H C')
})
it('returns undefined if TC name does not exist in table', () => {
const missing = resolveTreasureClassGroup(dropTables.tcTable, 'NonExistentTC', 10)
expect(missing).toBeUndefined()
})
})
describe('4. Drop Pipeline Execution', () => {
it('executes drop pipeline for Act 1 monster and yields real items without demo data', () => {
const drops = executeDropPipeline(dropTables, {
tcName: 'Act 1 H2H A',
nLevel: 3,
monsterType: 1,
difficulty: 'normal',
gamePlayers: 1,
partyPlayers: 1,
})
// Drops may be empty due to NoDrop, or contain items/gold
// Run multiple iterations to verify drop contents
let totalItems = 0
let totalGold = 0
const droppedNames: string[] = []
for (let i = 0; i < 50; i++) {
const batch = executeDropPipeline(dropTables, {
tcName: 'Act 1 H2H A',
nLevel: 3,
monsterType: 1,
difficulty: 'normal',
gamePlayers: 1,
partyPlayers: 1,
monsterRng: i * 1000 + 1,
})
for (const item of batch) {
expect(item.base.id).not.toMatch(/^demo-/)
expect(item.name).not.toMatch(/^demo-/)
if (item.base.id === 'gold' || item.code?.trim() === 'gld') {
totalGold += 1
} else {
totalItems += 1
droppedNames.push(item.name)
}
}
}
expect(totalGold + totalItems).toBeGreaterThan(0)
// Verify no item has demo tags or demo id
for (const name of droppedNames) {
expect(name).not.toBe('Sword')
expect(name).not.toBe('Cap')
}
})
it('supports champion and unique monster type quality boosts', () => {
let magicOrBetter = 0
for (let i = 0; i < 50; i++) {
const batch = executeDropPipeline(dropTables, {
tcName: 'Act 1 Champ A',
nLevel: 5,
monsterType: 2, // Champion
difficulty: 'normal',
playerMf: 100,
monsterRng: i * 7919 + 7,
})
for (const item of batch) {
if (item.base.id !== 'gold') {
if (item.prefix || item.suffix || (item.quality !== undefined && item.quality > 2)) {
magicOrBetter += 1
}
}
}
}
expect(magicOrBetter).toBeGreaterThan(0)
})
})
describe('5. Complete Deletion of DEMO_BASES & DEMO_AFFIXES', () => {
it('verifies demo-data.ts does not export DEMO_BASES or DEMO_AFFIXES', async () => {
const demoDataModule = await import('../src/game/demo-data.ts')
expect((demoDataModule as Record<string, unknown>).DEMO_BASES).toBeUndefined()
expect((demoDataModule as Record<string, unknown>).DEMO_AFFIXES).toBeUndefined()
})
it('verifies src/ contains 0 occurrences of DEMO_BASES and DEMO_AFFIXES', () => {
const srcFiles = fs.readdirSync('src', { recursive: true }) as string[]
for (const file of srcFiles) {
if (!file.endsWith('.ts')) continue
const content = fs.readFileSync(`src/${file}`, 'utf-8')
expect(content).not.toContain('DEMO_BASES')
expect(content).not.toContain('DEMO_AFFIXES')
}
})
})
describe('6. GameEngine Integration with DropTables', () => {
it('GameEngine rolls drops using real DropTables on monster kill', () => {
const fallen = dropTables.monsterKinds.get('fallen1')!
const dummyTerrain = {
widthPx: 1000,
heightPx: 1000,
overlap: () => 0,
}
const engineOpts: GameEngineOptions = {
spawn: { x: 500, y: 500 },
stats: [
{
id: 'fallen1',
name: 'Fallen',
level: 3,
rank: 'normal',
hp: 10,
damage: 2,
cooldownTicks: 10,
reach: 30,
aggroRadius: 100,
speed: 50,
xp: 20,
},
],
xpTable: DEMO_EXPERIENCE,
dropTables,
difficulty: 'normal',
monsterKinds: dropTables.monsterKinds,
skills: DEMO_SKILLS,
npcDefs: DEMO_NPCS,
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: 100,
playerReach: 60,
playerCooldownTicks: 1,
playerDamage: 100, // One-shot kill
playerManaPerAttack: 0,
respawnTicks: 100,
},
talkRadius: 80,
pickupRadius: 64,
inventoryCols: 10,
inventoryRows: 4,
monsterCount: 5,
monsterSpread: 100,
}
const engine = new GameEngine(dummyTerrain, engineOpts)
// Ensure monsters are within reach and attack
let killedAny = false
for (let tick = 0; tick < 50; tick++) {
engine.tick({
movement: { x: 0, y: 0 },
attacking: true,
pickingUp: false,
talking: false,
digits: [],
saving: false,
loading: false,
})
if (engine.world.events.some(e => e.kind === 'kill')) {
killedAny = true
}
}
expect(killedAny).toBe(true)
// Check that either ground items or gold bag additions were recorded from dropTables
expect(engine.metrics.dropsRolled).toBeGreaterThan(0)
for (const entry of engine.ground) {
expect(entry.item.base.id).not.toMatch(/^demo-/)
}
})
})
describe('7. Quality Degradation Fallback & Rolled Concrete Values (D2 1.13c Ground Truth)', () => {
it('7.1 canItemBeRare identifies valid rare item types and excludes charms', () => {
const smallCharm = dropTables.getBase('cm1')!
const largeCharm = dropTables.getBase('cm2')!
const grandCharm = dropTables.getBase('cm3')!
const monarch = dropTables.getBase('uit')!
const ring = dropTables.getBase('rin')!
const amulet = dropTables.getBase('amu')!
expect(canItemBeRare(smallCharm, dropTables.itemTypes, dropTables.isA)).toBe(false)
expect(canItemBeRare(largeCharm, dropTables.itemTypes, dropTables.isA)).toBe(false)
expect(canItemBeRare(grandCharm, dropTables.itemTypes, dropTables.isA)).toBe(false)
expect(canItemBeRare(monarch, dropTables.itemTypes, dropTables.isA)).toBe(true)
expect(canItemBeRare(ring, dropTables.itemTypes, dropTables.isA)).toBe(true)
expect(canItemBeRare(amulet, dropTables.itemTypes, dropTables.isA)).toBe(true)
})
it('7.2 Unique fallback: Monarch with ilvl 50 (< 77) degrades to Rare with 3x durability', () => {
const monarch = dropTables.getBase('uit')! // base durability 86
expect((monarch as any).durability).toBe(86)
// Force Unique on Monarch with ilvl 50 where Stormshield (lvl 77) cannot spawn
const dropped = createDroppedItem(monarch, 'rare', {
ilvl: 50,
dwInitSeed: 12345,
durabilityMultiplier: 3, // Unique fallback triple durability
rareAffixes: {
name: 'Storm Tower',
affixes: [],
} as any,
})
expect(dropped.rarity).toBe('rare')
expect(dropped.durability).toBe(86 * 3) // 258
expect(dropped.maxDurability).toBe(258)
})
it('7.3 Set fallback: Monarch with no set item degrades to Magic with 2x durability', () => {
const monarch = dropTables.getBase('uit')! // base durability 86
const dropped = createDroppedItem(monarch, 'magic', {
ilvl: 50,
dwInitSeed: 12345,
durabilityMultiplier: 2, // Set fallback double durability
magicAffixes: {
name: 'Monarch of Deflection',
} as any,
})
expect(dropped.rarity).toBe('magic')
expect(dropped.durability).toBe(86 * 2) // 172
expect(dropped.maxDurability).toBe(172)
})
it('7.4 Charm fallback: Charm rolling Set or Unique degrades to Magic, NEVER Rare', () => {
const smallCharm = dropTables.getBase('cm1')!
// When small charm quality roll produces Set, fallback MUST be Magic
const canRare = canItemBeRare(smallCharm, dropTables.itemTypes, dropTables.isA)
expect(canRare).toBe(false)
const dropped = createDroppedItem(smallCharm, 'magic', {
ilvl: 50,
dwInitSeed: 12345,
magicAffixes: {
name: 'Fine Small Charm of Vita',
} as any,
})
expect(dropped.rarity).toBe('magic')
expect(dropped.durability).toBeUndefined() // Charms have no durability
})
it('7.5 Concrete rolled values: properties roll specific values in [min, max] and formatItemTooltip shows single number', () => {
const dummyUniqueDef = dropTables.uniques.getEnabledByCode('uap')[0]! // Shako
const shakoBase = dropTables.getBase('uap')!
// Unique with variable or fixed props
const dropped = createDroppedItem(shakoBase, 'unique', {
uniqueItem: dummyUniqueDef,
ilvl: 87,
dwInitSeed: 99999,
})
expect(dropped.rolledProps).toBeDefined()
expect(dropped.rolledProps!.length).toBeGreaterThan(0)
for (const p of dropped.rolledProps!) {
expect(p.value).toBeGreaterThanOrEqual(p.min)
expect(p.value).toBeLessThanOrEqual(p.max)
}
const tooltip = formatItemTooltip(dropped)
// Check that tooltip lines do not contain unresolved ranges like '+11-20'
for (const line of tooltip.lines) {
// Unless it's an elemental damage range (Adds X-Y Damage), lines should not contain 'X-Y'
if (!line.text.startsWith('Adds ')) {
expect(line.text).not.toMatch(/\+\d+-\d+/)
}
}
})
it('7.6 resolveItemTypeFlags and Jewelry A drop pipeline never produce normal/superior/low rings, amulets, jewels, or charms', () => {
for (const code of ['rin', 'amu', 'jew', 'cm1', 'cm2', 'cm3']) {
const base = dropTables.getBase(code)!
const flags = resolveItemTypeFlags(base, dropTables.itemTypes, dropTables.isA)
expect(flags.isAlwaysMagic).toBe(true)
expect(flags.isNormalOnly).toBe(false)
}
for (let i = 0; i < 100; i++) {
const drops = executeDropPipeline(dropTables, {
tcName: 'Jewelry A',
nLevel: 1,
monsterType: 1,
difficulty: 'normal',
playerMf: -100,
monsterRng: (0xabc0000 + i * 997) >>> 0,
})
for (const item of drops) {
expect(['magic', 'rare', 'set', 'unique']).toContain(item.rarity)
}
}
})
})
})