diablo2-web/tests/superunique-minion-drop.tes...

579 lines
18 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import { loadDropTables } from '../src/game/drop-pipeline.ts'
import * as dropPipeline from '../src/game/drop-pipeline.ts'
import { GameEngine } from '../src/game/engine.ts'
import { damageMonster } from '../src/game/combat.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { getMonsterTreasureClass } from '../src/game/monsters.ts'
import * as treasureClass from '../src/game/treasure-class.ts'
import * as fs from 'node:fs'
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
describe('SuperUnique Multi-Difficulty TC & Minion Drop Inheritance (Issue #411)', () => {
const dropTables = getEmbeddedDropTables()
const dummyTerrain = {
widthPx: 1000,
heightPx: 1000,
overlap: () => 0,
}
function createTestEngine(difficulty: 'normal' | 'nightmare' | 'hell' = 'normal') {
return new GameEngine(dummyTerrain, {
spawn: { x: 0, y: 0 },
stats: [],
xpTable: [0, 100, 200],
dropTables,
difficulty,
skills: [],
npcDefs: [],
questDefs: [],
combatOptions: {} as any,
talkRadius: 50,
pickupRadius: 50,
inventoryCols: 10,
inventoryRows: 4,
})
}
function lastDropArgs(spy: any) {
expect(spy).toHaveBeenCalled()
const calls = spy.mock.calls
return calls[calls.length - 1][1]
}
it('SuperUnique boss drops from SuperUnique TC while minion inherits host TreasureClass3', () => {
const engine = createTestEngine('normal')
const dropSpy = vi.spyOn(dropPipeline, 'executeDropPipeline')
// Bishibosh has TC: 'Act 1 Super A'.
// Bishibosh host class is fallenshaman1. Minions are fallen1.
// fallen1 TreasureClass3 is 'Act 1 Unique A'.
const bishibosh = dropTables.superUniques.get('Bishibosh')!
expect(bishibosh).toBeDefined()
expect(bishibosh.getTreasureClass?.('normal')).toBe('Act 1 Super A')
const fallenKind = dropTables.monsterKinds.get('fallen1')!
expect(fallenKind).toBeDefined()
const fallenTc3 = getMonsterTreasureClass(fallenKind, 'normal', 3)
expect(fallenTc3).toBe('Act 1 Unique A')
// Simulate Bishibosh boss kill: superUniqueId is set, monsterRank is 'unique'
engine.world.pendingKills = [
{
kind: 'kill',
x: 50,
y: 50,
subjectId: 'fallenshaman1',
monsterRank: 'unique',
superUniqueId: 'Bishibosh',
monsterLevel: 10,
} as any,
]
engine.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
expect(engine.metrics.dropsRolled).toBe(1)
const bossArgs = lastDropArgs(dropSpy)
expect(bossArgs.tcName).toBe('Act 1 Super A')
expect(bossArgs.monsterType).toBe(3)
expect(bossArgs.isBoss).toBe(false)
// Simulate Bishibosh MINION kill: superUniqueId is set, but monsterRank is 'minion'
// Minion MUST inherit host Unique monster TreasureClass3 instead of Bishibosh boss TC ('Act 1 Super A')
const minionEngine = createTestEngine('normal')
minionEngine.world.pendingKills = [
{
kind: 'kill',
x: 60,
y: 60,
subjectId: 'fallen1',
monsterRank: 'minion',
superUniqueId: 'Bishibosh',
monsterLevel: 10,
} as any,
]
minionEngine.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
expect(minionEngine.metrics.dropsRolled).toBe(1)
const minionArgs = lastDropArgs(dropSpy)
expect(minionArgs.tcName).toBe('Act 1 Unique A')
expect(minionArgs.monsterType).toBe(3)
expect(minionArgs.isBoss).toBe(false)
dropSpy.mockRestore()
})
it('real combat damageMonster killing a minion emits monsterType 3 and resolves TreasureClass3', () => {
const engine = createTestEngine('normal')
const dropSpy = vi.spyOn(dropPipeline, 'executeDropPipeline')
// Put a minion in the world
engine.world.monsters.push({
index: 0,
stats: {
id: 'fallen1',
name: 'Fallen Minion',
hp: 15,
maxHp: 15,
speed: 5,
rank: 'minion',
level: 8,
xp: 20,
superUniqueId: 'Bishibosh',
resistances: {} as any,
} as any,
x: 100,
y: 100,
hp: 15,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
})
// Kill the minion via combat damage
const killed = damageMonster(engine.world, 0, 999)
expect(killed).toBe(true)
// Verify buildKillEvent set monsterType to 3 for rank === 'minion'
expect(engine.world.pendingKills).toHaveLength(1)
expect(engine.world.pendingKills![0]!.monsterRank).toBe('minion')
expect(engine.world.pendingKills![0]!.monsterType).toBe(3)
// Advance engine tick to execute drop resolution
engine.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
expect(engine.metrics.dropsRolled).toBe(1)
const dropArgs = lastDropArgs(dropSpy)
expect(dropArgs.tcName).toBe('Act 1 Unique A')
expect(dropArgs.monsterType).toBe(3)
expect(dropArgs.isBoss).toBe(false)
dropSpy.mockRestore()
})
it('stale or adversarial monsterType: 1 on a minion event is strictly overridden to 3', () => {
const engine = createTestEngine('normal')
const dropSpy = vi.spyOn(dropPipeline, 'executeDropPipeline')
// Adversarially provide monsterType: 1 on a minion kill
engine.world.pendingKills = [
{
kind: 'kill',
x: 60,
y: 60,
subjectId: 'fallen1',
monsterRank: 'minion',
monsterType: 1, // Adversarial normal-monster type code
superUniqueId: 'Bishibosh',
monsterLevel: 10,
} as any,
]
engine.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
expect(engine.metrics.dropsRolled).toBe(1)
// Must be overridden to 3 and resolve to TreasureClass3
const dropArgs = lastDropArgs(dropSpy)
expect(dropArgs.tcName).toBe('Act 1 Unique A')
expect(dropArgs.monsterType).toBe(3)
expect(dropArgs.isBoss).toBe(false)
dropSpy.mockRestore()
})
it('SuperUnique minion inherits host TreasureClass3 on Nightmare and Hell difficulties', () => {
const dropSpy = vi.spyOn(dropPipeline, 'executeDropPipeline')
const fallenKind = dropTables.monsterKinds.get('fallen1')!
const fallenNmTc3 = getMonsterTreasureClass(fallenKind, 'nightmare', 3)
const fallenHellTc3 = getMonsterTreasureClass(fallenKind, 'hell', 3)
expect(fallenNmTc3).toBe('Act 1 (N) Unique A')
expect(fallenHellTc3).toBe('Act 1 (H) Unique A')
// On Nightmare
const engineNm = createTestEngine('nightmare')
engineNm.world.pendingKills = [
{
kind: 'kill',
x: 60,
y: 60,
subjectId: 'fallen1',
monsterRank: 'minion',
superUniqueId: 'Bishibosh',
monsterLevel: 45,
} as any,
]
engineNm.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
expect(engineNm.metrics.dropsRolled).toBe(1)
const nmArgs = lastDropArgs(dropSpy)
expect(nmArgs.tcName).toBe('Act 1 (N) Unique A')
expect(nmArgs.monsterType).toBe(3)
expect(nmArgs.difficulty).toBe('nightmare')
expect(nmArgs.isBoss).toBe(false)
// On Hell
const engineHell = createTestEngine('hell')
engineHell.world.pendingKills = [
{
kind: 'kill',
x: 60,
y: 60,
subjectId: 'fallen1',
monsterRank: 'minion',
superUniqueId: 'Bishibosh',
monsterLevel: 85,
} as any,
]
engineHell.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
expect(engineHell.metrics.dropsRolled).toBe(1)
const hellArgs = lastDropArgs(dropSpy)
expect(hellArgs.tcName).toBe('Act 1 (H) Unique A')
expect(hellArgs.monsterType).toBe(3)
expect(hellArgs.difficulty).toBe('hell')
expect(hellArgs.isBoss).toBe(false)
dropSpy.mockRestore()
})
it('minion of a boss monster does NOT inherit isBoss = true', () => {
const dropSpy = vi.spyOn(dropPipeline, 'executeDropPipeline')
// Blood Raven has boss: true in MonStats.txt
const bloodravenKind = dropTables.monsterKinds.get('bloodraven')!
expect(bloodravenKind.boss).toBe(true)
// Blood Raven boss kill
const engine = createTestEngine('hell')
engine.world.pendingKills = [
{
kind: 'kill',
x: 50,
y: 50,
subjectId: 'bloodraven',
monsterRank: 'unique',
superUniqueId: 'Blood Raven',
monsterLevel: 88,
} as any,
]
engine.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
const bossArgs = lastDropArgs(dropSpy)
expect(bossArgs.tcName).toBe('Blood Raven (H)')
expect(bossArgs.isBoss).toBe(true)
// Blood Raven minion kill (zombie1)
engine.world.pendingKills = [
{
kind: 'kill',
x: 50,
y: 50,
subjectId: 'zombie1',
monsterRank: 'minion',
superUniqueId: 'Blood Raven',
monsterLevel: 88,
} as any,
]
engine.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
// Minion must have isBoss: false so TC upgrading is permitted per 1.13c rules
const minionArgs = lastDropArgs(dropSpy)
expect(minionArgs.tcName).toBe('Act 1 (H) Unique A')
expect(minionArgs.isBoss).toBe(false)
expect(minionArgs.monsterType).toBe(3)
dropSpy.mockRestore()
})
it('minion with missing subjectId gracefully falls back to host superUnique monsterId', () => {
const dropSpy = vi.spyOn(dropPipeline, 'executeDropPipeline')
const engine = createTestEngine('normal')
// Bishibosh host monster is fallenshaman1
engine.world.pendingKills = [
{
kind: 'kill',
x: 60,
y: 60,
subjectId: '', // missing subjectId
monsterRank: 'minion',
superUniqueId: 'Bishibosh',
monsterLevel: 10,
} as any,
]
engine.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
expect(engine.metrics.dropsRolled).toBe(1)
// fallenshaman1 TreasureClass3 is 'Act 1 Unique A'
const fallbackArgs = lastDropArgs(dropSpy)
expect(fallbackArgs.tcName).toBe('Act 1 Unique A')
expect(fallbackArgs.monsterType).toBe(3)
dropSpy.mockRestore()
})
it('minion with missing subjectId and unknown superUniqueId fails fast with descriptive error', () => {
const engine = createTestEngine('normal')
engine.world.pendingKills = [
{
kind: 'kill',
x: 60,
y: 60,
subjectId: '',
monsterRank: 'minion',
superUniqueId: 'NonExistentSuperUnique_XYZ',
monsterLevel: 10,
} as any,
]
expect(() => {
engine.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
}).toThrow(/GameEngine drop failure: unknown monster/)
})
it('superUniqueId resolves case-insensitively and tolerates whitespace for both boss and minion', () => {
const dropSpy = vi.spyOn(dropPipeline, 'executeDropPipeline')
const engine = createTestEngine('normal')
// Boss with lowercase 'the countess'
engine.world.pendingKills = [
{
kind: 'kill',
x: 50,
y: 50,
subjectId: 'corruptrogue5',
monsterRank: 'unique',
superUniqueId: ' the countess ',
monsterLevel: 12,
} as any,
]
engine.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
expect(engine.metrics.dropsRolled).toBe(1)
const countessArgs = lastDropArgs(dropSpy)
expect(countessArgs.tcName).toBe('Countess')
expect(countessArgs.monsterType).toBe(3)
// Minion with lowercase 'bishibosh' and missing subjectId falls back to host monsterId
const minionEngine = createTestEngine('normal')
minionEngine.world.pendingKills = [
{
kind: 'kill',
x: 60,
y: 60,
subjectId: '',
monsterRank: 'minion',
superUniqueId: 'bishibosh',
monsterLevel: 10,
} as any,
]
minionEngine.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
expect(minionEngine.metrics.dropsRolled).toBe(1)
const minionArgs = lastDropArgs(dropSpy)
expect(minionArgs.tcName).toBe('Act 1 Unique A')
expect(minionArgs.monsterType).toBe(3)
dropSpy.mockRestore()
})
it('embeds noRatio from MonStats.txt and enforces noRatio TC upgrade suppression in GameEngine', () => {
// 1. Embedded monsterKinds parity: claygolem, valkyrie, etc. have noRatio: true
const claygolem = dropTables.monsterKinds.get('claygolem')!
expect(claygolem).toBeDefined()
expect(claygolem.noRatio).toBe(true)
const valkyrie = dropTables.monsterKinds.get('valkyrie')!
expect(valkyrie).toBeDefined()
expect(valkyrie.noRatio).toBe(true)
// Standard monsters do not have noRatio
const fallen = dropTables.monsterKinds.get('fallen1')!
expect(fallen).toBeDefined()
expect(fallen.noRatio).toBeUndefined()
// 2. GameEngine passes isNoRatio: true on kill to executeDropPipeline
const dropSpy = vi.spyOn(dropPipeline, 'executeDropPipeline')
const engine = createTestEngine('hell')
// Mock a summon/pet with noRatio: true and a TC in an isolated map
const testMonsterKind = {
...claygolem,
getTreasureClass: () => 'Act 1 H2H A',
}
const isolatedKinds = new Map(dropTables.monsterKinds)
isolatedKinds.set('test_pet', testMonsterKind as any)
engine.opts.dropTables = {
...dropTables,
monsterKinds: isolatedKinds,
}
engine.world.pendingKills = [
{
kind: 'kill',
x: 50,
y: 50,
subjectId: 'test_pet',
monsterRank: 'normal',
monsterLevel: 80,
} as any,
]
engine.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
expect(engine.metrics.dropsRolled).toBe(1)
const petDropArgs = lastDropArgs(dropSpy)
expect(petDropArgs.isNoRatio).toBe(true)
dropSpy.mockRestore()
// 3. Verify executeDropPipeline suppresses TC group upgrade when isNoRatio is true
const tcUpgradeSpy = vi.spyOn(treasureClass, 'resolveTreasureClassGroup')
dropPipeline.executeDropPipeline({
tcName: 'Act 1 H2H A',
nLevel: 80,
difficulty: 'hell',
isNoRatio: true,
dropTables,
})
expect(tcUpgradeSpy).not.toHaveBeenCalled()
// Without isNoRatio, TC upgrade runs
dropPipeline.executeDropPipeline({
tcName: 'Act 1 H2H A',
nLevel: 80,
difficulty: 'hell',
isNoRatio: false,
dropTables,
})
expect(tcUpgradeSpy).toHaveBeenCalledWith(dropTables.tcTable, 'Act 1 H2H A', 80)
tcUpgradeSpy.mockRestore()
})
it('audits all 66 SuperUniques and 734 MonsterKinds resolve authentic non-empty 1.13c TCs', () => {
expect(dropTables.superUniques.size).toBe(66)
expect(dropTables.monsterKinds.size).toBe(734)
for (const [id, su] of dropTables.superUniques) {
for (const diff of ['normal', 'nightmare', 'hell'] as const) {
const tc = su.getTreasureClass ? su.getTreasureClass(diff) : su.treasureClass
// ancientbarb1/2/3 in 1.13c SuperUniques.txt have blank TC column (they don't drop items)
if (id.startsWith('Ancient Barbarian')) {
continue
}
expect(tc, `SuperUnique ${id} on ${diff} should have valid TC`).toBeTruthy()
expect(dropTables.tcTable.get(tc), `TC "${tc}" for ${id} on ${diff} must exist in tcTable`).toBeDefined()
}
}
for (const [id, mon] of dropTables.monsterKinds) {
for (const diff of ['normal', 'nightmare', 'hell'] as const) {
for (const mType of [1, 2, 3, 4]) {
const tc = mon.getTreasureClass ? mon.getTreasureClass(diff, mType) : ''
// If a monster kind defines a TC, it must exist in tcTable
if (tc) {
expect(dropTables.tcTable.get(tc), `MonsterKind ${id} (diff: ${diff}, type: ${mType}) TC "${tc}" must exist in tcTable`).toBeDefined()
}
}
}
}
})
it('loadDropTables throws an explicit error when SuperUniques.txt cannot be read', async () => {
if (!hasD2) return
// Create an archive stack without SuperUniques.txt
const incompleteArchives = new MountedArchives()
incompleteArchives.add('d2data.mpq', await MpqArchive.open(await fileSource('samples/d2/d2data.mpq')))
// d2data.mpq alone does NOT have SuperUniques.txt (which is in d2exp.mpq / Patch_D2.mpq)
// Verify that loading fails fast with an explicit Error
await expect(loadDropTables(incompleteArchives)).rejects.toThrow(/SuperUniques\.txt|mounted archives have no member/)
})
})