forked from phenom/healer-man
159 lines
5.2 KiB
TypeScript
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);
|
|
}
|