612 lines
18 KiB
TypeScript
612 lines
18 KiB
TypeScript
/**
|
|
* 1.13c Diablo II Item Drop Pipeline.
|
|
*
|
|
* Complete implementation of the real TreasureClassEx drop tree, replacing
|
|
* synthetic rollDrop and deleting demo item bases and affixes.
|
|
*
|
|
* Gold standard entry flow (D2Game!6FC32D60):
|
|
* - nLevel = monster.level
|
|
* - tcName = monStats.getTreasureClass(difficulty, monsterType)
|
|
* - Top-level TC group upgrade: resolveTreasureClassGroup(tcTable, tcName, nLevel)
|
|
* - Expand TC tree using expandTreasureClass (from rollTreasureClass)
|
|
* - For each rolled leaf item:
|
|
* * Gold: calculate scaled gold amount and emit goldItem(amount).
|
|
* * Base item:
|
|
* - Lookup base in weapons/armor/misc via dropTables.getBase(code).
|
|
* - Base upgrade: upgradeItemBase(base, qf4, qf5, rng, dropTables.getBase).
|
|
* - Init item seed: dwInitSeed = (rng.rand(0xFFFFFFFF) >>> 0), itemRng = new D2Rng(dwInitSeed).
|
|
* - ItemRatio quality roll using rollItemQuality.
|
|
* - Quality branches:
|
|
* * Unique: lookup eligible enabled uniques (lvl <= nLevel). Fallback if empty: Rare + 3x durability.
|
|
* * Set: lookup eligible set items (lvl <= nLevel). Fallback if empty: Magic + 2x durability.
|
|
* * Rare: rollRareAffixes.
|
|
* * Magic: rollMagicAffixes.
|
|
* * Superior / Normal / Low: standard base.
|
|
*/
|
|
|
|
import type { MountedArchives } from '../mpq/mount.ts'
|
|
import { parseTable } from './acts.ts'
|
|
import type { D2Table } from './acts.ts'
|
|
import { D2Rng } from './d2-rng.ts'
|
|
import { Rng } from './rng.ts'
|
|
import {
|
|
type TreasureClassTable,
|
|
type TreasureClassNode,
|
|
loadTreasureClasses,
|
|
resolveTreasureClassGroup,
|
|
} from './treasure-class.ts'
|
|
import {
|
|
expandTreasureClass,
|
|
type DroppedItemEntry,
|
|
} from './treasure-engine.ts'
|
|
import {
|
|
type WeaponTable,
|
|
loadWeapons,
|
|
} from './weapons.ts'
|
|
import {
|
|
type ArmorTable,
|
|
loadArmor,
|
|
} from './armor.ts'
|
|
import {
|
|
type MiscTable,
|
|
loadMisc,
|
|
} from './misc-items.ts'
|
|
import {
|
|
type ItemRatioTable,
|
|
loadItemRatio,
|
|
rollItemQuality,
|
|
type ItemQualityTier,
|
|
QUALITY_TIER_TO_ITEM_QUALITY,
|
|
} from './item-ratio.ts'
|
|
import {
|
|
type DifficultyLevelTable,
|
|
loadDifficultyLevels,
|
|
DEFAULT_DIFFICULTY_LEVEL_ODDS,
|
|
upgradeItemBase,
|
|
} from './item-upgrade.ts'
|
|
import {
|
|
type UniqueItemsTable,
|
|
loadUniqueItems,
|
|
type UniqueItem,
|
|
} from './unique-items.ts'
|
|
import {
|
|
type SetTable,
|
|
type SetItemTable,
|
|
loadSetData,
|
|
type SetItem,
|
|
} from './set-items.ts'
|
|
import {
|
|
type AffixTable,
|
|
loadMagicAffixes,
|
|
} from './affixes.ts'
|
|
import {
|
|
type RareNameTable,
|
|
loadRareNames,
|
|
} from './rare-names.ts'
|
|
import {
|
|
type ItemTypeTable,
|
|
loadItemTypes,
|
|
isA,
|
|
} from './item-types.ts'
|
|
import {
|
|
generateAutoTreasureClasses,
|
|
autoTcToTreasureClassNode,
|
|
} from './auto-tc.ts'
|
|
import {
|
|
type Item,
|
|
type ItemBase,
|
|
ItemQuality,
|
|
goldItem,
|
|
ItemFlag,
|
|
decodeItemFlags,
|
|
formatItemCode,
|
|
} from './items.ts'
|
|
import {
|
|
type MonsterKind,
|
|
readMonsterKinds,
|
|
getMonsterTreasureClass,
|
|
type Difficulty,
|
|
readSuperUniques,
|
|
type SuperUnique,
|
|
} from './monsters.ts'
|
|
import {
|
|
rollMagicAffixes,
|
|
rollRareAffixes,
|
|
type GeneratedAffixes,
|
|
type GeneratedRareAffixes,
|
|
} from './affix-generator.ts'
|
|
|
|
/**
|
|
* Complete collection of parsed game data tables required for the drop pipeline.
|
|
*/
|
|
export interface DropTables {
|
|
readonly tcTable: TreasureClassTable
|
|
readonly autoTcTable: Map<string, TreasureClassNode>
|
|
readonly weapons: WeaponTable
|
|
readonly armor: ArmorTable
|
|
readonly misc: MiscTable
|
|
readonly itemRatio: ItemRatioTable
|
|
readonly difficultyLevels: DifficultyLevelTable
|
|
readonly uniques: UniqueItemsTable
|
|
readonly sets: { readonly items: SetItemTable; readonly sets: SetTable }
|
|
readonly magicAffixes: { readonly prefixes: AffixTable; readonly suffixes: AffixTable }
|
|
readonly rareNames: { readonly prefixes: RareNameTable; readonly suffixes: RareNameTable }
|
|
readonly itemTypes: ItemTypeTable
|
|
readonly monsterKinds: Map<string, MonsterKind>
|
|
readonly superUniques: Map<string, SuperUnique>
|
|
readonly isA: (type: string, target: string) => boolean
|
|
readonly getBase: (code: string) => ItemBase | undefined
|
|
}
|
|
|
|
/**
|
|
* Loads all data tables required by the drop pipeline from mounted MPQ archives.
|
|
* Fails loudly with an Error if required files or tables are missing.
|
|
*
|
|
* @param archives - Mounted MPQ archives.
|
|
* @returns Promise resolving to DropTables.
|
|
*/
|
|
export async function loadDropTables(archives: MountedArchives): Promise<DropTables> {
|
|
const [
|
|
tcTable,
|
|
weapons,
|
|
armor,
|
|
misc,
|
|
itemRatio,
|
|
difficultyLevels,
|
|
uniques,
|
|
sets,
|
|
magicAffixes,
|
|
rareNames,
|
|
itemTypes,
|
|
monstatsBytes,
|
|
] = await Promise.all([
|
|
loadTreasureClasses(archives),
|
|
loadWeapons(archives),
|
|
loadArmor(archives),
|
|
loadMisc(archives),
|
|
loadItemRatio(archives),
|
|
loadDifficultyLevels(archives),
|
|
loadUniqueItems(archives),
|
|
loadSetData(archives),
|
|
loadMagicAffixes(archives),
|
|
loadRareNames(archives),
|
|
loadItemTypes(archives),
|
|
archives.read('data\\global\\excel\\monstats.txt'),
|
|
])
|
|
|
|
const rawAutoTcTable = generateAutoTreasureClasses(itemTypes, armor, weapons)
|
|
const autoTcTable = new Map<string, TreasureClassNode>()
|
|
for (const [key, node] of rawAutoTcTable) {
|
|
autoTcTable.set(key, autoTcToTreasureClassNode(node))
|
|
}
|
|
|
|
const isAPredicate = (type: string, target: string): boolean => isA(type, target, itemTypes)
|
|
|
|
const getBase = (code: string): ItemBase | undefined => {
|
|
return weapons.get(code) ?? armor.get(code) ?? misc.get(code)
|
|
}
|
|
|
|
const monstatsTable: D2Table = parseTable(monstatsBytes)
|
|
const monsterKinds = readMonsterKinds(monstatsTable)
|
|
|
|
const superUniques = new Map<string, SuperUnique>()
|
|
try {
|
|
const suBytes = await archives.read('data\\global\\excel\\SuperUniques.txt')
|
|
const suList = readSuperUniques(parseTable(suBytes))
|
|
for (const su of suList) {
|
|
superUniques.set(su.id, su)
|
|
}
|
|
} catch {
|
|
// If SuperUniques.txt is absent, keep map empty
|
|
}
|
|
|
|
return {
|
|
tcTable,
|
|
autoTcTable,
|
|
weapons,
|
|
armor,
|
|
misc,
|
|
itemRatio,
|
|
difficultyLevels,
|
|
uniques,
|
|
sets,
|
|
magicAffixes,
|
|
rareNames,
|
|
itemTypes,
|
|
monsterKinds,
|
|
superUniques,
|
|
isA: isAPredicate,
|
|
getBase,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Options for executing the TreasureClass drop pipeline.
|
|
*/
|
|
export interface DropPipelineOptions {
|
|
/** Top-level TreasureClass name (e.g. 'Act 1 H2H A', 'Act 1 Champ A'). */
|
|
readonly tcName: string
|
|
/** Monster level (or area level) used for group upgrade and ilvl. */
|
|
readonly nLevel: number
|
|
/** Monster type: 1 = Normal, 2 = Champion, 3 = Unique, 4 = Boss / Quest. */
|
|
readonly monsterType?: number | undefined
|
|
/** Monster drop RNG instance or seed. */
|
|
readonly monsterRng?: D2Rng | Rng | number | undefined
|
|
/** Player Magic Find percentage (e.g. 50 = +50% MF). */
|
|
readonly playerMf?: number | undefined
|
|
/** Game difficulty. */
|
|
readonly difficulty?: Difficulty | undefined
|
|
/** Total players in game (for NoDrop scaling). */
|
|
readonly gamePlayers?: number | undefined
|
|
/** Nearby party members (for NoDrop scaling). */
|
|
readonly partyPlayers?: number | undefined
|
|
/** Whether Expansion items and runes are enabled (default: true). */
|
|
readonly isExpansion?: boolean | undefined
|
|
/** Parsed drop tables. */
|
|
readonly dropTables: DropTables
|
|
}
|
|
|
|
/**
|
|
* Creates a concrete Item instance for a dropped leaf item.
|
|
*/
|
|
export function createDroppedItem(
|
|
base: ItemBase,
|
|
qualityTier: ItemQualityTier,
|
|
options: {
|
|
readonly uniqueItem?: UniqueItem | undefined
|
|
readonly setItem?: SetItem | undefined
|
|
readonly magicAffixes?: GeneratedAffixes | undefined
|
|
readonly rareAffixes?: GeneratedRareAffixes | undefined
|
|
readonly ilvl: number
|
|
readonly dwInitSeed: number
|
|
readonly durabilityMultiplier?: number | undefined
|
|
},
|
|
): Item {
|
|
const quality = QUALITY_TIER_TO_ITEM_QUALITY[qualityTier] ?? ItemQuality.NORMAL
|
|
const rawFlags = ItemFlag.IDENTIFIED
|
|
|
|
let name = base.name
|
|
let prefix = null
|
|
let suffix = null
|
|
const stats: Record<string, number> = {}
|
|
|
|
if (base.damage > 0) stats.damage = base.damage
|
|
if (base.defense > 0) stats.defense = base.defense
|
|
|
|
if (qualityTier === 'unique' && options.uniqueItem) {
|
|
name = options.uniqueItem.index
|
|
for (const prop of options.uniqueItem.props) {
|
|
if (prop.code) {
|
|
stats[prop.code] = (stats[prop.code] ?? 0) + prop.min
|
|
}
|
|
}
|
|
} else if (qualityTier === 'set' && options.setItem) {
|
|
name = options.setItem.index
|
|
for (const prop of options.setItem.props) {
|
|
if (prop.code) {
|
|
stats[prop.code] = (stats[prop.code] ?? 0) + prop.min
|
|
}
|
|
}
|
|
} else if (qualityTier === 'rare' && options.rareAffixes) {
|
|
name = options.rareAffixes.name
|
|
for (const affix of options.rareAffixes.affixes) {
|
|
for (const mod of affix.mods) {
|
|
stats[mod.code] = (stats[mod.code] ?? 0) + mod.value
|
|
}
|
|
}
|
|
} else if (qualityTier === 'magic' && options.magicAffixes) {
|
|
name = options.magicAffixes.name
|
|
if (options.magicAffixes.prefix) {
|
|
const p = options.magicAffixes.prefix
|
|
prefix = {
|
|
id: p.affix.name,
|
|
name: p.name,
|
|
kind: 'prefix' as const,
|
|
level: p.level,
|
|
itemTypes: p.affix.itypes,
|
|
modifiers: p.mods.map(m => ({ stat: m.code, min: m.min, max: m.max })),
|
|
}
|
|
for (const mod of p.mods) {
|
|
stats[mod.code] = (stats[mod.code] ?? 0) + mod.value
|
|
}
|
|
}
|
|
if (options.magicAffixes.suffix) {
|
|
const s = options.magicAffixes.suffix
|
|
suffix = {
|
|
id: s.affix.name,
|
|
name: s.name,
|
|
kind: 'suffix' as const,
|
|
level: s.level,
|
|
itemTypes: s.affix.itypes,
|
|
modifiers: s.mods.map(m => ({ stat: m.code, min: m.min, max: m.max })),
|
|
}
|
|
for (const mod of s.mods) {
|
|
stats[mod.code] = (stats[mod.code] ?? 0) + mod.value
|
|
}
|
|
}
|
|
} else if (qualityTier === 'superior') {
|
|
name = `Superior ${base.name}`
|
|
} else if (qualityTier === 'low') {
|
|
name = `Cracked ${base.name}`
|
|
}
|
|
|
|
const baseDurability = 20
|
|
const mult = options.durabilityMultiplier ?? 1
|
|
const maxDurability = Math.round(baseDurability * mult)
|
|
|
|
return {
|
|
base,
|
|
prefix,
|
|
suffix,
|
|
level: options.ilvl,
|
|
ilvl: options.ilvl,
|
|
name,
|
|
stats,
|
|
invWidth: base.invWidth,
|
|
invHeight: base.invHeight,
|
|
stack: 1,
|
|
value: base.value,
|
|
rawFlags,
|
|
flags: decodeItemFlags(rawFlags),
|
|
version: 0x65,
|
|
code: formatItemCode(base.id),
|
|
quality,
|
|
rarity: qualityTier,
|
|
uniqueId: options.dwInitSeed,
|
|
durability: maxDurability,
|
|
maxDurability,
|
|
...(options.uniqueItem ? { uniqueId: options.dwInitSeed, uniqueItemDef: options.uniqueItem } : {}),
|
|
...(options.setItem ? { setId: options.setItem.id, setItemDef: options.setItem } : {}),
|
|
...(options.rareAffixes ? { rolledRareAffixes: options.rareAffixes } : {}),
|
|
...(options.magicAffixes ? { rolledMagicAffixes: options.magicAffixes } : {}),
|
|
} as Item
|
|
}
|
|
|
|
/**
|
|
* Executes the full TreasureClass drop pipeline for a slain monster or container.
|
|
*
|
|
* @param options - Drop pipeline parameters.
|
|
* @returns Array of dropped Item instances (including gold items).
|
|
*/
|
|
export function executeDropPipeline(
|
|
tablesOrOptions: DropTables | DropPipelineOptions,
|
|
maybeOptions?: Omit<DropPipelineOptions, 'dropTables'>,
|
|
): Item[] {
|
|
const options: DropPipelineOptions =
|
|
'tcTable' in tablesOrOptions
|
|
? { ...maybeOptions!, dropTables: tablesOrOptions }
|
|
: tablesOrOptions
|
|
const {
|
|
tcName,
|
|
nLevel,
|
|
monsterType = 1,
|
|
dropTables,
|
|
difficulty = 'normal',
|
|
playerMf = 0,
|
|
gamePlayers = 1,
|
|
partyPlayers = 1,
|
|
isExpansion = true,
|
|
} = options
|
|
|
|
if (!tcName || tcName.trim() === '') {
|
|
return []
|
|
}
|
|
|
|
// 1. Top-level TC group upgrade (D2Game!6FC32D60)
|
|
const resolvedNode = resolveTreasureClassGroup(dropTables.tcTable, tcName, nLevel)
|
|
const effectiveTcName = resolvedNode?.name ?? tcName
|
|
|
|
// 2. Setup RNG
|
|
let rng: D2Rng
|
|
if (options.monsterRng instanceof D2Rng) {
|
|
rng = options.monsterRng
|
|
} else if (options.monsterRng instanceof Rng) {
|
|
rng = new D2Rng(options.monsterRng.int(0, 0x7FFFFFFF))
|
|
} else if (typeof options.monsterRng === 'number') {
|
|
rng = new D2Rng(options.monsterRng >>> 0)
|
|
} else {
|
|
rng = new D2Rng((Math.random() * 0xFFFFFFFF) >>> 0, (Math.random() * 0xFFFFFFFF) >>> 0)
|
|
}
|
|
|
|
// 3. Expand TC tree
|
|
const dropLeaves: DroppedItemEntry[] = expandTreasureClass(effectiveTcName, {
|
|
rng,
|
|
tcTable: dropTables.tcTable,
|
|
autoTcTable: dropTables.autoTcTable,
|
|
gamePlayers,
|
|
partyPlayers,
|
|
isExpansion,
|
|
})
|
|
|
|
// 4. Resolve difficulty odds for base upgrades
|
|
const diffKey = difficulty === 'hell' ? 'Hell' : difficulty === 'nightmare' ? 'Nightmare' : 'Normal'
|
|
const odds = dropTables.difficultyLevels.get(diffKey) ?? DEFAULT_DIFFICULTY_LEVEL_ODDS[diffKey]!
|
|
const isGood = monsterType > 1
|
|
const qf4 = isGood ? odds.uberCodeOddsGood : odds.uberCodeOddsNormal
|
|
const qf5 = isGood ? odds.ultraCodeOddsGood : odds.ultraCodeOddsNormal
|
|
|
|
const drops: Item[] = []
|
|
|
|
// 5. Process each dropped leaf
|
|
for (const leaf of dropLeaves) {
|
|
if (leaf.isGold) {
|
|
const multiplier = leaf.goldMultiplier ?? 1
|
|
const baseGold = rng.randRange(Math.max(1, Math.trunc(nLevel / 2)), Math.max(5, nLevel * 5))
|
|
const goldAmount = Math.max(1, baseGold * multiplier)
|
|
drops.push(goldItem(goldAmount))
|
|
continue
|
|
}
|
|
|
|
const base = dropTables.getBase(leaf.code)
|
|
if (!base) {
|
|
continue
|
|
}
|
|
|
|
// Base upgrade
|
|
const upgradeResult = upgradeItemBase(base, qf4, qf5, rng, dropTables.getBase)
|
|
const effectiveBase = upgradeResult.base
|
|
const isUber = upgradeResult.isUber
|
|
|
|
// Initialize item PRNG
|
|
const dwInitSeed = (rng.rand(0xFFFFFFFF) >>> 0)
|
|
const itemRng = new D2Rng(dwInitSeed)
|
|
|
|
// Check if the item can roll magical qualities (Weapons, Armor, Rings, Amulets, Jewels, Charms)
|
|
const canHaveQuality =
|
|
effectiveBase.kind === 'weapon' ||
|
|
effectiveBase.kind === 'armor' ||
|
|
effectiveBase.id === 'rin' ||
|
|
effectiveBase.id === 'amu' ||
|
|
effectiveBase.id === 'jew' ||
|
|
effectiveBase.id === 'cm1' ||
|
|
effectiveBase.id === 'cm2' ||
|
|
effectiveBase.id === 'cm3'
|
|
|
|
// Roll quality
|
|
let quality: ItemQualityTier
|
|
if (!canHaveQuality) {
|
|
quality = 'normal'
|
|
} else if (leaf.forcedUnique) {
|
|
quality = 'unique'
|
|
} else if (leaf.forcedSet) {
|
|
quality = 'set'
|
|
} else {
|
|
quality = rollItemQuality({
|
|
ilvl: nLevel,
|
|
qlvl: effectiveBase.level ?? 1,
|
|
magicFind: playerMf,
|
|
tcFactors: {
|
|
unique: leaf.qualityFactors[0],
|
|
set: leaf.qualityFactors[1],
|
|
rare: leaf.qualityFactors[2],
|
|
magic: leaf.qualityFactors[3],
|
|
},
|
|
table: dropTables.itemRatio,
|
|
rng: itemRng,
|
|
uber: isUber ? 1 : 0,
|
|
classSpecific: 0,
|
|
})
|
|
}
|
|
|
|
let durabilityMultiplier = 1
|
|
|
|
// Quality branches & fallbacks
|
|
if (quality === 'unique') {
|
|
const uniqueCandidates = dropTables.uniques.getEnabledByCode(effectiveBase.id).filter(u => u.lvl <= nLevel)
|
|
if (uniqueCandidates.length > 0) {
|
|
let chosenUnique = uniqueCandidates[0]!
|
|
if (uniqueCandidates.length > 1) {
|
|
const totalRarity = uniqueCandidates.reduce((sum, u) => sum + Math.max(1, u.rarity), 0)
|
|
let roll = itemRng.rand(totalRarity)
|
|
for (const u of uniqueCandidates) {
|
|
const weight = Math.max(1, u.rarity)
|
|
if (roll < weight) {
|
|
chosenUnique = u
|
|
break
|
|
}
|
|
roll -= weight
|
|
}
|
|
}
|
|
drops.push(createDroppedItem(effectiveBase, 'unique', {
|
|
uniqueItem: chosenUnique,
|
|
ilvl: nLevel,
|
|
dwInitSeed,
|
|
}))
|
|
continue
|
|
}
|
|
// Degrade to Rare + 3x durability
|
|
quality = 'rare'
|
|
durabilityMultiplier = 3
|
|
}
|
|
|
|
if (quality === 'set') {
|
|
const setCandidates = dropTables.sets.items.entries.filter(
|
|
s => s.item.toLowerCase() === effectiveBase.id.toLowerCase() && s.lvl <= nLevel,
|
|
)
|
|
if (setCandidates.length > 0) {
|
|
let chosenSet = setCandidates[0]!
|
|
if (setCandidates.length > 1) {
|
|
const totalRarity = setCandidates.reduce((sum, s) => sum + Math.max(1, s.rarity), 0)
|
|
let roll = itemRng.rand(totalRarity)
|
|
for (const s of setCandidates) {
|
|
const weight = Math.max(1, s.rarity)
|
|
if (roll < weight) {
|
|
chosenSet = s
|
|
break
|
|
}
|
|
roll -= weight
|
|
}
|
|
}
|
|
drops.push(createDroppedItem(effectiveBase, 'set', {
|
|
setItem: chosenSet,
|
|
ilvl: nLevel,
|
|
dwInitSeed,
|
|
}))
|
|
continue
|
|
}
|
|
// Degrade to Magic + 2x durability
|
|
quality = 'magic'
|
|
durabilityMultiplier = 2
|
|
}
|
|
|
|
if (quality === 'rare') {
|
|
const rareAffixes = rollRareAffixes(
|
|
effectiveBase,
|
|
nLevel,
|
|
dropTables.magicAffixes,
|
|
dropTables.rareNames,
|
|
dropTables.isA,
|
|
itemRng,
|
|
)
|
|
drops.push(createDroppedItem(effectiveBase, 'rare', {
|
|
rareAffixes,
|
|
ilvl: nLevel,
|
|
dwInitSeed,
|
|
durabilityMultiplier,
|
|
}))
|
|
continue
|
|
}
|
|
|
|
if (quality === 'magic') {
|
|
const magicAffixes = rollMagicAffixes(
|
|
effectiveBase,
|
|
nLevel,
|
|
dropTables.magicAffixes,
|
|
dropTables.isA,
|
|
itemRng,
|
|
)
|
|
drops.push(createDroppedItem(effectiveBase, 'magic', {
|
|
magicAffixes,
|
|
ilvl: nLevel,
|
|
dwInitSeed,
|
|
durabilityMultiplier,
|
|
}))
|
|
continue
|
|
}
|
|
|
|
if (quality === 'superior') {
|
|
drops.push(createDroppedItem(effectiveBase, 'superior', {
|
|
ilvl: nLevel,
|
|
dwInitSeed,
|
|
}))
|
|
continue
|
|
}
|
|
|
|
if (quality === 'low') {
|
|
drops.push(createDroppedItem(effectiveBase, 'low', {
|
|
ilvl: nLevel,
|
|
dwInitSeed,
|
|
}))
|
|
continue
|
|
}
|
|
|
|
// Default: normal quality
|
|
drops.push(createDroppedItem(effectiveBase, 'normal', {
|
|
ilvl: nLevel,
|
|
dwInitSeed,
|
|
}))
|
|
}
|
|
|
|
return drops
|
|
}
|