feat(drops): implement monster & superunique treasure class resolution (Fixes #405)

This commit is contained in:
troytt 2026-09-24 08:13:35 +00:00
parent 29a53233a8
commit ebaa22e26d
7 changed files with 529 additions and 24 deletions

File diff suppressed because one or more lines are too long

View File

@ -288,6 +288,12 @@ export interface DropPipelineOptions {
readonly monsterRng?: D2Rng | Rng | number | undefined
/** Player Magic Find percentage (e.g. 50 = +50% MF). */
readonly playerMf?: number | undefined
/** Player Gold Find percentage (e.g. 100 = +100% GF). */
readonly playerGf?: number | undefined
/** Whether monster is a boss (never upgrades TC). */
readonly isBoss?: boolean | undefined
/** Whether monster is a summon/minion with noRatio (never upgrades TC). */
readonly isNoRatio?: boolean | undefined
/** Game difficulty. */
readonly difficulty?: Difficulty | undefined
/** Total players in game (for NoDrop scaling). */
@ -854,6 +860,9 @@ export function executeDropPipeline(
dropTables,
difficulty = 'normal',
playerMf = 0,
playerGf = 0,
isBoss = false,
isNoRatio = false,
gamePlayers = 1,
partyPlayers = 1,
isExpansion = true,
@ -870,8 +879,18 @@ export function executeDropPipeline(
// A8: Clamp effective ilvl to [1, 99] per 1.13c D2Game!6FC32D60
const effectiveIlvl = Math.max(1, Math.min(99, Math.trunc(nLevel)))
// 1. Top-level TC group upgrade (D2Game!6FC32D60)
const resolvedNode = resolveTreasureClassGroup(dropTables.tcTable, tcName, effectiveIlvl)
// 1. Top-level TC group upgrade (D2Game!6FC32D60 / 0x6FC52110)
// ONLY upgrade if difficulty != 'normal' && isExpansion && !isBoss && !isNoRatio && monsterType !== 4
const canUpgradeTC =
difficulty !== 'normal' &&
isExpansion !== false &&
!isBoss &&
!isNoRatio &&
monsterType !== 4
const resolvedNode = canUpgradeTC
? resolveTreasureClassGroup(dropTables.tcTable, tcName, effectiveIlvl)
: dropTables.tcTable.get(tcName)
const effectiveTcName = resolvedNode?.name ?? tcName
// 2. Setup RNG
@ -924,11 +943,21 @@ export function executeDropPipeline(
// 5. Process each dropped leaf
for (const leaf of dropLeaves) {
if (leaf.isGold) {
// A6: 1.13c D2Game!6FC319D0 gold drop range [floor(clampedLvl / 2), clampedLvl * 6] * multiplier
const multiplier = leaf.goldMultiplier ?? 1
// 1.13c Items.cpp:621: rand(5 * ilvl) + ilvl
const clampedLvl = effectiveIlvl
const baseGold = rng.randRange(Math.max(1, Math.trunc(clampedLvl / 2)), Math.max(6, clampedLvl * 6))
const goldAmount = Math.max(1, baseGold * multiplier)
const minGold = clampedLvl
const maxGoldAdd = 5 * clampedLvl
const baseGold = Math.max(1, rng.rand(maxGoldAdd) + minGold)
// Apply TC multiplier (mul flag)
const multiplier = leaf.goldMultiplier ?? 1
let goldAmount = Math.max(1, baseGold * multiplier)
// Apply Player Gold Find multiplier: floor(gold * (100 + GF) / 100)
if (playerGf !== 0) {
goldAmount = Math.max(1, Math.floor((goldAmount * (100 + playerGf)) / 100))
}
drops.push(goldItem(goldAmount))
continue
}

View File

@ -21,6 +21,8 @@ import {
RAW_MAGIC_SUFFIX,
RAW_RARE_PREFIX,
RAW_RARE_SUFFIX,
RAW_MONSTATS,
RAW_SUPERUNIQUES,
} from '../data/canonical-drop-data.ts'
import { parseTable } from './acts.ts'
import { parseTreasureClassTable, type TreasureClassNode } from './treasure-class.ts'
@ -43,6 +45,7 @@ import {
import type { DropTables } from './drop-pipeline.ts'
import type { ItemBase } from './items.ts'
import { CANONICAL_AUTOMAGIC_TABLE } from './automagic.ts'
import { readMonsterKinds, readSuperUniques, type SuperUnique } from './monsters.ts'
let _cachedDropTables: DropTables | null = null
@ -91,6 +94,16 @@ export function getEmbeddedDropTables(): DropTables {
}
const difficultyLevels = new DifficultyLevelsTable(Object.values(DEFAULT_DIFFICULTY_LEVEL_ODDS), oddsMap)
const monTable = parseTable(enc.encode(RAW_MONSTATS))
const monsterKinds = readMonsterKinds(monTable)
const suTable = parseTable(enc.encode(RAW_SUPERUNIQUES))
const suList = readSuperUniques(suTable)
const superUniques = new Map<string, SuperUnique>()
for (const su of suList) {
superUniques.set(su.id, su)
}
_cachedDropTables = {
tcTable,
autoTcTable,
@ -105,8 +118,8 @@ export function getEmbeddedDropTables(): DropTables {
rareNames,
autoMagic: CANONICAL_AUTOMAGIC_TABLE,
itemTypes,
monsterKinds: new Map(),
superUniques: new Map(),
monsterKinds,
superUniques,
isA: isAPredicate,
getBase,
}

View File

@ -891,18 +891,34 @@ export class GameEngine {
let tcName = ''
if (event.superUniqueId && this.opts.dropTables.superUniques.has(event.superUniqueId)) {
tcName = this.opts.dropTables.superUniques.get(event.superUniqueId)!.treasureClass
const su = this.opts.dropTables.superUniques.get(event.superUniqueId)!
tcName = typeof su.getTreasureClass === 'function'
? su.getTreasureClass(difficulty)
: su.treasureClass
}
const monsterKind = this.opts.dropTables.monsterKinds.get(event.subjectId ?? '') ?? this.opts.monsterKinds?.get(event.subjectId ?? '')
if (!tcName && monsterKind) {
tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType)
}
if (!tcName) {
const monsterKind = this.opts.dropTables.monsterKinds.get(event.subjectId ?? '') ?? this.opts.monsterKinds?.get(event.subjectId ?? '')
if (monsterKind) {
tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType)
}
}
if (!tcName) {
tcName = 'Act 1 H2H A'
continue
}
const isBoss = Boolean(monsterKind?.boss)
const isNoRatio = Boolean(monsterKind?.noRatio)
const playerMf =
totalStat(this.bag.contents, 'mag%') +
totalStat(this.bag.contents, 'magicFind') +
totalStat(this.bag.contents, 'item_magicbonus') +
((this.world.player as any).magicFind ?? 0)
const playerGf =
totalStat(this.bag.contents, 'gold%') +
totalStat(this.bag.contents, 'goldFind') +
totalStat(this.bag.contents, 'item_goldbonus') +
((this.world.player as any).goldFind ?? 0)
const droppedItems = executeDropPipeline(this.opts.dropTables, {
tcName,
nLevel: monsterLevel,
@ -910,6 +926,10 @@ export class GameEngine {
difficulty,
gamePlayers: 1,
partyPlayers: 1,
playerMf,
playerGf,
isBoss,
isNoRatio,
monsterRng: this.loot,
})

View File

@ -196,7 +196,9 @@ export interface MonsterKind {
readonly inTown: boolean
/** `boss` — a boss, excluded from ordinary population. */
readonly boss: boolean
/** `killable` — some rows are scenery that merely looks alive. */
/** `noRatio` — summons/pets/traps, excluded from ratio scaling and TC upgrading. */
readonly noRatio?: boolean
/** `killable` — some scenery that merely looks alive. */
readonly killable: boolean
/**
* `Rarity` — relative weight when the level picks which types to use.
@ -288,7 +290,7 @@ export function readMonsterKinds(table: D2Table, difficulty: Difficulty = 'norma
const kinds = new Map<string, MonsterKind>()
for (const row of table.rows) {
const id = cell(table, row, 'Id').trim()
if (id === '') continue
if (id === '' || id === 'Expansion') continue
// `MinHP`/`MaxHP` are capitalised differently per difficulty in the real
// file: `minHP` on normal, `MinHP(N)` and `MinHP(H)` after it. Reading the
// wrong case silently yields 0, so both spellings are tried.
@ -347,6 +349,7 @@ export function readMonsterKinds(table: D2Table, difficulty: Difficulty = 'norma
interact: flag(table, row, 'interact'),
inTown: flag(table, row, 'inTown'),
boss: flag(table, row, 'boss'),
...(flag(table, row, 'noRatio') ? { noRatio: true } : {}),
killable: flag(table, row, 'killable'),
rarity: num(table, row, 'Rarity', 1),
minGroup: num(table, row, 'MinGrp', 1),
@ -556,8 +559,11 @@ export const UNSCALED: MonsterScale = { armour: 100, toHit: 100, health: 100, da
*/
export function monsterLevelFor(kind: MonsterKind, difficulty: Difficulty, areaLevel: number): number {
const tier = difficulty === 'normal' ? 0 : difficulty === 'nightmare' ? 1 : 2
if (difficulty === 'normal' || kind.boss) return kind.level[tier]
return areaLevel > 0 ? areaLevel : kind.level[tier]
if (difficulty === 'normal' || kind.boss || kind.noRatio) {
return Math.min(99, Math.max(1, kind.level[tier]))
}
const baseLevel = areaLevel > 0 ? areaLevel : kind.level[tier]
return Math.min(99, Math.max(1, baseLevel))
}
/**
@ -750,6 +756,10 @@ export interface SuperUnique {
readonly replaceable: boolean
/** `TC` for this difficulty. Consumed by M10. */
readonly treasureClass: string
/**
* Difficulty-aware TreasureClass resolution for SuperUniques.
*/
getTreasureClass?(difficulty?: Difficulty): string
}
/**
@ -774,7 +784,13 @@ export function readSuperUniques(table: D2Table, difficulty: Difficulty = 'norma
// every other column blank. Keeping it would hand the spawner an entry with
// no monster behind it.
if (id === '' || monsterId === '') continue
out.push({
const tcsByDiff: Record<Difficulty, string> = {
normal: cell(table, row, 'TC').trim(),
nightmare: cell(table, row, 'TC(N)').trim() || cell(table, row, 'TC').trim(),
hell: cell(table, row, 'TC(H)').trim() || cell(table, row, 'TC(N)').trim() || cell(table, row, 'TC').trim(),
}
const getTc = (diff: Difficulty = 'normal'): string => tcsByDiff[diff] ?? tcsByDiff.normal
const su: SuperUnique = {
id,
nameKey: cell(table, row, 'Name').trim(),
monsterId,
@ -787,8 +803,15 @@ export function readSuperUniques(table: D2Table, difficulty: Difficulty = 'norma
autoPosition: flag(table, row, 'AutoPos'),
stacks: flag(table, row, 'Stacks'),
replaceable: flag(table, row, 'Replaceable'),
treasureClass: cell(table, row, `TC${d}`).trim(),
treasureClass: cell(table, row, `TC${d}`).trim() || tcsByDiff[difficulty] || '',
}
Object.defineProperty(su, 'getTreasureClass', {
value: getTc,
enumerable: false,
configurable: true,
writable: true,
})
out.push(su)
}
return out
}
@ -932,6 +955,13 @@ export function applyEliteModifiers(
let hp = base.hp
let damage = base.damage
let speed = base.speed
let level = base.level ?? 1
if (rank === 'champion') {
level += 2
} else if (rank === 'unique' || rank === 'minion') {
level += 3
}
level = Math.min(99, Math.max(1, level))
if (modifiers.includes('strong')) {
damage = Math.max(1, Math.round(damage * (MONUMOD_CONSTANTS.uniqueDmgPctStrong / 100)))
}
@ -983,6 +1013,7 @@ export function applyEliteModifiers(
damage,
speed,
rank,
level,
...(modifiers.length > 0 ? { modifiers } : {}),
...(superUniqueId !== undefined ? { superUniqueId } : {}),
...(updatedResistances !== undefined ? { resistances: updatedResistances, resists: updatedResistances, lightningResist: lightningRes } : {}),
@ -1534,7 +1565,7 @@ export function planLevelMonsters(
if (!bossKind) continue
const minionKind = kinds.get(suSpec.minionMonsterId) ?? kinds.get(suSpec.minionMonsterId.replace(/\d+$/, '1')) ?? bossKind
const bossArt = artMap?.get(bossKind.id)
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel)
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel), bossArt, bossLevel)
const bossScaled: MonsterStats = {
...bossBase,

View File

@ -394,7 +394,19 @@ export function resolveTreasureClassGroup(
): TreasureClassNode | undefined {
const initialNode = tcTable.byName.get(tcName)
if (!initialNode) return undefined
if (!initialNode.group || initialNode.group <= 0) return initialNode
if (nLevel <= 0 || !initialNode.group || initialNode.group <= 0) return initialNode
const idx = tcTable.all.indexOf(initialNode)
if (idx !== -1) {
let current = initialNode
for (let i = idx + 1; i < tcTable.all.length; i++) {
const next = tcTable.all[i]!
if (next.group !== initialNode.group) break
if (next.level > nLevel) break
current = next
}
return current
}
const candidates = tcTable.byGroup.get(initialNode.group)
if (!candidates || candidates.length === 0) return initialNode

View File

@ -0,0 +1,396 @@
import { describe, it, expect } from 'vitest'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import {
getMonsterTreasureClass,
monsterLevelFor,
applyEliteModifiers,
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 733 unique monster kinds from MonStats (735 rows - 1 Expansion divider - 1 cr_lancer8 duplicate)', () => {
expect(dropTables.monsterKinds.size).toBe(733)
expect(dropTables.monsterKinds.has('Expansion')).toBe(false)
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 without jumping across sequences', () => {
// Pindleskin TC is 'Act 5 Super Cx' (Group 18). Sequential scan must not jump to non-x series.
const resolved = resolveTreasureClassGroup(dropTables.tcTable, 'Act 5 Super Cx', 99)
expect(resolved?.name).toBe('Act 5 (H) Super Cx')
})
})
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)
})
})
})