osrs-tools
Version:
A comprehensive TypeScript library for Old School RuneScape (OSRS) data and utilities, including quest data, skill requirements, and game item information
443 lines (442 loc) • 16 kB
JavaScript
/**
* Helper class for OSRS Clue Scrolls
* Handles casket opening simulation with accurate wiki-based reward odds
*
* WIKI REFERENCE: https://oldschool.runescape.wiki/w/Clue_scrolls
* Each tier has unique mechanics documented in the reward casket pages
*/
import { getClueRewardsByTier, getClueRewardTables } from "./ClueScrollRewards";
const ELITE_MIMIC_BASE_CHANCE = 1 / 35;
//======================================================================================
// CORE UTILITY METHODS - Wiki Rarity Driven Selection
//======================================================================================
function cloneItemWithQuantity(item, quantity) {
const cloned = Object.assign(Object.create(Object.getPrototypeOf(item)), item);
cloned.quantity = quantity;
return cloned;
}
function toCanonicalRewardName(rewardKey) {
// Keep canonical suffixes that are real item names, but strip quantity-range descriptors.
if (/\((?:\d|\d+k|\d+-\d+|\d+k-\d+k)/i.test(rewardKey)) {
return rewardKey.replace(/\s*\((?:\d|\d+k|\d+-\d+|\d+k-\d+k)[^)]*\)$/i, "").trim();
}
return rewardKey;
}
function canonicalizeRewardItem(rewardKey, reward) {
const canonicalName = toCanonicalRewardName(rewardKey);
const canonicalized = cloneItemWithQuantity(reward.item, resolveRewardQuantity(reward));
canonicalized.name = canonicalName;
canonicalized.officialWikiUrl = `https://oldschool.runescape.wiki/w/${canonicalName.replace(/ /g, "_")}`;
return canonicalized;
}
function resolveRewardQuantity(reward) {
if (typeof reward.quantity === "number") {
return reward.quantity;
}
if (typeof reward.quantityMin === "number" && typeof reward.quantityMax === "number") {
const min = Math.ceil(reward.quantityMin);
const max = Math.floor(reward.quantityMax);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
return 1;
}
function rollTierReward(tier, excludeMasterClue = true) {
const rewards = getClueRewardsByTier(tier);
const entries = Object.entries(rewards).filter(([name]) => !(excludeMasterClue && name === "Clue scroll (master)"));
if (entries.length === 0) {
throw new Error(`No rewards configured for tier: ${tier}`);
}
const totalWeight = entries.reduce((sum, [, reward]) => sum + 1 / reward.rarity, 0);
const roll = Math.random() * totalWeight;
let cumulative = 0;
for (const [rewardKey, reward] of entries) {
cumulative += 1 / reward.rarity;
if (roll < cumulative) {
return canonicalizeRewardItem(rewardKey, reward);
}
}
const [fallbackKey, fallbackReward] = entries[entries.length - 1];
return canonicalizeRewardItem(fallbackKey, fallbackReward);
}
/**
* Rolls the primary reward for a tier, which may involve weighted table selection if multiple tables exist.
* This handles the multi-table mechanics for beginner clues and any future tiers that may have them.
* @param tier The clue tier to roll a reward for
*
* @returns An Item representing the rolled reward, with canonicalized name and resolved quantity
*/
function rollTierPrimaryReward(tier) {
const tierTables = getClueRewardTables(tier);
if (!tierTables) {
return rollTierReward(tier, true);
}
const primaryTables = tierTables.filter((table) => table.weight > 0);
if (primaryTables.length === 0) {
return rollTierReward(tier, true);
}
const totalWeight = primaryTables.reduce((sum, table) => sum + table.weight, 0);
const tableRoll = Math.random() * totalWeight;
let cumulativeWeight = 0;
for (const table of primaryTables) {
cumulativeWeight += table.weight;
if (tableRoll < cumulativeWeight) {
const itemEntries = Object.entries(table.items);
const itemWeightTotal = itemEntries.reduce((sum, [, reward]) => sum + 1 / reward.rarity, 0);
const itemRoll = Math.random() * itemWeightTotal;
let cumulativeItemWeight = 0;
for (const [rewardKey, reward] of itemEntries) {
cumulativeItemWeight += 1 / reward.rarity;
if (itemRoll < cumulativeItemWeight) {
return canonicalizeRewardItem(rewardKey, reward);
}
}
const [fallbackKey, fallbackReward] = itemEntries[itemEntries.length - 1];
return canonicalizeRewardItem(fallbackKey, fallbackReward);
}
}
return rollTierReward(tier, true);
}
//======================================================================================
// TIER-SPECIFIC REWARD COUNT METHODS
//======================================================================================
/**
* Beginner: 1-3 items, weighting towards 2
* Distribution: 25% = 1, 50% = 2, 25% = 3 (average = 2)
* Wiki: https://oldschool.runescape.wiki/w/Reward_casket_(beginner)
*/
function getBeginnerRewardCount() {
const r = Math.random();
if (r < 0.25)
return 1;
if (r < 0.75)
return 2;
return 3;
}
/**
* Easy: 2-4 items, weighting towards 3
* Distribution: 25% = 2, 50% = 3, 25% = 4 (average = 3)
* Wiki: https://oldschool.runescape.wiki/w/Reward_casket_(easy)
*/
function getEasyRewardCount() {
const r = Math.random();
if (r < 0.25)
return 2;
if (r < 0.75)
return 3;
return 4;
}
/**
* Medium: 3-5 items, uniform distribution
* Distribution: 33.3% each (average = 4)
* Wiki: https://oldschool.runescape.wiki/w/Reward_casket_(medium)
*/
function getMediumRewardCount() {
const r = Math.random();
if (r < 0.333333)
return 3;
if (r < 0.666666)
return 4;
return 5;
}
/**
* Hard: 4-6 items, weighting towards 5
* Distribution: 25% = 4, 50% = 5, 25% = 6 (average = 5)
* Wiki: https://oldschool.runescape.wiki/w/Reward_casket_(hard)
*/
function getHardRewardCount() {
const r = Math.random();
if (r < 0.25)
return 4;
if (r < 0.75)
return 5;
return 6;
}
/**
* Elite: 4-6 items, weighting towards 5
* Distribution: 25% = 4, 50% = 5, 25% = 6 (average = 5)
* NOTE: Master clue is separate (1/5 chance, doesn't consume a slot)
* Wiki: https://oldschool.runescape.wiki/w/Reward_casket_(elite)
*/
function getEliteRewardCount() {
const r = Math.random();
if (r < 0.25)
return 4;
if (r < 0.75)
return 5;
return 6;
}
/**
* Master: 5-7 items, weighting towards 6
* Distribution: 20% = 5, 60% = 6, 20% = 7 (average = 6)
* Wiki: https://oldschool.runescape.wiki/w/Reward_casket_(master)
*/
function getMasterRewardCount() {
const r = Math.random();
if (r < 0.2)
return 5;
if (r < 0.8)
return 6;
return 7;
}
//======================================================================================
// TIER-SPECIFIC OPENING MECHANICS
//======================================================================================
/**
* Beginner casket opening with special unique/cabbage mechanics
*
* WIKI MECHANICS:
* - Unique/Cabbage roll: 1/12 (41/492 weight)
* - When hitting this table: 50% Cabbage, 50% Specific unique item
* - This is handled in the reward data structure
* - Black items table: 11/492 weight, then 1/18 for each item
* - Common items: 440/492 weight
*
* The key insight is that the weighted table selection naturally handles these
* mechanics since the reward data is properly structured.
*
* Wiki: https://oldschool.runescape.wiki/w/Reward_casket_(beginner)
*/
function openBeginnerCasket() {
const rewardCount = getBeginnerRewardCount();
const rewards = [];
for (let i = 0; i < rewardCount; i++) {
rewards.push(rollTierPrimaryReward("beginner"));
}
return { items: rewards, count: rewardCount };
}
/**
* Easy casket opening with master clue special mechanic
*
* WIKI MECHANICS:
* - Main rewards: 2-4 items per casket roll
* - Master clue scroll: SEPARATE 1/50 chance per casket opening
* - Not part of the main reward slots
* - Is an additional reward if rolled successfully
* - Unique items: High probability tier (~22.9%)
* - Standard items: Common weapons, armor, runes, food
*
* Wiki: https://oldschool.runescape.wiki/w/Reward_casket_(easy)
*/
function openEasyCasket() {
const rewardCount = getEasyRewardCount();
const rewards = [];
for (let i = 0; i < rewardCount; i++) {
rewards.push(rollTierPrimaryReward("easy"));
}
const easyRewards = getClueRewardsByTier("easy");
let masterClue;
if (Math.random() < 1 / 50 && easyRewards["Clue scroll (master)"]) {
masterClue = cloneItemWithQuantity(easyRewards["Clue scroll (master)"].item, resolveRewardQuantity(easyRewards["Clue scroll (master)"]));
}
const result = { items: rewards, count: rewardCount };
if (masterClue) {
result.masterClue = masterClue;
}
return result;
}
/**
* Medium casket opening
* Standard weighted table selection with no special mechanics
*
* Wiki: https://oldschool.runescape.wiki/w/Reward_casket_(medium)
*/
function openMediumCasket() {
const rewardCount = getMediumRewardCount();
const rewards = [];
for (let i = 0; i < rewardCount; i++) {
rewards.push(rollTierPrimaryReward("medium"));
}
const mediumRewards = getClueRewardsByTier("medium");
let masterClue;
if (Math.random() < 1 / 30 && mediumRewards["Clue scroll (master)"]) {
masterClue = cloneItemWithQuantity(mediumRewards["Clue scroll (master)"].item, resolveRewardQuantity(mediumRewards["Clue scroll (master)"]));
}
const result = { items: rewards, count: rewardCount };
if (masterClue) {
result.masterClue = masterClue;
}
return result;
}
/**
* Hard casket opening
* Standard weighted table selection with no special mechanics
*
* Wiki: https://oldschool.runescape.wiki/w/Reward_casket_(hard)
*/
function openHardCasket() {
const rewardCount = getHardRewardCount();
const rewards = [];
for (let i = 0; i < rewardCount; i++) {
rewards.push(rollTierPrimaryReward("hard"));
}
const hardRewards = getClueRewardsByTier("hard");
let masterClue;
if (Math.random() < 1 / 15 && hardRewards["Clue scroll (master)"]) {
masterClue = cloneItemWithQuantity(hardRewards["Clue scroll (master)"].item, resolveRewardQuantity(hardRewards["Clue scroll (master)"]));
}
const result = { items: rewards, count: rewardCount };
if (masterClue) {
result.masterClue = masterClue;
}
return result;
}
/**
* Elite casket opening with master clue special mechanic
*
* WIKI MECHANICS:
* - Main rewards: 4-6 items per casket
* - Master clue scroll: 1/5 chance (20%), DOES NOT consume a reward slot
* - This is an additional reward beyond the main reward count
* - If obtained, casket total rewards = rewardCount + 1
*
* Wiki: https://oldschool.runescape.wiki/w/Reward_casket_(elite)
*/
function openEliteCasket() {
const rewardCount = getEliteRewardCount();
const rewards = [];
for (let i = 0; i < rewardCount; i++) {
rewards.push(rollTierPrimaryReward("elite"));
}
const mimicTriggered = Math.random() < ELITE_MIMIC_BASE_CHANCE;
const eliteRewards = getClueRewardsByTier("elite");
if (Math.random() < 1 / 5 && eliteRewards["Clue scroll (master)"]) {
rewards.push(cloneItemWithQuantity(eliteRewards["Clue scroll (master)"].item, resolveRewardQuantity(eliteRewards["Clue scroll (master)"])));
}
const result = {
items: rewards,
count: rewardCount,
mimicTriggered,
};
return result;
}
/**
* Master casket opening
* Standard weighted table selection with no special mechanics
*
* Wiki: https://oldschool.runescape.wiki/w/Reward_casket_(master)
*/
function openMasterCasket() {
const rewardCount = getMasterRewardCount();
const rewards = [];
for (let i = 0; i < rewardCount; i++) {
rewards.push(rollTierPrimaryReward("master"));
}
const mimicTriggered = Math.random() < 1 / 15;
if (!mimicTriggered) {
return { items: rewards, count: rewardCount };
}
const mimicBonusItem = rollTierPrimaryReward("master");
rewards.push(mimicBonusItem);
return {
items: rewards,
count: rewardCount,
mimicTriggered: true,
mimicBonusItem,
};
}
//======================================================================================
// PUBLIC API
//======================================================================================
/**
* Main ClueScrollHelper class that provides methods to simulate clue scroll rewards
*/
export class ClueScrollHelper {
/**
* Resets internal simulation state.
*/
static resetSimulationState() {
// No persistent per-tier state is used in wiki-accurate roll mode.
}
/**
* Simulate opening a clue casket and return all rewards
*
* Uses tier-specific mechanics per OSRS Wiki documented behavior:
* - Beginner: 1-3 rewards with unique/cabbage sub-mechanic
* - Easy: 2-4 rewards + 1/50 master clue
* - Medium: 3-5 rewards (standard)
* - Hard: 4-6 rewards (standard)
* - Elite: 4-6 rewards + 1/5 master clue (bonus, doesn't consume slot)
* - Master: 5-7 rewards (standard)
*
* @param tier The difficulty tier of the clue scroll
* @returns CasketReward containing all items, count, and optional master clue
*/
static openCasket(tier) {
switch (tier.toLowerCase()) {
case "beginner":
return openBeginnerCasket();
case "easy":
return openEasyCasket();
case "medium":
return openMediumCasket();
case "hard":
return openHardCasket();
case "elite":
return openEliteCasket();
case "master":
return openMasterCasket();
default:
throw new Error(`Unknown clue tier: ${tier}`);
}
}
/**
* Get the probability of obtaining a specific item from a casket as a fraction
* @param tier The difficulty tier
* @param itemName The item name to check probability for
* @returns The probability as a fraction (e.g., 0.0278 for 1/36)
*/
static getItemProbability(tier, itemName) {
const rewards = getClueRewardsByTier(tier);
const reward = rewards[itemName];
return reward ? 1 / reward.rarity : 0;
}
/**
* Get the rarity denominator (X in "1 in X") for an item
*/
static getItemRarity(tier, itemName) {
const rewards = getClueRewardsByTier(tier);
const reward = rewards[itemName];
return reward ? reward.rarity : 0;
}
/**
* Get all possible rewards for a tier as Item objects
*/
static getPossibleRewards(tier) {
const rewards = getClueRewardsByTier(tier);
return Object.values(rewards).map((r) => r.item);
}
/**
* Get all possible reward items names for a tier
*/
static getPossibleRewardNames(tier) {
const rewards = getClueRewardsByTier(tier);
return Object.keys(rewards);
}
/**
* Simulate opening multiple caskets
* @param tier The clue tier
* @param count Number of caskets to open
* @returns Array of all reward items
*/
static simulateMultiple(tier, count) {
const rewards = [];
for (let i = 0; i < count; i++) {
const casketReward = this.openCasket(tier);
rewards.push(...casketReward.items);
}
return rewards;
}
/**
* Get reward statistics for a tier
*/
static getRewardStats(tier) {
const rewards = getClueRewardsByTier(tier);
const items = Object.values(rewards);
return {
tier,
totalUnique: items.length,
rareItems: items.filter((r) => r.rarity > 100).length,
commonItems: items.filter((r) => r.rarity <= 50).length,
};
}
}