feat(drops): implement monster & superunique treasure class resolution (Fixes #405)
This commit is contained in:
parent
29a53233a8
commit
ebaa22e26d
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue