forked from phenom/healer-man
152 lines
5.3 KiB
TypeScript
152 lines
5.3 KiB
TypeScript
import generatedCatalog from "./generated/runewakerLootCatalog.json";
|
|
import type { ContentCategoryId } from "../app/contentCategories";
|
|
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;
|
|
/** Saved character/party category; dungeon category is not used as owner identity. */
|
|
readonly ownerCategory?: ContentCategoryId;
|
|
}
|
|
|
|
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({
|
|
contentCategory: "rom",
|
|
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, options.ownerCategory);
|
|
}
|