import generatedLootCatalog from "./generated/lootCatalog.json"; import type { ContentCategoryId } from "../app/contentCategories"; import { itemStatsFor, type SourceItemStats, type WeaponStats, } from "./itemStats"; import type { InventoryItem, ItemQuality } from "./lootTypes"; import { rollRunewakerBossLoot, runewakerBossHasLoot } from "./runewakerLootCatalog"; import { allowedLootDrops } from "./lootPolicy"; export interface LootCatalogItem { readonly itemId: number; readonly itemKey: string; readonly itemName: string; readonly quality: ItemQuality; readonly inventorySlot: string; readonly armorType: string | null; readonly weaponType: string | null; readonly sourceItemLevel: number; readonly scalable: boolean; readonly weight: number; readonly minQuantity: number; readonly maxQuantity: number; readonly sourceUrl: string | null; readonly sourceStats: SourceItemStats; readonly sourceWeapon: WeaponStats | null; } export interface BossLootTable { readonly bossKey: string; readonly creatureEntry: number; readonly bossName: string; readonly dungeonId: string; readonly lootTableId: string; readonly rollsMin: number; readonly rollsMax: number; readonly items: readonly LootCatalogItem[]; } export interface LootCatalog { readonly schemaVersion: 2; readonly generatedFrom: readonly string[]; readonly bosses: readonly BossLootTable[]; } export interface LootRollOptions { readonly random?: () => number; readonly now?: number; /** A single additional weighted item roll after the authored boss rolls. */ readonly bonusItemChance?: number; /** Saved character/party category; source identity is never inferred from dungeon. */ readonly ownerCategory?: ContentCategoryId; } export const LOOT_CATALOG = generatedLootCatalog as LootCatalog; const bossLootByKey = new Map(LOOT_CATALOG.bosses.map((boss) => [boss.bossKey, boss])); let itemInstanceSequence = 0; export function bossLootTable(bossKey: string): BossLootTable | null { return bossLootByKey.get(bossKey) ?? null; } export function bossHasLoot(bossKey: string): boolean { return (bossLootByKey.get(bossKey)?.items.length ?? 0) > 0 || runewakerBossHasLoot(bossKey); } function boundedRandom(random: () => number): number { const value = random(); if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(0.999999999999, value)); } function randomInteger(minimum: number, maximum: number, random: () => number): number { if (maximum <= minimum) return minimum; return minimum + Math.floor(boundedRandom(random) * (maximum - minimum + 1)); } function chooseWeightedItem(items: readonly LootCatalogItem[], random: () => number): LootCatalogItem | null { const totalWeight = items.reduce((total, item) => total + Math.max(0, item.weight), 0); if (totalWeight <= 0) return null; let cursor = boundedRandom(random) * totalWeight; for (const item of items) { cursor -= Math.max(0, item.weight); if (cursor < 0) return item; } return items.at(-1) ?? null; } export function scaledLootItemLevel(playerLevel: number): number { const level = Math.max(1, Math.min(80, Math.trunc(Number.isFinite(playerLevel) ? playerLevel : 1))); return level >= 80 ? 85 : level; } /** * Rolls a SQL-authored boss table and creates concrete inventory items. * Item level is captured now, before any XP from the killing blow changes it. */ export function rollBossLoot( bossKey: string, playerLevel: number, options: LootRollOptions = {}, ): readonly InventoryItem[] { const table = bossLootTable(bossKey); if (!table?.items.length) { return rollRunewakerBossLoot(bossKey, playerLevel, options); } if (options.ownerCategory !== undefined && options.ownerCategory !== "wow") return []; const random = options.random ?? Math.random; const acquiredAt = options.now ?? Date.now(); const rolls = randomInteger(table.rollsMin, table.rollsMax, random); const itemLevel = scaledLootItemLevel(playerLevel); const drops: InventoryItem[] = []; const addWeightedDrop = () => { const template = chooseWeightedItem(table.items, random); if (!template) return; const quantity = randomInteger(template.minQuantity, template.maxQuantity, random); itemInstanceSequence += 1; const scaledItemLevel = template.scalable ? itemLevel : template.sourceItemLevel; drops.push({ contentCategory: "wow", instanceId: `${bossKey}:${acquiredAt}:${itemInstanceSequence}`, itemId: template.itemId, itemKey: template.itemKey, name: template.itemName, quality: template.quality, inventorySlot: template.inventorySlot, armorType: template.armorType, weaponType: template.weaponType, itemLevel: scaledItemLevel, sourceItemLevel: template.sourceItemLevel, stats: itemStatsFor({ itemId: template.itemId, quality: template.quality, inventorySlot: template.inventorySlot, armorType: template.armorType, weaponType: template.weaponType, itemLevel: scaledItemLevel, sourceItemLevel: template.sourceItemLevel, sourceStats: template.sourceStats, sourceWeapon: template.sourceWeapon, }), quantity, sourceBossKey: table.bossKey, sourceBossName: table.bossName, acquiredAt, }); }; for (let roll = 0; roll < rolls; roll += 1) { addWeightedDrop(); } const bonusItemChance = Math.max(0, Math.min(1, options.bonusItemChance ?? 0)); if (bonusItemChance > 0 && boundedRandom(random) < bonusItemChance) { addWeightedDrop(); } return allowedLootDrops(drops, playerLevel, options.ownerCategory); }