import generatedCatalog from "./generated/runewakerLootCatalog.json"; import { allowedLootDrops } from "./lootPolicy"; import { EMPTY_ITEM_STATS } from "./itemStats"; import type { InventoryItem } from "./lootTypes"; export interface RunewakerLootOutcome { readonly objectId: number; readonly name: string; readonly sourceCatalog: string | null; readonly sourceObjectType?: number; readonly runtimeStatus: "executable-generic-item" | "blocked"; readonly blockedReason: string | null; readonly conditionalChance: number; readonly quantity: number; readonly sourcePath: readonly number[]; readonly minimumLevelDelta?: number; readonly luaCheck?: string; } export interface RunewakerLootRoll { readonly sourceSlot: number; readonly sourceObjectId: number; readonly sourceRatePer100000: number; readonly chancePerKill: number; readonly outcomes: readonly RunewakerLootOutcome[]; } export interface RunewakerBossLootSource { readonly bossKey: string; readonly dungeonId: string; readonly sourceNpcId: number; readonly bossName: string; readonly sourceLevel: number; readonly status: "portable" | "partial" | "no-source-drops" | "blocked"; readonly blockedReasons: readonly string[]; readonly rolls: readonly RunewakerLootRoll[]; readonly executableOutcomeCount?: number; readonly blockedOutcomeCount?: number; } export interface RunewakerLootCatalog { readonly schemaVersion: 1; readonly bossCount: number; readonly bosses: readonly RunewakerBossLootSource[]; } export interface RunewakerLootRollOptions { readonly random?: () => number; readonly now?: number; /** Source DLv: defeated NPC level minus the highest contributing player level. */ readonly levelDelta?: number; /** HealerMan gameplay modifier: one extra authored slot roll may be attempted. */ readonly bonusItemChance?: number; } export const RUNEWAKER_LOOT_CATALOG = generatedCatalog as RunewakerLootCatalog; const bossesByKey = new Map( RUNEWAKER_LOOT_CATALOG.bosses.map((boss) => [boss.bossKey, boss]), ); let itemInstanceSequence = 0; function boundedRandom(random: () => number): number { const value = random(); if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(1 - Number.EPSILON, value)); } export function runewakerBossLootSource(bossKey: string): RunewakerBossLootSource | null { return bossesByKey.get(bossKey) ?? null; } export function runewakerBossHasLoot(bossKey: string): boolean { return (bossesByKey.get(bossKey)?.executableOutcomeCount ?? 0) > 0; } function selectedOutcome( roll: RunewakerLootRoll, random: () => number, ): RunewakerLootOutcome | null { let cursor = boundedRandom(random); for (const outcome of roll.outcomes) { cursor -= Math.max(0, Math.min(1, outcome.conditionalChance)); if (cursor < 0) return outcome; } return null; } function rollSourceSlot( boss: RunewakerBossLootSource, roll: RunewakerLootRoll, playerLevel: number, levelDelta: number, acquiredAt: number, random: () => number, ): InventoryItem | null { if (boundedRandom(random) >= Math.max(0, Math.min(1, roll.chancePerKill))) return null; const outcome = selectedOutcome(roll, random); if (!outcome || outcome.runtimeStatus !== "executable-generic-item") return null; if (levelDelta < (outcome.minimumLevelDelta ?? Number.NEGATIVE_INFINITY)) return null; const itemLevel = Math.max(1, Math.min(255, Math.trunc(playerLevel))); itemInstanceSequence += 1; return Object.freeze({ instanceId: `${boss.bossKey}:rw:${acquiredAt}:${itemInstanceSequence}`, itemId: outcome.objectId, itemKey: `runewaker-${outcome.objectId}`, name: outcome.name, quality: "common", inventorySlot: "misc", armorType: null, weaponType: null, itemLevel, sourceItemLevel: itemLevel, stats: EMPTY_ITEM_STATS, quantity: Math.max(1, Math.trunc(outcome.quantity)), sourceBossKey: boss.bossKey, sourceBossName: boss.bossName, acquiredAt, }); } /** * Executes the imported independent NPC drop slots and mutually exclusive * treasure outcomes. Unknown leaves and Lua-gated tables remain no-drop paths. */ export function rollRunewakerBossLoot( bossKey: string, playerLevel: number, options: RunewakerLootRollOptions = {}, ): readonly InventoryItem[] { const boss = bossesByKey.get(bossKey); if (!boss) return []; const random = options.random ?? Math.random; const acquiredAt = options.now ?? Date.now(); const levelDelta = Math.trunc(options.levelDelta ?? 0); const drops = boss.rolls.flatMap((roll) => { const item = rollSourceSlot(boss, roll, playerLevel, levelDelta, acquiredAt, random); return item ? [item] : []; }); const bonusChance = Math.max(0, Math.min(1, options.bonusItemChance ?? 0)); if (boss.rolls.length && bonusChance > 0 && boundedRandom(random) < bonusChance) { const bonusRoll = boss.rolls[Math.floor(boundedRandom(random) * boss.rolls.length)]; const item = rollSourceSlot(boss, bonusRoll, playerLevel, levelDelta, acquiredAt, random); if (item) drops.push(item); } return allowedLootDrops(drops, playerLevel); }