UNPKG

@allemandi/gacha-engine

Version:

Practical, type-safe toolkit for simulating and understanding gacha rates and rate-ups.

218 lines (217 loc) 8.76 kB
class GachaEngine { static SCALE = 1_000_000; static MAX_SAFE_SCALE = Math.floor(Number.MAX_SAFE_INTEGER / GachaEngine.SCALE); mode; pools = []; rarityRatesScaled = {}; flatRateMap = new Map(); dropRateCacheScaled = new Map(); flatRateRateUpItems = []; constructor(config) { this.mode = config.mode; if (config.mode === 'weighted') { const weightedConfig = config; this.pools = weightedConfig.pools; this.rarityRatesScaled = this.scaleRarityRates(weightedConfig.rarityRates); this.validateConfig(weightedConfig.rarityRates); } else if (config.mode === 'flatRate') { const flatConfig = config; this.pools = flatConfig.pools; // Keep pools for reference for (const pool of flatConfig.pools) { for (const item of pool.items) { if (item.weight < 0) { throw new Error(`FlatRate item "${item.name}" must have non-negative weight`); } this.flatRateMap.set(item.name, item.weight); // direct probability if (item.rateUp) { this.flatRateRateUpItems.push(item.name); } } } const total = Array.from(this.flatRateMap.values()).reduce((sum, v) => sum + v, 0); if (Math.abs(total - 1.0) > 1e-6) { throw new Error(`FlatRate item rates must sum to 1.0, but got ${total}`); } } else { throw new Error(`Unknown gacha mode: ${this.mode}`); } } scaleRarityRates(rarityRates) { const scaled = {}; for (const [rarity, rate] of Object.entries(rarityRates)) { if (rate < 0 || rate > 1) { throw new Error(`Rarity rate for "${rarity}" must be between 0 and 1, got ${rate}`); } scaled[rarity] = this.toScaled(rate); } return scaled; } toScaled(probability) { if (probability > GachaEngine.MAX_SAFE_SCALE / GachaEngine.SCALE) { throw new Error(`Probability ${probability} too large for safe integer arithmetic`); } return Math.round(probability * GachaEngine.SCALE); } fromScaled(scaledInt) { return scaledInt / GachaEngine.SCALE; } validateConfig(originalRates) { const configuredRarities = new Set(Object.keys(this.rarityRatesScaled)); const usedRarities = new Set(this.pools.map(p => p.rarity)); const missing = Array.from(usedRarities).filter(r => !configuredRarities.has(r)); if (missing.length > 0) { throw new Error(`Missing rarity rates for: ${missing.join(', ')}`); } const totalRate = Object.values(originalRates).reduce((sum, rate) => sum + rate, 0); if (Math.abs(totalRate - 1.0) > 1e-10) { throw new Error(`Rarity rates must sum to 1.0, got ${totalRate}`); } for (const pool of this.pools) { if (pool.items.length === 0) { throw new Error(`Rarity "${pool.rarity}" has no items`); } const totalWeight = pool.items.reduce((sum, i) => sum + i.weight, 0); if (totalWeight <= 0) { throw new Error(`Rarity "${pool.rarity}" has zero total weight`); } for (const item of pool.items) { if (item.weight < 0) { throw new Error(`Item "${item.name}" weight must be non-negative, got ${item.weight}`); } } if (!pool.items.some(i => i.weight > 0)) { throw new Error(`Rarity "${pool.rarity}" must have at least one item with positive weight`); } } } getItemDropRate(name) { if (this.mode === 'flatRate') { return this.flatRateMap.get(name) || 0; } if (this.dropRateCacheScaled.has(name)) { return this.fromScaled(this.dropRateCacheScaled.get(name)); } for (const pool of this.pools) { const item = pool.items.find(i => i.name === name); if (item) { if (item.weight === 0) { this.dropRateCacheScaled.set(name, 0); return 0; } const totalPoolWeight = pool.items.reduce((sum, i) => sum + i.weight, 0); const baseRarityRateScaled = this.rarityRatesScaled[pool.rarity]; const itemWeightScaled = this.toScaled(item.weight); const totalWeightScaled = this.toScaled(totalPoolWeight); const numeratorScaled = Math.round((itemWeightScaled * baseRarityRateScaled) / GachaEngine.SCALE); const rateScaled = Math.round((numeratorScaled * GachaEngine.SCALE) / totalWeightScaled); this.dropRateCacheScaled.set(name, rateScaled); return this.fromScaled(rateScaled); } } throw new Error(`Item "${name}" not found`); } getCumulativeProbabilityForItem(name, rolls) { const rate = this.getItemDropRate(name); if (rate === 0) return 0; if (rate >= 1) return 1; const cumulativeFailProbability = Math.pow(1 - rate, rolls); return 1 - cumulativeFailProbability; } getRollsForTargetProbability(name, targetProbability) { if (targetProbability <= 0) return 0; if (targetProbability >= 1) return 1; const rate = this.getItemDropRate(name); if (rate <= 0) return Infinity; return Math.ceil(Math.log(1 - targetProbability) / Math.log(1 - rate)); } getRateUpItems() { if (this.mode === 'weighted') { return this.pools.flatMap(p => p.items.filter(i => i.rateUp).map(i => i.name)); } else { return this.flatRateRateUpItems; } } getAllItemDropRates() { if (this.mode === 'flatRate') { return Array.from(this.flatRateMap.entries()).map(([name, dropRate]) => ({ name, dropRate, rarity: 'flatRate', })); } return this.pools.flatMap(p => p.items.map(i => ({ name: i.name, dropRate: this.getItemDropRate(i.name), rarity: p.rarity, }))); } roll(count = 1) { const results = []; for (let i = 0; i < count; i++) { if (this.mode === 'flatRate') { const rand = Math.random(); let cumulative = 0; for (const [name, rate] of this.flatRateMap.entries()) { cumulative += rate; if (rand < cumulative) { results.push(name); break; } } } else { const rarity = this.selectRarity(); const pool = this.pools.find(p => p.rarity === rarity); const item = this.selectItemFromPool(pool); results.push(item.name); } } return results; } selectRarity() { const rand = Math.floor(Math.random() * GachaEngine.SCALE); let cumulative = 0; for (const [rarity, scaledRate] of Object.entries(this.rarityRatesScaled)) { cumulative += scaledRate; if (rand < cumulative) return rarity; } return Object.keys(this.rarityRatesScaled)[0]; } selectItemFromPool(pool) { const items = pool.items.filter(i => i.weight > 0); const scaledItems = items.map(i => ({ ...i, scaledWeight: this.toScaled(i.weight), })); const totalScaledWeight = scaledItems.reduce((sum, i) => sum + i.scaledWeight, 0); const rand = Math.floor(Math.random() * totalScaledWeight); let cumulative = 0; for (const item of scaledItems) { cumulative += item.scaledWeight; if (rand < cumulative) { return { name: item.name, weight: item.weight }; } } return items[0]; // Fallback } getDebugInfo() { const rarityRatesFloat = {}; for (const [rarity, scaledRate] of Object.entries(this.rarityRatesScaled)) { rarityRatesFloat[rarity] = this.fromScaled(scaledRate); } return { scale: GachaEngine.SCALE, rarityRatesScaled: { ...this.rarityRatesScaled }, rarityRatesFloat, }; } }export{GachaEngine};//# sourceMappingURL=index.module.js.map