diablo2-web/tests/monster-treasure-class.test.ts

504 lines
18 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import {
getMonsterTreasureClass,
monsterLevelFor,
applyEliteModifiers,
monsterStatsOf,
type MonsterKind,
type Difficulty,
} from '../src/game/monsters.ts'
import {
executeDropPipeline,
type DropTables,
} from '../src/game/drop-pipeline.ts'
import { resolveTreasureClassGroup } from '../src/game/treasure-class.ts'
import { effectiveMF } from '../src/game/item-ratio.ts'
import { GameEngine } from '../src/game/engine.ts'
import { createItem, type ItemBase } from '../src/game/items.ts'
import { D2Rng } from '../src/game/d2-rng.ts'
import { Rng } from '../src/game/rng.ts'
describe('Monster & SuperUnique TreasureClass Resolution (Issue #405)', () => {
const dropTables = getEmbeddedDropTables()
describe('1. Embedded MonStats and SuperUniques Data Fidelity', () => {
it('hydrates 734 unique monster kinds from MonStats (735 rows - 1 duplicate cr_lancer8)', () => {
expect(dropTables.monsterKinds.size).toBe(734)
expect(dropTables.monsterKinds.has('Expansion')).toBe(true)
expect(dropTables.monsterKinds.has('fallen1')).toBe(true)
expect(dropTables.monsterKinds.has('andariel')).toBe(true)
expect(dropTables.monsterKinds.has('baalcrab')).toBe(true)
})
it('hydrates exactly 66 valid super uniques from SuperUniques.txt (excluding Expansion divider)', () => {
expect(dropTables.superUniques.size).toBe(66)
expect(dropTables.superUniques.has('Expansion')).toBe(false)
expect(dropTables.superUniques.has('Bishibosh')).toBe(true)
expect(dropTables.superUniques.has('The Countess')).toBe(true)
expect(dropTables.superUniques.has('Pindleskin')).toBe(true)
})
it('resolves correct TCs for standard monsters across all 3 difficulties', () => {
const fallen = dropTables.monsterKinds.get('fallen1')!
expect(fallen).toBeDefined()
expect(getMonsterTreasureClass(fallen, 'normal', 1)).toBe('Act 1 H2H A')
expect(getMonsterTreasureClass(fallen, 'nightmare', 1)).toBe('Act 1 (N) H2H A')
expect(getMonsterTreasureClass(fallen, 'hell', 1)).toBe('Act 1 (H) H2H A')
})
it('identifies boss flag and authentic quest boss TCs (TreasureClass4)', () => {
const andariel = dropTables.monsterKinds.get('andariel')!
expect(andariel.boss).toBe(true)
expect(getMonsterTreasureClass(andariel, 'normal', 1)).toBe('Andariel')
expect(getMonsterTreasureClass(andariel, 'normal', 4)).toBe('Andarielq')
expect(getMonsterTreasureClass(andariel, 'nightmare', 4)).toBe('Andarielq (N)')
expect(getMonsterTreasureClass(andariel, 'hell', 4)).toBe('Andarielq (H)')
const mephisto = dropTables.monsterKinds.get('mephisto')!
expect(mephisto.boss).toBe(true)
expect(getMonsterTreasureClass(mephisto, 'normal', 4)).toBe('Mephistoq')
expect(getMonsterTreasureClass(mephisto, 'nightmare', 4)).toBe('Mephistoq (N)')
expect(getMonsterTreasureClass(mephisto, 'hell', 4)).toBe('Mephistoq (H)')
const diablo = dropTables.monsterKinds.get('diablo')!
expect(diablo.boss).toBe(true)
expect(getMonsterTreasureClass(diablo, 'normal', 4)).toBe('Diabloq')
expect(getMonsterTreasureClass(diablo, 'nightmare', 4)).toBe('Diabloq (N)')
expect(getMonsterTreasureClass(diablo, 'hell', 4)).toBe('Diabloq (H)')
const baal = dropTables.monsterKinds.get('baalcrab')!
expect(baal.boss).toBe(true)
expect(getMonsterTreasureClass(baal, 'normal', 4)).toBe('Baalq')
expect(getMonsterTreasureClass(baal, 'nightmare', 4)).toBe('Baalq (N)')
expect(getMonsterTreasureClass(baal, 'hell', 4)).toBe('Baalq (H)')
})
it('returns empty string for non-dropping entities (critters, NPCs, scenery, summons)', () => {
const critters = ['cow', 'camel', 'chicken', 'hare', 'bird', 'frog']
for (const id of critters) {
const kind = dropTables.monsterKinds.get(id)
if (kind) {
expect(getMonsterTreasureClass(kind, 'normal', 1)).toBe('')
expect(getMonsterTreasureClass(kind, 'nightmare', 1)).toBe('')
expect(getMonsterTreasureClass(kind, 'hell', 1)).toBe('')
}
}
const npcs = ['cain1', 'akara', 'charsi', 'gheed', 'fara', 'drognan']
for (const id of npcs) {
const kind = dropTables.monsterKinds.get(id)
if (kind) {
expect(getMonsterTreasureClass(kind, 'normal', 1)).toBe('')
}
}
})
})
describe('2. SuperUniques Difficulty TC Resolution', () => {
it('resolves multi-difficulty TCs for SuperUniques via getTreasureClass', () => {
const bishibosh = dropTables.superUniques.get('Bishibosh')!
expect(bishibosh).toBeDefined()
expect(bishibosh.getTreasureClass?.('normal')).toBe('Act 1 Super A')
expect(bishibosh.getTreasureClass?.('nightmare')).toBe('Act 1 (N) Super A')
expect(bishibosh.getTreasureClass?.('hell')).toBe('Act 1 (H) Super A')
const countess = dropTables.superUniques.get('The Countess')!
expect(countess.getTreasureClass?.('normal')).toBe('Countess')
expect(countess.getTreasureClass?.('nightmare')).toBe('Countess (N)')
expect(countess.getTreasureClass?.('hell')).toBe('Countess (H)')
const smith = dropTables.superUniques.get('The Smith')!
expect(smith.getTreasureClass?.('normal')).toBe('Smith')
expect(smith.getTreasureClass?.('nightmare')).toBe('Smith (N)')
expect(smith.getTreasureClass?.('hell')).toBe('Smith (H)')
const pindleskin = dropTables.superUniques.get('Pindleskin')!
expect(pindleskin.getTreasureClass?.('normal')).toBe('Act 5 Super Cx')
expect(pindleskin.getTreasureClass?.('nightmare')).toBe('Act 5 (N) Super Cx')
expect(pindleskin.getTreasureClass?.('hell')).toBe('Act 5 (H) Super Cx')
})
it('returns empty string for non-dropping SuperUniques (Ancients)', () => {
const korlic = dropTables.superUniques.get('Ancient Barbarian 1')!
expect(korlic.getTreasureClass?.('normal')).toBe('')
expect(korlic.getTreasureClass?.('nightmare')).toBe('')
expect(korlic.getTreasureClass?.('hell')).toBe('')
})
})
describe('3. Monster Rank TC & Monster Level (mlvl) Determination', () => {
it('resolves rank-specific TCs: Normal=TC1, Champ=TC2, Unique=TC3, Minion=TC1', () => {
const fallen = dropTables.monsterKinds.get('fallen1')!
expect(getMonsterTreasureClass(fallen, 'normal', 1)).toBe('Act 1 H2H A')
expect(getMonsterTreasureClass(fallen, 'normal', 2)).toBe('Act 1 Champ A')
expect(getMonsterTreasureClass(fallen, 'normal', 3)).toBe('Act 1 Unique A')
// Minions drop from TC1
expect(getMonsterTreasureClass(fallen, 'normal', 1)).toBe('Act 1 H2H A')
})
it('determines base level: MonStats Level on Normal, AreaLevel on NM/Hell for non-bosses', () => {
const fallen = dropTables.monsterKinds.get('fallen1')!
// MonStats Level for fallen1 is 1
expect(monsterLevelFor(fallen, 'normal', 10)).toBe(1)
// Nightmare: uses area level (e.g. 40)
expect(monsterLevelFor(fallen, 'nightmare', 40)).toBe(40)
// Hell: uses area level (e.g. 85)
expect(monsterLevelFor(fallen, 'hell', 85)).toBe(85)
})
it('bosses always use MonStats level and ignore AreaLevel on NM/Hell', () => {
const andariel = dropTables.monsterKinds.get('andariel')!
expect(andariel.boss).toBe(true)
// MonStats levels for Andariel: [12, 49, 75]
expect(monsterLevelFor(andariel, 'normal', 5)).toBe(12)
expect(monsterLevelFor(andariel, 'nightmare', 80)).toBe(49)
expect(monsterLevelFor(andariel, 'hell', 85)).toBe(75)
})
it('applies rank level modifiers: Normal +0, Champ +2, Unique +3, Minion +3, clamped [1, 99]', () => {
const baseStats = {
id: 'fallen1',
name: 'Fallen',
hp: 100,
damage: 10,
cooldownTicks: 15,
reach: 20,
aggroRadius: 200,
speed: 50,
xp: 50,
level: 80,
}
const normalMod = applyEliteModifiers(baseStats, 'normal')
expect(normalMod.level).toBe(80)
const champMod = applyEliteModifiers(baseStats, 'champion')
expect(champMod.level).toBe(82)
const uniqueMod = applyEliteModifiers(baseStats, 'unique')
expect(uniqueMod.level).toBe(83)
const minionMod = applyEliteModifiers(baseStats, 'minion')
expect(minionMod.level).toBe(83)
// Test upper clamp at 99
const highBase = { ...baseStats, level: 98 }
const clampedUnique = applyEliteModifiers(highBase, 'unique')
expect(clampedUnique.level).toBe(99)
})
})
describe('4. TC Group Upgrading Gating (D2Game 0x6FC32D60)', () => {
it('NEVER upgrades TCs on Normal difficulty even with high mlvl', () => {
const drops = executeDropPipeline(dropTables, {
tcName: 'Act 1 H2H A',
nLevel: 85,
difficulty: 'normal',
isExpansion: true,
})
// Normal difficulty must not upgrade to Act 5 (H) H2H C
// resolveTreasureClassGroup called directly on Normal gating:
const upgradeAttempt = resolveTreasureClassGroup(dropTables.tcTable, 'Act 1 H2H A', 0)
expect(upgradeAttempt?.name).toBe('Act 1 H2H A')
})
it('upgrades TC on Hell difficulty when mlvl is sufficiently high', () => {
// Act 1 (H) H2H A (group 7, level 63) upgrades to Act 5 (H) H2H C (level 85) at mlvl 85
const upgraded = resolveTreasureClassGroup(dropTables.tcTable, 'Act 1 (H) H2H A', 85)
expect(upgraded?.name).toBe('Act 5 (H) H2H C')
})
it('NEVER upgrades boss TCs even on Hell difficulty', () => {
// In executeDropPipeline with isBoss=true or monsterType=4
const result = executeDropPipeline(dropTables, {
tcName: 'Andariel (H)',
nLevel: 99,
difficulty: 'hell',
isBoss: true,
monsterType: 4,
})
expect(result).toBeDefined()
})
it('preserves Group 18 nerfed x-series and non-x series without spillover', () => {
// Pindleskin TC is 'Act 5 Super Cx' (Group 18). Sequential scan must not jump to non-x series.
const resolvedX = resolveTreasureClassGroup(dropTables.tcTable, 'Act 5 Super Cx', 99)
expect(resolvedX?.name).toBe('Act 5 (H) Super Cx')
// Act 5 (H) Super C (lvl 96) must not spill over into Act 1 Super Ax (lvl 0)
const resolvedNonX99 = resolveTreasureClassGroup(dropTables.tcTable, 'Act 5 (H) Super C', 99)
expect(resolvedNonX99?.name).toBe('Act 5 (H) Super C')
const resolvedNonX70 = resolveTreasureClassGroup(dropTables.tcTable, 'Act 5 (H) Super C', 70)
expect(resolvedNonX70?.name).toBe('Act 5 (H) Super C')
})
})
describe('5. Magic Find Diminishing Returns & Quality Determination', () => {
it('calculates exact 1.13c effective Magic Find values', () => {
// MF <= 10: strictly linear
expect(effectiveMF(0, 250)).toBe(100)
expect(effectiveMF(10, 250)).toBe(110)
// Unique diminishing returns: (MF * 250) / (MF + 250)
// MF 100: 100 + floor(100 * 250 / 350) = 100 + 71 = 171
expect(effectiveMF(100, 250)).toBe(171)
// MF 300: 100 + floor(300 * 250 / 550) = 100 + 136 = 236
expect(effectiveMF(300, 250)).toBe(236)
// Set diminishing returns: (MF * 500) / (MF + 500)
// MF 100: 100 + floor(100 * 500 / 600) = 100 + 83 = 183
expect(effectiveMF(100, 500)).toBe(183)
// MF 300: 100 + floor(300 * 500 / 800) = 100 + 187 = 287
expect(effectiveMF(300, 500)).toBe(287)
// Rare diminishing returns: (MF * 600) / (MF + 600)
// MF 100: 100 + floor(100 * 600 / 700) = 100 + 85 = 185
expect(effectiveMF(100, 600)).toBe(185)
// MF 300: 100 + floor(300 * 600 / 900) = 100 + 200 = 300
expect(effectiveMF(300, 600)).toBe(300)
})
})
describe('6. Gold Generation & Gold Find (GF) Multipliers', () => {
it('generates base gold in range [ilvl, 6*ilvl - 1] and scales with playerGf', () => {
const ilvl = 20
// Execute drop pipeline with explicit gold TC
const rng1 = new D2Rng(12345)
const dropsBase = executeDropPipeline(dropTables, {
tcName: 'Gold',
nLevel: ilvl,
difficulty: 'normal',
playerGf: 0,
monsterRng: rng1,
})
expect(dropsBase.length).toBe(1)
const baseAmount = dropsBase[0]!.value ?? dropsBase[0]!.stack
expect(baseAmount).toBeGreaterThanOrEqual(ilvl)
expect(baseAmount).toBeLessThanOrEqual(ilvl * 6)
// Now with same seed and +100% GF: amount must be exactly 2x base
const rng2 = new D2Rng(12345)
const drops100Gf = executeDropPipeline(dropTables, {
tcName: 'Gold',
nLevel: ilvl,
difficulty: 'normal',
playerGf: 100,
monsterRng: rng2,
})
expect(drops100Gf.length).toBe(1)
expect(drops100Gf[0]!.value).toBe(Math.floor((baseAmount * 200) / 100))
// Now with same seed and +250% GF: amount must be exactly floor(base * 350 / 100)
const rng3 = new D2Rng(12345)
const drops250Gf = executeDropPipeline(dropTables, {
tcName: 'Gold',
nLevel: ilvl,
difficulty: 'normal',
playerGf: 250,
monsterRng: rng3,
})
expect(drops250Gf.length).toBe(1)
expect(drops250Gf[0]!.value).toBe(Math.floor((baseAmount * 350) / 100))
})
})
describe('7. GameEngine Drop Resolution & Fallback Elimination', () => {
const dummyTerrain = {
widthPx: 1000,
heightPx: 1000,
overlap: () => 0,
}
it('does not drop loot for entities with no TC (eliminating Act 1 H2H A fallback)', () => {
const engine = new GameEngine(dummyTerrain, {
spawn: { x: 0, y: 0 },
stats: [],
xpTable: [0, 100, 200],
dropTables,
skills: [],
npcDefs: [],
questDefs: [],
combatOptions: {} as any,
talkRadius: 50,
pickupRadius: 50,
inventoryCols: 10,
inventoryRows: 4,
})
// Simulate a kill event for a town critter (chicken, which has no TC)
engine.world.pendingKills = [
{
kind: 'kill',
x: 10,
y: 10,
subjectId: 'chicken',
monsterLevel: 1,
} as any,
]
engine.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
// No drops should be placed on ground or in bag
expect(engine.ground.length).toBe(0)
expect(engine.metrics.dropsRolled).toBe(0)
})
it('passes player equipped MF and GF from inventory into drop pipeline', () => {
const engine = new GameEngine(dummyTerrain, {
spawn: { x: 0, y: 0 },
stats: [],
xpTable: [0, 100, 200],
dropTables,
skills: [],
npcDefs: [],
questDefs: [],
combatOptions: {} as any,
talkRadius: 50,
pickupRadius: 50,
inventoryCols: 10,
inventoryRows: 4,
})
// Equip player with +50% MF and +100% GF ring
const mfRingBase = dropTables.getBase('rin')!
const ringItem = createItem(mfRingBase, [], [], new Rng(1), { level: 1 })
;(ringItem.stats as Record<string, number>)['mag%'] = 50
;(ringItem.stats as Record<string, number>)['gold%'] = 100
engine.bag.add(ringItem)
// Simulate a fallen kill
engine.world.pendingKills = [
{
kind: 'kill',
x: 20,
y: 20,
subjectId: 'fallen1',
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)
})
it('Issue #415: assigns rank: "boss" for act bosses in monsterStatsOf and resolves monsterType 4', () => {
const andariel = dropTables.monsterKinds.get('andariel')!
expect(andariel.boss).toBe(true)
const stats = monsterStatsOf(andariel, new Rng(1), 100)
expect(stats.rank).toBe('boss')
const fallen = dropTables.monsterKinds.get('fallen1')!
const fallenStats = monsterStatsOf(fallen, new Rng(1), 100)
expect(fallenStats.rank).toBe('normal')
// Test GameEngine resolves monsterRank: 'boss' to monsterType: 4 (Andarielq)
const engine = new GameEngine(dummyTerrain, {
spawn: { x: 0, y: 0 },
stats: [],
xpTable: [0, 100, 200],
dropTables,
skills: [],
npcDefs: [],
questDefs: [],
combatOptions: {} as any,
talkRadius: 50,
pickupRadius: 50,
inventoryCols: 10,
inventoryRows: 4,
})
engine.world.pendingKills = [
{
kind: 'kill',
x: 20,
y: 20,
subjectId: 'andariel',
monsterLevel: 12,
monsterRank: 'boss',
} 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)
})
it('Issue #417: combines getEquippedItems with inventory bag to scale playerMf and playerGf', () => {
const engine = new GameEngine(dummyTerrain, {
spawn: { x: 0, y: 0 },
stats: [],
xpTable: [0, 100, 200],
dropTables,
skills: [],
npcDefs: [],
questDefs: [],
combatOptions: {} as any,
talkRadius: 50,
pickupRadius: 50,
inventoryCols: 10,
inventoryRows: 4,
getEquippedItems: () => [
{
name: 'Harlequin Crest',
stats: { 'mag%': 50 },
},
{
name: 'Goldwrap',
stats: [{ text: '50% 额外金币 (Extra Gold)' }],
},
],
})
engine.world.pendingKills = [
{
kind: 'kill',
x: 20,
y: 20,
subjectId: 'fallen1',
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)
})
})
})