Compare commits
2 Commits
29a53233a8
...
3aae6d164c
| Author | SHA1 | Date |
|---|---|---|
|
|
3aae6d164c | |
|
|
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
|
readonly monsterRng?: D2Rng | Rng | number | undefined
|
||||||
/** Player Magic Find percentage (e.g. 50 = +50% MF). */
|
/** Player Magic Find percentage (e.g. 50 = +50% MF). */
|
||||||
readonly playerMf?: number | undefined
|
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. */
|
/** Game difficulty. */
|
||||||
readonly difficulty?: Difficulty | undefined
|
readonly difficulty?: Difficulty | undefined
|
||||||
/** Total players in game (for NoDrop scaling). */
|
/** Total players in game (for NoDrop scaling). */
|
||||||
|
|
@ -854,6 +860,9 @@ export function executeDropPipeline(
|
||||||
dropTables,
|
dropTables,
|
||||||
difficulty = 'normal',
|
difficulty = 'normal',
|
||||||
playerMf = 0,
|
playerMf = 0,
|
||||||
|
playerGf = 0,
|
||||||
|
isBoss = false,
|
||||||
|
isNoRatio = false,
|
||||||
gamePlayers = 1,
|
gamePlayers = 1,
|
||||||
partyPlayers = 1,
|
partyPlayers = 1,
|
||||||
isExpansion = true,
|
isExpansion = true,
|
||||||
|
|
@ -870,8 +879,18 @@ export function executeDropPipeline(
|
||||||
// A8: Clamp effective ilvl to [1, 99] per 1.13c D2Game!6FC32D60
|
// A8: Clamp effective ilvl to [1, 99] per 1.13c D2Game!6FC32D60
|
||||||
const effectiveIlvl = Math.max(1, Math.min(99, Math.trunc(nLevel)))
|
const effectiveIlvl = Math.max(1, Math.min(99, Math.trunc(nLevel)))
|
||||||
|
|
||||||
// 1. Top-level TC group upgrade (D2Game!6FC32D60)
|
// 1. Top-level TC group upgrade (D2Game!6FC32D60 / 0x6FC52110)
|
||||||
const resolvedNode = resolveTreasureClassGroup(dropTables.tcTable, tcName, effectiveIlvl)
|
// 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
|
const effectiveTcName = resolvedNode?.name ?? tcName
|
||||||
|
|
||||||
// 2. Setup RNG
|
// 2. Setup RNG
|
||||||
|
|
@ -924,11 +943,21 @@ export function executeDropPipeline(
|
||||||
// 5. Process each dropped leaf
|
// 5. Process each dropped leaf
|
||||||
for (const leaf of dropLeaves) {
|
for (const leaf of dropLeaves) {
|
||||||
if (leaf.isGold) {
|
if (leaf.isGold) {
|
||||||
// A6: 1.13c D2Game!6FC319D0 gold drop range [floor(clampedLvl / 2), clampedLvl * 6] * multiplier
|
// 1.13c Items.cpp:621: rand(5 * ilvl) + ilvl
|
||||||
const multiplier = leaf.goldMultiplier ?? 1
|
|
||||||
const clampedLvl = effectiveIlvl
|
const clampedLvl = effectiveIlvl
|
||||||
const baseGold = rng.randRange(Math.max(1, Math.trunc(clampedLvl / 2)), Math.max(6, clampedLvl * 6))
|
const minGold = clampedLvl
|
||||||
const goldAmount = Math.max(1, baseGold * multiplier)
|
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))
|
drops.push(goldItem(goldAmount))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import {
|
||||||
RAW_MAGIC_SUFFIX,
|
RAW_MAGIC_SUFFIX,
|
||||||
RAW_RARE_PREFIX,
|
RAW_RARE_PREFIX,
|
||||||
RAW_RARE_SUFFIX,
|
RAW_RARE_SUFFIX,
|
||||||
|
RAW_MONSTATS,
|
||||||
|
RAW_SUPERUNIQUES,
|
||||||
} from '../data/canonical-drop-data.ts'
|
} from '../data/canonical-drop-data.ts'
|
||||||
import { parseTable } from './acts.ts'
|
import { parseTable } from './acts.ts'
|
||||||
import { parseTreasureClassTable, type TreasureClassNode } from './treasure-class.ts'
|
import { parseTreasureClassTable, type TreasureClassNode } from './treasure-class.ts'
|
||||||
|
|
@ -43,6 +45,7 @@ import {
|
||||||
import type { DropTables } from './drop-pipeline.ts'
|
import type { DropTables } from './drop-pipeline.ts'
|
||||||
import type { ItemBase } from './items.ts'
|
import type { ItemBase } from './items.ts'
|
||||||
import { CANONICAL_AUTOMAGIC_TABLE } from './automagic.ts'
|
import { CANONICAL_AUTOMAGIC_TABLE } from './automagic.ts'
|
||||||
|
import { readMonsterKinds, readSuperUniques, type SuperUnique } from './monsters.ts'
|
||||||
|
|
||||||
let _cachedDropTables: DropTables | null = null
|
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 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 = {
|
_cachedDropTables = {
|
||||||
tcTable,
|
tcTable,
|
||||||
autoTcTable,
|
autoTcTable,
|
||||||
|
|
@ -105,8 +118,8 @@ export function getEmbeddedDropTables(): DropTables {
|
||||||
rareNames,
|
rareNames,
|
||||||
autoMagic: CANONICAL_AUTOMAGIC_TABLE,
|
autoMagic: CANONICAL_AUTOMAGIC_TABLE,
|
||||||
itemTypes,
|
itemTypes,
|
||||||
monsterKinds: new Map(),
|
monsterKinds,
|
||||||
superUniques: new Map(),
|
superUniques,
|
||||||
isA: isAPredicate,
|
isA: isAPredicate,
|
||||||
getBase,
|
getBase,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -891,18 +891,34 @@ export class GameEngine {
|
||||||
|
|
||||||
let tcName = ''
|
let tcName = ''
|
||||||
if (event.superUniqueId && this.opts.dropTables.superUniques.has(event.superUniqueId)) {
|
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
|
||||||
}
|
}
|
||||||
if (!tcName) {
|
|
||||||
const monsterKind = this.opts.dropTables.monsterKinds.get(event.subjectId ?? '') ?? this.opts.monsterKinds?.get(event.subjectId ?? '')
|
const monsterKind = this.opts.dropTables.monsterKinds.get(event.subjectId ?? '') ?? this.opts.monsterKinds?.get(event.subjectId ?? '')
|
||||||
if (monsterKind) {
|
if (!tcName && monsterKind) {
|
||||||
tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType)
|
tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!tcName) {
|
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, {
|
const droppedItems = executeDropPipeline(this.opts.dropTables, {
|
||||||
tcName,
|
tcName,
|
||||||
nLevel: monsterLevel,
|
nLevel: monsterLevel,
|
||||||
|
|
@ -910,6 +926,10 @@ export class GameEngine {
|
||||||
difficulty,
|
difficulty,
|
||||||
gamePlayers: 1,
|
gamePlayers: 1,
|
||||||
partyPlayers: 1,
|
partyPlayers: 1,
|
||||||
|
playerMf,
|
||||||
|
playerGf,
|
||||||
|
isBoss,
|
||||||
|
isNoRatio,
|
||||||
monsterRng: this.loot,
|
monsterRng: this.loot,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,9 @@ export interface MonsterKind {
|
||||||
readonly inTown: boolean
|
readonly inTown: boolean
|
||||||
/** `boss` — a boss, excluded from ordinary population. */
|
/** `boss` — a boss, excluded from ordinary population. */
|
||||||
readonly boss: boolean
|
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
|
readonly killable: boolean
|
||||||
/**
|
/**
|
||||||
* `Rarity` — relative weight when the level picks which types to use.
|
* `Rarity` — relative weight when the level picks which types to use.
|
||||||
|
|
@ -347,6 +349,7 @@ export function readMonsterKinds(table: D2Table, difficulty: Difficulty = 'norma
|
||||||
interact: flag(table, row, 'interact'),
|
interact: flag(table, row, 'interact'),
|
||||||
inTown: flag(table, row, 'inTown'),
|
inTown: flag(table, row, 'inTown'),
|
||||||
boss: flag(table, row, 'boss'),
|
boss: flag(table, row, 'boss'),
|
||||||
|
...(flag(table, row, 'noRatio') ? { noRatio: true } : {}),
|
||||||
killable: flag(table, row, 'killable'),
|
killable: flag(table, row, 'killable'),
|
||||||
rarity: num(table, row, 'Rarity', 1),
|
rarity: num(table, row, 'Rarity', 1),
|
||||||
minGroup: num(table, row, 'MinGrp', 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 {
|
export function monsterLevelFor(kind: MonsterKind, difficulty: Difficulty, areaLevel: number): number {
|
||||||
const tier = difficulty === 'normal' ? 0 : difficulty === 'nightmare' ? 1 : 2
|
const tier = difficulty === 'normal' ? 0 : difficulty === 'nightmare' ? 1 : 2
|
||||||
if (difficulty === 'normal' || kind.boss) return kind.level[tier]
|
if (difficulty === 'normal' || kind.boss || kind.noRatio) {
|
||||||
return areaLevel > 0 ? areaLevel : kind.level[tier]
|
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
|
readonly replaceable: boolean
|
||||||
/** `TC` for this difficulty. Consumed by M10. */
|
/** `TC` for this difficulty. Consumed by M10. */
|
||||||
readonly treasureClass: string
|
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
|
// every other column blank. Keeping it would hand the spawner an entry with
|
||||||
// no monster behind it.
|
// no monster behind it.
|
||||||
if (id === '' || monsterId === '') continue
|
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,
|
id,
|
||||||
nameKey: cell(table, row, 'Name').trim(),
|
nameKey: cell(table, row, 'Name').trim(),
|
||||||
monsterId,
|
monsterId,
|
||||||
|
|
@ -787,8 +803,15 @@ export function readSuperUniques(table: D2Table, difficulty: Difficulty = 'norma
|
||||||
autoPosition: flag(table, row, 'AutoPos'),
|
autoPosition: flag(table, row, 'AutoPos'),
|
||||||
stacks: flag(table, row, 'Stacks'),
|
stacks: flag(table, row, 'Stacks'),
|
||||||
replaceable: flag(table, row, 'Replaceable'),
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
@ -932,6 +955,13 @@ export function applyEliteModifiers(
|
||||||
let hp = base.hp
|
let hp = base.hp
|
||||||
let damage = base.damage
|
let damage = base.damage
|
||||||
let speed = base.speed
|
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')) {
|
if (modifiers.includes('strong')) {
|
||||||
damage = Math.max(1, Math.round(damage * (MONUMOD_CONSTANTS.uniqueDmgPctStrong / 100)))
|
damage = Math.max(1, Math.round(damage * (MONUMOD_CONSTANTS.uniqueDmgPctStrong / 100)))
|
||||||
}
|
}
|
||||||
|
|
@ -983,6 +1013,7 @@ export function applyEliteModifiers(
|
||||||
damage,
|
damage,
|
||||||
speed,
|
speed,
|
||||||
rank,
|
rank,
|
||||||
|
level,
|
||||||
...(modifiers.length > 0 ? { modifiers } : {}),
|
...(modifiers.length > 0 ? { modifiers } : {}),
|
||||||
...(superUniqueId !== undefined ? { superUniqueId } : {}),
|
...(superUniqueId !== undefined ? { superUniqueId } : {}),
|
||||||
...(updatedResistances !== undefined ? { resistances: updatedResistances, resists: updatedResistances, lightningResist: lightningRes } : {}),
|
...(updatedResistances !== undefined ? { resistances: updatedResistances, resists: updatedResistances, lightningResist: lightningRes } : {}),
|
||||||
|
|
@ -1534,7 +1565,7 @@ export function planLevelMonsters(
|
||||||
if (!bossKind) continue
|
if (!bossKind) continue
|
||||||
const minionKind = kinds.get(suSpec.minionMonsterId) ?? kinds.get(suSpec.minionMonsterId.replace(/\d+$/, '1')) ?? bossKind
|
const minionKind = kinds.get(suSpec.minionMonsterId) ?? kinds.get(suSpec.minionMonsterId.replace(/\d+$/, '1')) ?? bossKind
|
||||||
const bossArt = artMap?.get(bossKind.id)
|
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 bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel), bossArt, bossLevel)
|
||||||
const bossScaled: MonsterStats = {
|
const bossScaled: MonsterStats = {
|
||||||
...bossBase,
|
...bossBase,
|
||||||
|
|
|
||||||
|
|
@ -394,7 +394,20 @@ export function resolveTreasureClassGroup(
|
||||||
): TreasureClassNode | undefined {
|
): TreasureClassNode | undefined {
|
||||||
const initialNode = tcTable.byName.get(tcName)
|
const initialNode = tcTable.byName.get(tcName)
|
||||||
if (!initialNode) return undefined
|
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 < current.level) break // Stop when ascending sub-sequence ends
|
||||||
|
if (next.level > nLevel) break
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
const candidates = tcTable.byGroup.get(initialNode.group)
|
const candidates = tcTable.byGroup.get(initialNode.group)
|
||||||
if (!candidates || candidates.length === 0) return initialNode
|
if (!candidates || candidates.length === 0) return initialNode
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,403 @@
|
||||||
|
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 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue