forked from phenom/healer-man
Release Healer Man 0.1.21
This commit is contained in:
1 parent
699fc858c9
commit
f6f2375145
210 files changed
+976039
-6247
No files matched your search
@@ -0,0 +1,146 @@
|
||||
import generatedCatalog from "./generated/runewakerLootCatalog.json";
|
||||
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 drops;
|
||||
}
|
||||
Reference in new issue
Block a user