UNPKG

tiny-essentials

Version:

Collection of small, essential scripts designed to be used across various projects. These simple utilities are crafted for speed, ease of use, and versatility.

1,467 lines (1,321 loc) 56.6 kB
'use strict'; var TinyEvents = require('./TinyEvents.cjs'); /** * Defines the available normalization strategies for probability weight calculations. * @typedef {'relative' | 'softmax'} Normalization */ /** * Generates a pseudo-random number between 0 (inclusive) and 1 (exclusive). * @callback RngGenerator * @returns {number} - A floating-point number in the range [0, 1). */ /** * Represents a temporary weight modifier that is applied for a limited number of draws. * @typedef {Object} TempModifier * @property {WeightsCallback} fn - Function that modifies the item weights. * @property {number} uses - Number of draws this modifier remains active before being removed. */ /** * Represents the core data structure for an item in the raffle system. * @template {Set<string>|string[]} TGroups * @typedef {Object} ItemDataTemplate * @property {string} id - Unique identifier for the item. * @property {string} label - Human-readable name for the item. * @property {number} baseWeight - The base probability weight before modifiers. * @property {TGroups} groups - The groups the item belongs to (Set<string> or string[]). * @property {boolean} locked - Whether the item is currently locked (excluded from draws). * @property {ItemMetadata} meta - Arbitrary metadata associated with the item. */ /** * Represents the serialized state of the raffle system for export or persistence. * @typedef {Object} ExportedJson * @property {ItemDataGetter[]} items - Array of item objects in their exported form. * @property {[string, Pity][]} pity - Array of tuples where the first element is the item ID and the second is its associated Pity system state. * @property {string[]} exclusions - List of item IDs excluded from the draw. * @property {Normalization} normalization - The normalization mode used in weight calculations. * @property {number|null} seed - The RNG seed used for reproducibility, or null if no seed is set. */ /** * A concrete version of ItemDataTemplate where groups is Set<string>. * @typedef {ItemDataTemplate<Set<string>>} ItemData */ /** * A concrete version of ItemDataTemplate where groups is string[]. * @typedef {ItemDataTemplate<string[]>} ItemDataGetter */ /** * Arbitrary key-value metadata container for additional item information. * Keys can be strings, numbers, or symbols. * @typedef {Record<string|number|symbol, any>} ItemMetadata */ /** * Context object passed to weight modification functions during draw calculations. * @typedef {Object} ComputeEffectiveWeightsContext * @property {ItemMetadata} [metadata] - Metadata of the current raffle state or item. * @property {DrawOne[]} [previousDraws] - History of previously drawn items. */ /** * Maps each item ID to its computed effective weight. * @typedef {Map<string, number>} Weights */ /** * Pity system configuration and current state. * @typedef {Object} Pity * @property {number} threshold - Number of draws without a win before pity starts applying. * @property {number} increment - Additional weight applied per pity step. * @property {number} cap - Maximum total pity weight allowed. * @property {number} counter - Current number of consecutive draws without a win. * @property {number} currentAdd - Current pity weight being applied. */ /** * Function used to modify or override computed weights before a draw. * @callback WeightsCallback * @param {Weights} weights - Current computed item weights. * @param {ComputeEffectiveWeightsContext} context - Additional context data for calculation. * @returns {Weights|null} - Modified weights map, or null to skip modification. */ /** * Represents the result of a single draw. * @typedef {Object} DrawOne * @property {string} id - Item ID. * @property {string} label - Human-readable label of the drawn item. * @property {ItemMetadata} meta - Arbitrary metadata of the drawn item. * @property {number} prob - Final probability of the item at draw time. */ /** * Generic event handler function for message or signal reception. * @callback handler * @param {any} payload - The data sent by the emitter. * @param {any} event - Metadata about the emitted event. */ /** * TinyAdvancedRaffle — A high-performance, fully customizable raffle system. * * This class provides advanced item drawing capabilities with: * - Weighted probabilities and adjustable normalization modes (relative, softmax). * - Seedable RNG for reproducible results. * - Pity system support for fairness in repeated draws. * - Temporary and permanent weight modifiers. * - Item exclusions and group-based filtering. * - JSON import/export for persistence and sharing. * - Event-based architecture for monitoring and extending behaviors. * * @class */ class TinyAdvancedRaffle { #events = new TinyEvents(); /** * Emits an event, triggering all registered handlers for that event. * * @param {string|string[]} event - The event name to emit. * @param {...any} payload - Optional data to pass to each handler. * @returns {boolean[]} True if any listeners were called, false otherwise. */ #emit(event, ...payload) { return this.#events.emit(event, ...payload); } /** * Enables or disables throwing an error when the maximum number of listeners is exceeded. * * @param {boolean} shouldThrow - If true, an error will be thrown when the max is exceeded. */ setThrowOnMaxListeners(shouldThrow) { return this.#events.setThrowOnMaxListeners(shouldThrow); } /** * Checks whether an error will be thrown when the max listener limit is exceeded. * * @returns {boolean} True if an error will be thrown, false if only a warning is shown. */ getThrowOnMaxListeners() { return this.#events.getThrowOnMaxListeners(); } ///////////////////////////////////////////////////////////// /** * Adds a listener to the beginning of the listeners array for the specified event. * * @param {string|string[]} event - Event name. * @param {handler} handler - The callback function. */ prependListener(event, handler) { return this.#events.prependListener(event, handler); } /** * Adds a one-time listener to the beginning of the listeners array for the specified event. * * @param {string|string[]} event - Event name. * @param {handler} handler - The callback function. * @returns {handler[]} - The wrapped handler used internally. */ prependListenerOnce(event, handler) { return this.#events.prependListenerOnce(event, handler); } ////////////////////////////////////////////////////////////////////// /** * Adds a event listener. * * @param {string|string[]} event - Event name, such as 'onScrollBoundary' or 'onAutoScroll'. * @param {handler} handler - Callback function to be called when event fires. */ appendListener(event, handler) { return this.#events.appendListener(event, handler); } /** * Registers an event listener that runs only once, then is removed. * * @param {string|string[]} event - Event name, such as 'onScrollBoundary' or 'onAutoScroll'. * @param {handler} handler - The callback function to run on event. * @returns {handler[]} - The wrapped version of the handler. */ appendListenerOnce(event, handler) { return this.#events.appendListenerOnce(event, handler); } /** * Adds a event listener. * * @param {string|string[]} event - Event name, such as 'onScrollBoundary' or 'onAutoScroll'. * @param {handler} handler - Callback function to be called when event fires. */ on(event, handler) { return this.#events.on(event, handler); } /** * Registers an event listener that runs only once, then is removed. * * @param {string|string[]} event - Event name, such as 'onScrollBoundary' or 'onAutoScroll'. * @param {handler} handler - The callback function to run on event. * @returns {handler[]} - The wrapped version of the handler. */ once(event, handler) { return this.#events.once(event, handler); } //////////////////////////////////////////////////////////////////// /** * Removes a previously registered event listener. * * @param {string|string[]} event - The name of the event to remove the handler from. * @param {handler} handler - The specific callback function to remove. */ off(event, handler) { return this.#events.off(event, handler); } /** * Removes all event listeners of a specific type from the element. * * @param {string|string[]} event - The event type to remove (e.g. 'onScrollBoundary'). */ offAll(event) { return this.#events.offAll(event); } /** * Removes all event listeners of all types from the element. */ offAllTypes() { return this.#events.offAllTypes(); } //////////////////////////////////////////////////////////// /** * Returns the number of listeners for a given event. * * @param {string} event - The name of the event. * @returns {number} Number of listeners for the event. */ listenerCount(event) { return this.#events.listenerCount(event); } /** * Returns a copy of the array of listeners for the specified event. * * @param {string} event - The name of the event. * @returns {handler[]} Array of listener functions. */ listeners(event) { return this.#events.listeners(event); } /** * Returns a copy of the array of listeners for the specified event. * * @param {string} event - The name of the event. * @returns {handler[]} Array of listener functions. */ onceListeners(event) { return this.#events.onceListeners(event); } /** * Returns a copy of the internal listeners array for the specified event, * including wrapper functions like those used by `.once()`. * @param {string | symbol} event - The event name. * @returns {handler[]} An array of raw listener functions. */ allListeners(event) { return this.#events.allListeners(event); } /** * Returns an array of event names for which there are registered listeners. * * @returns {string[]} Array of registered event names. */ eventNames() { return this.#events.eventNames(); } ////////////////////////////////////////////////////// /** * Sets the maximum number of listeners per event before a warning is shown. * * @param {number} n - The maximum number of listeners. */ setMaxListeners(n) { return this.#events.setMaxListeners(n); } /** * Gets the maximum number of listeners allowed per event. * * @returns {number} The maximum number of listeners. */ getMaxListeners() { return this.#events.getMaxListeners(); } /////////////////////////////////////////////////// /** * Whether this instance has been destroyed. * @type {boolean} */ #isDestroyed = false; /** * Normalization method used to adjust item weights before performing the draw. * Can define how the probabilities are scaled or balanced. * @type {Normalization} */ #normalization; /** * Seed value used for deterministic random number generation. * If null, results will be non-deterministic. * @type {number|null} */ #seed; /** * Persistent weight modifiers that are applied to every draw. * Each modifier is a callback function that adjusts item weights. * @type {WeightsCallback[]} */ #globalModifiers = []; /** * Temporary weight modifiers that are cleared after use or after a defined number of draws. * Each entry contains a modifier function and a remaining usage counter. * @type {TempModifier[]} */ #temporaryModifiers = []; /** * Conditional rules that dynamically modify item weights based on current state. * @type {WeightsCallback[]} */ #conditionalRules = []; /** * "Pity" systems — mechanisms that guarantee or increase the probability of certain items * after a number of unsuccessful draws. * Keyed by `itemId`. * @type {Map<string, Pity>} */ #pitySystems = new Map(); /** * Items excluded from being selected in the raffle. * Contains a set of item IDs. * @type {Set<string>} */ #exclusions = new Set(); /** * Groups of items, where each group has a name and contains a set of item IDs. * @type {Map<string, Set<string>>} */ #groups = new Map(); /** * Random number generator instance used for draw calculations. * @type {RngGenerator} */ #rng; /** * All registered raffle items and their respective data. * Keyed by `itemId`. * @type {Map<string, ItemData>} */ #items = new Map(); /** * Tracks how many times each item has been drawn in the raffle. * Keys are item IDs, and values represent the frequency count. * @type {Map<string, number>} */ #freq = new Map(); /* -------------------- GETTERS & SETTERS -------------------- */ /** * Indicates whether this instance has been destroyed. * @type {boolean} */ get isDestroyed() { return this.#isDestroyed; } /** * Returns a plain object representation of the draw frequency map. * The keys are item IDs and the values are the number of times each item was drawn. * @returns {Record<string, number>} - Object with item IDs as keys and their respective draw counts. */ get freq() { return Object.fromEntries(this.#freq); } /** * Returns the total number of registered items in the raffle. * @returns {number} Total count of items. */ get size() { return this.#items.size; } /** * Gets the current probability normalization strategy. * @returns {Normalization} Current normalization mode. */ get normalization() { return this.#normalization; } /** * Sets the probability normalization strategy. * @param {Normalization} value - Accepted values: `'relative'` or `'softmax'`. * @throws {TypeError} If value is not a non-empty string. */ set normalization(value) { this._checkDestroyed(); if (typeof value !== 'string' || !value.trim()) { throw new TypeError( "normalization must be a non-empty string (e.g., 'relative', 'softmax').", ); } this.#normalization = value; } /** * Gets the current RNG seed value, if any. * @returns {number|null} Current seed or `null` if RNG is not seeded. */ get seed() { return this.#seed; } /** * Sets the RNG seed for deterministic randomization. * @param {number|null} value - Finite number or `null` to disable seeding. * @throws {TypeError} If not `null` or a finite number. */ set seed(value) { this._checkDestroyed(); if (value !== null && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError('seed must be a finite number or null.'); this.#seed = value; if (value !== null) this.#rng = this._makeSeededRng(value); } /** * Gets all global weight modifier callbacks. * @returns {WeightsCallback[]} Array of registered global modifier functions. */ get globalModifiers() { return [...this.#globalModifiers]; } /** * Replaces all global weight modifier callbacks. * @param {WeightsCallback[]} value - Array of modifier functions. * @throws {TypeError} If not an array of functions. */ set globalModifiers(value) { this._checkDestroyed(); if (!Array.isArray(value) || !value.every((fn) => typeof fn === 'function')) throw new TypeError('globalModifiers must be an array of functions (WeightsCallback).'); this.#globalModifiers = value; } /** * Gets all temporary modifiers with usage counters. * @returns {TempModifier[]} Array of temporary modifier entries. */ get temporaryModifiers() { return [...this.#temporaryModifiers]; } /** * Replaces all temporary modifiers. * @param {TempModifier[]} value - Each object must have a function and a positive integer usage count. * @throws {TypeError} If structure is invalid. */ set temporaryModifiers(value) { this._checkDestroyed(); if ( !Array.isArray(value) || !value.every( (obj) => obj && typeof obj.fn === 'function' && Number.isInteger(obj.uses) && obj.uses > 0, ) ) throw new TypeError( 'temporaryModifiers must be an array of objects { fn: function, uses: positive integer }.', ); this.#temporaryModifiers = value; } /** * Gets all conditional rule callbacks. * @returns {WeightsCallback[]} Array of conditional rule functions. */ get conditionalRules() { return [...this.#conditionalRules]; } /** * Replaces all conditional rule callbacks. * @param {WeightsCallback[]} value - Array of functions. * @throws {TypeError} If not an array of functions. */ set conditionalRules(value) { this._checkDestroyed(); if (!Array.isArray(value) || !value.every((fn) => typeof fn === 'function')) throw new TypeError('conditionalRules must be an array of functions (WeightsCallback).'); this.#conditionalRules = value; } /** * Gets a shallow copy of all configured pity systems. * @returns {Record<string, Pity>} Object keyed by system name. */ get pitySystems() { return Object.fromEntries(this.#pitySystems); } /** * Replaces all pity systems. * @param {Map<string, Pity>} value - Map of pity systems with numeric fields. * @throws {TypeError} If structure is invalid. */ set pitySystems(value) { this._checkDestroyed(); if ( !(value instanceof Map) || ![...value.values()].every( (p) => p && typeof p.threshold === 'number' && typeof p.increment === 'number' && typeof p.cap === 'number' && typeof p.counter === 'number' && typeof p.currentAdd === 'number', ) ) throw new TypeError('pitySystems must be a Map<string, Pity> with all numeric fields.'); this.#pitySystems = value; } /** * Gets a copy of all exclusion IDs. * @returns {string[]} Array of item IDs excluded from draws. */ get exclusions() { return Array.from(this.#exclusions); } /** * Replaces all exclusion IDs. * @param {Set<string>} value - Set of excluded item IDs. * @throws {TypeError} If not a Set of strings. */ set exclusions(value) { this._checkDestroyed(); if (!(value instanceof Set) || ![...value].every((v) => typeof v === 'string')) throw new TypeError('exclusions must be a Set<string>.'); this.#exclusions = value; } /** * Gets all group definitions. * @returns {Record<string, string[]>} Object where each key is a group name and value is an array of item IDs. */ get groups() { /** @type {Record<string, string[]>} */ const groups = {}; this.#groups.forEach((value, key) => (groups[key] = Array.from(value))); return groups; } /** * Replaces all group definitions. * @param {Map<string, Set<string>>} value - Map of group names to item ID sets. * @throws {TypeError} If not a valid Map<string, Set<string>>. */ set groups(value) { this._checkDestroyed(); if ( !(value instanceof Map) || ![...value.values()].every( (v) => v instanceof Set && [...v].every((i) => typeof i === 'string'), ) ) throw new TypeError('groups must be a Map<string, Set<string>>.'); this.#groups = value; } /** * Gets the current RNG function. * @returns {RngGenerator} Function returning a floating-point number in [0, 1). */ get rng() { return this.#rng; } /** * Sets the RNG function. * @param {RngGenerator} value - Function returning a number between 0 and 1. * @throws {TypeError} If not a valid function. */ set rng(value) { this._checkDestroyed(); if (typeof value !== 'function' || typeof value() !== 'number') throw new TypeError('rng must be a function returning a number (RngGenerator).'); this.#rng = value; } /** * Gets all item definitions with group names expanded to arrays. * @returns {Record<string, ItemDataGetter>} Object keyed by item ID. */ get items() { /** @type {Record<string, ItemDataGetter>} */ const items = {}; this.#items.forEach((value, key) => { items[key] = { ...value, groups: Array.from(value.groups) }; }); return items; } /** * Replaces all items (**and CLEAR OLD LIST**). * @param {Map<string, ItemData>} value - Map of item IDs to definitions. * @throws {TypeError} If structure is invalid. */ set items(value) { this._checkDestroyed(); if ( !(value instanceof Map) || ![...value.values()].every( (item) => item && typeof item.id === 'string' && typeof item.label === 'string' && typeof item.baseWeight === 'number' && item.groups instanceof Set && typeof item.locked === 'boolean' && typeof item.meta === 'object', ) ) throw new TypeError('items must be a Map<string, ItemData> with valid item structures.'); this.clearList(); this.#items = value; } /** * Creates a new AdvancedRaffle instance. * @param {Object} [opts] - Optional configuration. * @param {RngGenerator|null} [opts.rng=null] - Custom RNG function. If null, an internal seeded RNG is used when a seed is provided. * @param {number|null} [opts.seed=null] - Optional seed for deterministic results. * @param {Normalization} [opts.normalization='relative'] - Probability normalization mode. */ constructor(opts = {}) { const { rng = null, seed = null, normalization = 'relative' } = opts; this.#normalization = normalization; this.#seed = seed; if (typeof rng === 'function') this.#rng = rng; else if (this.#seed !== null) this.#rng = this._makeSeededRng(this.#seed); else this.#rng = Math.random; this.#seed = seed ?? null; } /** * Checks if the instance has been destroyed and throws an error if so. * @private * @throws {Error} If the instance has already been destroyed. */ _checkDestroyed() { if (this.#isDestroyed) throw new Error('This instance has been destroyed and can no longer be used.'); } /* =========================== Public: Item management =========================== */ /** * Check if an item exists in the system. * @param {string} itemId - Item ID to check. * @returns {boolean} `true` if the item exists, otherwise `false`. * @throws {TypeError} If `itemId` is not a string. */ hasItem(itemId) { if (typeof itemId !== 'string') throw new TypeError('itemId must be a string'); return this.#items.has(itemId); } /** * Add or update an item. * @param {string} id - Unique item identifier. * @param {Object} [opts={}] - Item configuration options. * @param {number} [opts.weight=1] - Base relative weight (must be >= 0). * @param {string} [opts.label] - Human-readable label for the item. * @param {ItemMetadata} [opts.meta={}] - Arbitrary metadata for the item. * @param {string[]|Set<string>} [opts.groups=[]] - Group names to attach. * @returns {ItemData} * @throws {TypeError} If any parameter has an invalid type. */ addItem(id, opts = {}) { this._checkDestroyed(); if (typeof id !== 'string' || !id.trim()) throw new TypeError('id must be a non-empty string'); if (typeof opts !== 'object' || opts === null) throw new TypeError('opts must be an object'); let { weight = 1, label = id, meta = {}, groups = [] } = opts; if (typeof weight !== 'number' || !Number.isFinite(weight) || weight < 0) throw new TypeError('weight must be a non-negative number'); if (typeof label !== 'string') throw new TypeError('label must be a string'); if (typeof meta !== 'object' || meta === null) throw new TypeError('meta must be an object'); // Allow both arrays and sets for groups if (!(Array.isArray(groups) || groups instanceof Set)) throw new TypeError('groups must be an array or a Set of strings'); groups = Array.isArray(groups) ? groups : [...groups]; for (const g of groups) { if (typeof g !== 'string') { throw new TypeError('groups must contain only strings'); } } const entry = { id, label, baseWeight: Math.max(0, Number(weight) || 0), meta: { ...meta }, groups: new Set(groups), locked: false, }; this.#items.set(id, entry); // Register in groups map for (const g of groups) this._ensureGroup(g).add(id); this.#emit('itemAdded', entry); return entry; } /** * Remove an item from the system. * @param {string} id - The unique item identifier to remove. * @returns {boolean} True if the item was removed, false if it did not exist. * @throws {TypeError} If id is not a string. */ removeItem(id) { this._checkDestroyed(); if (typeof id !== 'string' || !id.trim()) throw new TypeError('id must be a non-empty string'); const it = this.#items.get(id); if (!it) return false; for (const g of it.groups) { const s = this.#groups.get(g); if (s) s.delete(id); } this.#items.delete(id); this.#emit('itemRemoved', id); this.resetFreq(id); this.resetPity(id); return true; } /** * Set a new base weight for an item. * @param {string} id - The unique item identifier. * @param {number} weight - The new base weight (must be >= 0). * @throws {Error} If the item is not found. * @throws {TypeError} If weight is invalid. */ setBaseWeight(id, weight) { this._checkDestroyed(); if (typeof id !== 'string' || !id.trim()) throw new TypeError('id must be a non-empty string'); if (typeof weight !== 'number' || !Number.isFinite(weight) || weight < 0) throw new TypeError('weight must be a non-negative number'); const it = this.#items.get(id); if (!it) throw new Error('Item not found'); it.baseWeight = Math.max(0, Number(weight) || 0); this.#emit('weightChanged', { id, weight: it.baseWeight }); } /** * Get an item by its ID. * @param {string} id - The unique item identifier. * @returns {ItemData|null} The item data, or null if not found. * @throws {TypeError} If id is not a string. */ getItem(id) { if (typeof id !== 'string' || !id.trim()) throw new TypeError('id must be a non-empty string'); return this.#items.get(id) ?? null; } /** * List all items as cloned objects. * @returns {ItemData[]} Array of cloned item objects. */ listItems() { return Array.from(this.#items.values()).map((i) => ({ ...i })); } /** * Clear all items from the system. */ clearList() { this._checkDestroyed(); this.#items.clear(); this.clearFreqs(); this.clearPities(); } /* =========================== Modifiers & Rules =========================== */ /** * Check if a persistent global modifier exists. * @param {WeightsCallback} fn - Modifier callback function to check. * @returns {boolean} `true` if the modifier exists, otherwise `false`. * @throws {TypeError} If `fn` is not a function. */ hasGlobalModifier(fn) { if (typeof fn !== 'function') throw new TypeError('fn must be a function'); return this.#globalModifiers.includes(fn); } /** * Add a persistent modifier callback. * The callback receives `(weightsMap, context)` and must return a Map of overrides/additions or modifications. * @param {WeightsCallback} fn - Modifier callback function. * @throws {TypeError} If `fn` is not a function. */ addGlobalModifier(fn) { this._checkDestroyed(); if (typeof fn !== 'function') throw new TypeError('fn must be a function'); this.#globalModifiers.push(fn); } /** * Remove a persistent modifier callback. * @param {WeightsCallback} fn - Modifier callback to remove. * @throws {TypeError} If `fn` is not a function. */ removeGlobalModifier(fn) { this._checkDestroyed(); if (typeof fn !== 'function') throw new TypeError('fn must be a function'); this.#globalModifiers = this.#globalModifiers.filter((x) => x !== fn); } /** * Check if a specific temporary modifier exists. * @param {WeightsCallback} fn - Temporary modifier callback to check. * @returns {boolean} `true` if the modifier exists, otherwise `false`. * @throws {TypeError} If `fn` is not a function. */ hasTemporaryModifier(fn) { if (typeof fn !== 'function') throw new TypeError('fn must be a function'); return this.#temporaryModifiers.some((mod) => mod.fn === fn); } /** * Add a temporary modifier applied to the next `uses` draws (default 1). * The modifier returns the same structure as a global modifier. * @param {WeightsCallback} fn - Temporary modifier callback. * @param {number} [uses=1] - Number of draws the modifier will be active for. * @throws {TypeError} If `fn` is not a function. * @throws {TypeError} If `uses` is not a number. */ addTemporaryModifier(fn, uses = 1) { this._checkDestroyed(); if (typeof fn !== 'function') throw new TypeError('fn must be a function'); if (typeof uses !== 'number' || Number.isNaN(uses)) throw new TypeError('uses must be a number'); this.#temporaryModifiers.push({ fn, uses: Math.max(1, Math.floor(uses)) }); } /** * Remove a specific temporary modifier. * * @param {WeightsCallback} fn - The temporary modifier callback to remove. * @returns {boolean} `true` if a modifier was removed, `false` otherwise. * @throws {TypeError} If `fn` is not a function. */ removeTemporaryModifier(fn) { this._checkDestroyed(); if (typeof fn !== 'function') throw new TypeError('fn must be a function'); const originalLength = this.#temporaryModifiers.length; this.#temporaryModifiers = this.#temporaryModifiers.filter((mod) => mod.fn !== fn); return this.#temporaryModifiers.length !== originalLength; } /** * Check if a specific conditional rule exists. * @param {WeightsCallback} ruleFn - Conditional rule function to check. * @returns {boolean} `true` if the rule exists, otherwise `false`. * @throws {TypeError} If `ruleFn` is not a function. */ hasConditionalRule(ruleFn) { if (typeof ruleFn !== 'function') throw new TypeError('ruleFn must be a function'); return this.#conditionalRules.includes(ruleFn); } /** * Add a conditional rule (applied each draw). * Receives context `{previousDraws, activeModifiers, metadata}`. * Should return a Map of `itemId => deltaWeight` (can be positive or negative). * @param {WeightsCallback} ruleFn - Conditional rule function. * @throws {TypeError} If `ruleFn` is not a function. */ addConditionalRule(ruleFn) { this._checkDestroyed(); if (typeof ruleFn !== 'function') throw new TypeError('ruleFn must be a function'); this.#conditionalRules.push(ruleFn); } /** * Remove a specific conditional rule. * * @param {WeightsCallback} ruleFn - The conditional rule function to remove. * @returns {boolean} `true` if a rule was removed, `false` otherwise. * @throws {TypeError} If `ruleFn` is not a function. */ removeConditionalRule(ruleFn) { this._checkDestroyed(); if (typeof ruleFn !== 'function') throw new TypeError('ruleFn must be a function'); const originalLength = this.#conditionalRules.length; this.#conditionalRules = this.#conditionalRules.filter((fn) => fn !== ruleFn); return this.#conditionalRules.length !== originalLength; } /* =========================== Pity systems =========================== */ /** * Check if a pity configuration exists for a given item. * @param {string} itemId - Item ID to check. * @returns {boolean} `true` if pity is configured, otherwise `false`. * @throws {TypeError} If `itemId` is not a string. */ hasPity(itemId) { if (typeof itemId !== 'string') throw new TypeError('itemId must be a string'); return this.#pitySystems.has(itemId); } /** * Configure pity for an item. * If the item fails to appear for `threshold` draws, add `increment` to its base weight each draw until `cap` is reached. * @param {string} itemId - ID of the item to configure. * @param {Object} cfg - Pity configuration. * @param {number} cfg.threshold - Number of failed draws before applying pity. * @param {number} cfg.increment - Additional weight to add each draw after threshold. * @param {number} [cfg.cap=Infinity] - Maximum additional weight allowed. * @throws {Error} If the item does not exist. * @throws {TypeError} If parameters are invalid. */ configurePity(itemId, cfg) { this._checkDestroyed(); if (!this.#items.has(itemId)) throw new Error('Item not found'); if (typeof cfg !== 'object' || cfg === null) throw new TypeError('cfg must be an object'); const { threshold, increment, cap = Infinity } = cfg; if (typeof threshold !== 'number' || threshold <= 0) throw new TypeError('threshold must be a positive number'); if (typeof increment !== 'number') throw new TypeError('increment must be a number'); if (typeof cap !== 'number') throw new TypeError('cap must be a number'); this.#pitySystems.set(itemId, { threshold: Math.max(1, threshold), increment: Number(increment) || 0, cap: Number(cap) || Infinity, counter: 0, currentAdd: 0, }); } /** * Reset pity counters for a given item. * @param {string} itemId - ID of the item. * @throws {TypeError} If itemId is not a string. */ resetPity(itemId) { this._checkDestroyed(); if (typeof itemId !== 'string' || !itemId.trim()) throw new TypeError('itemId must be a non-empty string'); const p = this.#pitySystems.get(itemId); if (p) { p.counter = 0; p.currentAdd = 0; } } /** * Remove all pity configurations. */ clearPities() { this._checkDestroyed(); this.#pitySystems.clear(); } /* =========================== Exclusions & groups =========================== */ /** * Check if an item is excluded from the raffle. * @param {string} itemId - Item ID to check. * @returns {boolean} `true` if excluded, otherwise `false`. * @throws {TypeError} If `itemId` is not a string. */ hasExclusion(itemId) { if (typeof itemId !== 'string') throw new TypeError('itemId must be a string'); return this.#exclusions.has(itemId); } /** * Exclude an item from the raffle. * @param {string} itemId - ID of the item. * @throws {TypeError} If `itemId` is not a string. */ excludeItem(itemId) { this._checkDestroyed(); if (typeof itemId !== 'string') throw new TypeError('itemId must be a string'); this.#exclusions.add(itemId); } /** * Re-include an item in the raffle. * @param {string} itemId - ID of the item. * @throws {TypeError} If `itemId` is not a string. */ includeItem(itemId) { this._checkDestroyed(); if (typeof itemId !== 'string') throw new TypeError('itemId must be a string'); this.#exclusions.delete(itemId); } /** * Ensure a group exists, creating it if necessary. * @param {string} name - Group name. * @returns {Set<string>} The group set. * @throws {TypeError} If `name` is not a string. * @private */ _ensureGroup(name) { this._checkDestroyed(); if (typeof name !== 'string') throw new TypeError('name must be a string'); let group = this.#groups.get(name); if (!group) { group = new Set(); this.#groups.set(name, group); } return group; } /** * Check if an item is in a given group. * @param {string} itemId - ID of the item. * @param {string} groupName - Name of the group. * @returns {boolean} `true` if the item is in the group, otherwise `false`. * @throws {TypeError} If parameters are not strings. */ hasInGroup(itemId, groupName) { if (typeof itemId !== 'string' || typeof groupName !== 'string') throw new TypeError('itemId and groupName must be strings'); const group = this.#groups.get(groupName); return group ? group.has(itemId) : false; } /** * Add an item to a group. * @param {string} itemId - ID of the item. * @param {string} groupName - Name of the group. * @throws {Error} If the item does not exist. * @throws {TypeError} If parameters are not strings. */ addToGroup(itemId, groupName) { this._checkDestroyed(); if (typeof itemId !== 'string' || typeof groupName !== 'string') throw new TypeError('itemId and groupName must be strings'); const it = this.#items.get(itemId); if (!it) throw new Error('Item missing'); it.groups.add(groupName); this._ensureGroup(groupName).add(itemId); } /** * Remove an item from a group. * @param {string} itemId - ID of the item. * @param {string} groupName - Name of the group. * @throws {TypeError} If parameters are not strings. */ removeFromGroup(itemId, groupName) { this._checkDestroyed(); if (typeof itemId !== 'string' || typeof groupName !== 'string') throw new TypeError('itemId and groupName must be strings'); const g = this.#groups.get(groupName); if (g) g.delete(itemId); const it = this.#items.get(itemId); if (it) it.groups.delete(groupName); } /* =========================== Draw core =========================== */ /** * Clears the draw frequency count for all items. * Effectively resets the internal frequency map to an empty state. */ clearFreqs() { this._checkDestroyed(); this.#freq.clear(); } /** * Removes the draw frequency entry for a specific item. * If the item ID does not exist in the frequency map, nothing happens. * * @param {string} itemId - Unique identifier of the item whose frequency should be reset. * @throws {TypeError} If `itemId` is not a string. */ resetFreq(itemId) { this._checkDestroyed(); if (typeof itemId !== 'string') throw new TypeError('itemId must be a string'); this.#freq.delete(itemId); } /** * Compute effective weights after applying modifiers, rules, and pity adjustments. * Starts with base weights, then applies global, temporary, conditional modifiers, * pity increments, removes exclusions, and removes zero or negative weights. * * @param {ComputeEffectiveWeightsContext} [context={}] - Optional context with previous draws and metadata. * @returns {Map<string, number>} A Map of itemId to effective weight. * @throws {TypeError} If `context` is provided but is not an object. */ computeEffectiveWeights(context = {}) { this._checkDestroyed(); if (typeof context !== 'object' || context === null) throw new TypeError( `computeEffectiveWeights: parameter 'context' must be a non-null object, got ${typeof context}`, ); if ('previousDraws' in context && !Array.isArray(context.previousDraws)) throw new TypeError( `computeEffectiveWeights: context.previousDraws must be an array if provided, got ${typeof context.previousDraws}`, ); if ( 'metadata' in context && typeof context.metadata !== 'undefined' && (typeof context.metadata !== 'object' || context.metadata === null) ) throw new TypeError( `computeEffectiveWeights: context.metadata must be a non-null object if provided, got ${typeof context.metadata}`, ); /** * Start from base weights * @type {Map<string, number>} */ const weights = new Map(); for (const [id, it] of this.#items) { weights.set(id, it.baseWeight); } // Apply global modifiers (they can return Map of id->delta or id->absolute) for (const mod of this.#globalModifiers) { const res = mod(weights, context); if (res instanceof Map) { for (const [id, delta] of res) { weights.set(id, Math.max(0, (weights.get(id) || 0) + delta)); } } } // Apply temporary modifiers for (const tmp of this.#temporaryModifiers) { const res = tmp.fn(weights, context); if (res instanceof Map) { for (const [id, delta] of res) { weights.set(id, Math.max(0, (weights.get(id) || 0) + delta)); } } } // Apply conditional rules for (const rule of this.#conditionalRules) { const res = rule(weights, context); if (res instanceof Map) { for (const [id, delta] of res) { weights.set(id, Math.max(0, (weights.get(id) || 0) + delta)); } } } // Apply pity adjustments for (const [itemId, pity] of this.#pitySystems) { if (!weights.has(itemId)) continue; // if counter > threshold then add currentAdd if (pity.counter > pity.threshold) { // increase currentAdd each draw by increment but cap it pity.currentAdd = Math.min(pity.cap, pity.currentAdd + pity.increment); const weight = weights.get(itemId); if (weight) weights.set(itemId, weight + pity.currentAdd); } } // Remove excluded items for (const ex of this.#exclusions) weights.delete(ex); // Zero or negative weights are removed for (const [id, w] of Array.from(weights.entries())) { if (!(w > 0)) weights.delete(id); } return weights; } /** * Convert a map of weights into a probability distribution array, * normalized according to the current normalization method. * Returns array with each element containing id, weight, probability (p), * and cumulative probability (for sampling). * * @param {Map<string, number>} weights - Map of item IDs to their weights. * @returns {Array<{id: string, weight: number, p: number, cumulative: number}>} Distribution array. * @throws {TypeError} If `weights` is not a Map. */ _weightsToDistribution(weights) { this._checkDestroyed(); if (!(weights instanceof Map)) throw new TypeError( `_weightsToDistribution: parameter 'weights' must be a Map, got ${typeof weights}`, ); for (const [key, val] of weights) { if (typeof key !== 'string') throw new TypeError( `_weightsToDistribution: weights Map key must be string, got ${typeof key}`, ); if (typeof val !== 'number' || !Number.isFinite(val) || val < 0) throw new TypeError( `_weightsToDistribution: weights Map value must be finite non-negative number, got ${val} for key ${key}`, ); } const arr = Array.from(weights.entries()).map(([id, w]) => ({ id, weight: w })); if (arr.length === 0) return []; if (this.#normalization === 'softmax') { // simple softmax with temperature = 1 const maxW = Math.max(...arr.map((a) => a.weight)); const exps = arr.map((a) => Math.exp(a.weight - maxW)); const sumExp = exps.reduce((s, x) => s + x, 0); let cum = 0; return arr.map((a, i) => { const p = exps[i] / sumExp; cum += p; return { id: a.id, weight: a.weight, p, cumulative: cum }; }); } else { // relative normalization const sum = arr.reduce((s, a) => s + a.weight, 0); let cum = 0; return arr.map((a) => { const p = a.weight / sum; cum += p; return { id: a.id, weight: a.weight, p, cumulative: cum }; }); } } /** * Draw a single item from the raffle using current configuration. * Uses previous draws and metadata to influence conditional rules and pity. * * @param {Object} [opts={}] - Optional draw options. * @param {DrawOne[]} [opts.previousDraws=[]] - History of previous draws. * @param {ItemMetadata} [opts.metadata={}] - Arbitrary metadata for conditional rules. * @returns {DrawOne|null} The drawn item object or null if no items available. * @throws {TypeError} If `opts` is not an object. */ drawOne(opts = {}) { this._checkDestroyed(); if (typeof opts !== 'object' || opts === null) throw new TypeError( `drawOne: parameter 'opts' must be a non-null object, got ${typeof opts}`, ); if ('previousDraws' in opts && !Array.isArray(opts.previousDraws)) throw new TypeError( `drawOne: opts.previousDraws must be an array if provided, got ${typeof opts.previousDraws}`, ); if ( 'metadata' in opts && typeof opts.metadata !== 'undefined' && (typeof opts.metadata !== 'object' || opts.metadata === null) ) throw new TypeError( `drawOne: opts.metadata must be a non-null object if provided, got ${typeof opts.metadata}`, ); const context = { previousDraws: opts.previousDraws ?? [], metadata: opts.metadata ?? {}, activeModifiers: [...this.#temporaryModifiers], }; const weights = this.computeEffectiveWeights(context); const dist = this._weightsToDistribution(weights); if (!dist.length) return null; const r = this.#rng(); // find first cumulative >= r const chosen = dist.find((d) => r <= d.cumulative) ?? dist[dist.length - 1]; // Update pity counters for (const [itemId, pity] of this.#pitySystems) { if (itemId === chosen.id) { pity.counter = 0; pity.currentAdd = 0; } else { pity.counter++; } } // decrement temporary modifiers uses this._consumeTemporaryModifiers(); const item = this.#items.get(chosen.id); if (!item) return null; const result = { id: item.id, label: item.label, meta: { ...item.meta }, prob: chosen.p }; this.#emit('draw', result); // add frequence if (result) this.#freq.set(result.id, (this.#freq.get(result.id) || 0) + 1); // complete return result; } /** * Internal helper to decrement usage counts of temporary modifiers, * removing them when their uses reach zero. * @private */ _consumeTemporaryModifiers() { this._checkDestroyed(); for (let i = this.#temporaryModifiers.length - 1; i >= 0; --i) { const t = this.#temporaryModifiers[i]; t.uses -= 1; if (t.uses <= 0) this.#temporaryModifiers.splice(i, 1); } } /** * Draw multiple items from the raffle with configurable options. * * @param {number} count - Number of items to draw. * @param {Object} [opts={}] - Optional parameters. * @param {ItemMetadata} [opts.metadata={}] - Metadata passed to conditional rules. * @param {boolean} [opts.withReplacement=true] - Whether to allow the same item multiple times. * @param {boolean} [opts.ensureUnique=false] - If true, attempts to ensure unique results in multi-draws. * @param {DrawOne[]} [opts.previousDraws=[]] - Previous draw history for context. * @returns {DrawOne[]} List of drawn items. * @throws {TypeError} If `count` is not a positive integer. * @throws {TypeError} If `opts` is not an object. */ drawMany(count = 1, opts = {}) { this._checkDestroyed(); if (!Number.isInteger(count) || count <= 0) throw new TypeError(`drawMany: parameter 'count' must be a positive integer, got ${count}`); if (typeof opts !== 'object' || opts === null) throw new TypeError( `drawMany: parameter 'opts' must be a non-null object, got ${typeof opts}`, ); if ('withReplacement' in opts && typeof opts.withReplacement !== 'boolean') throw new TypeError( `drawMany: opts.withReplacement must be boolean if provided, got ${typeof opts.withReplacement}`, ); if ('ensureUnique' in opts && typeof opts.ensureUnique !== 'boolean') throw new TypeError( `drawMany: opts.ensureUnique must be boolean if provided, got ${typeof opts.ensureUnique}`, ); if ( 'metadata' in opts && typeof opts.metadata !== 'undefined' && (typeof opts.metadata !== 'object' || opts.metadata === null) ) throw new TypeError( `drawMany: opts.metadata must be a non-null object if provided, got ${typeof opts.metadata}`, ); if ('previousDraws' in opts && !Array.isArray(opts.previousDraws)) throw new TypeError( `drawMany: opts.previousDraws must be an array if provided, got ${typeof opts.previousDraws}`, ); const withReplacement = opts.withReplacement ?? true; const ensureUnique = opts.ensureUnique ?? false; const results = []; const prev = opts.previousDraws ?? []; // If we want to ensure uniqueness and withReplacement === false, simply perform draws removing chosen each time. // We'll do iterative draws updating exclusions if needed. if (!withReplacement && ensureUnique) { const tempExcluded = new Set(); for (let i = 0; i < count; ++i) { // temporarily add exclusions from tempExcluded const savedEx = new Set(this.#exclusions); for (const ex of tempExcluded) this.#exclusions.add(ex); const r = this.drawOne({ previousDraws: prev, metadata: opts.metadata }); // restore exclusions this.#exclusions = savedEx; if (!r) break; results.push(r); tempExcluded.add(r.id); prev.push(r); } return results; } // otherwise, perform draws possibly with changing pity & modifiers for (let i = 0; i < count; ++i) { const r = this.drawOne({ previousDraws: prev, metadata: opts.metadata }); if (!r) break; results.push(r); prev.push(r); if (!withReplacement) { // temporarily exclude the chosen item only for this multi-draw this.excludeItem(r.id); } } // restore exclusions that were added by drawMany for no-replacement behavior if (!withReplacement) { for (const r of results) this.includeItem(r.id); } return results; } /* =========================== Save / Load (JSON) =========================== */ /** * Export the current configuration to a JSON-serializable object. * Contains all items, pity systems, exclusions