Files
healer-man/src/game/lootCatalog.ts
T
2026-09-13 13:21:27 -04:00

159 lines
5.2 KiB
TypeScript

import generatedLootCatalog from "./generated/lootCatalog.json";
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;
}
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);
}
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({
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);
}