Files
healer-man/src/game/runewakerLootCatalog.ts
T

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);
}