export interface GachaData<ItemType> { result: ItemType; chance: number; tier?: number; } export interface GachaChoice<ItemType> { result: ItemType; chance: number; } export interface ComputedGachaData<ItemType> { result: ItemType; chance: number; cumulativeChance: number; tier: number; } export interface ComputedTierData { totalChance: number; items: number; tier: number; } export class GachaMachine<ItemType> { #items: ComputedGachaData<ItemType>[]; #tiers: ComputedTierData[]; #maxTier: number; #totalChance: number; #pool: number[]; constructor(items: GachaData<ItemType>[]) { this.#items = []; this.#maxTier = 1; this.#pool = []; this.#tiers = []; this.#totalChance = 0; this.#configTiers(items); this.#configItems(items); } get items(): ComputedGachaData<ItemType>[] { return this.#items; } set items(data: GachaData<ItemType>[]) { this.#items = []; this.#maxTier = 1; this.#pool = []; this.#tiers = []; this.#totalChance = 0; this.#configTiers(data); this.#configItems(data); } get maxTier() { return this.#maxTier; } get pool() { return this.#pool; } get tiers() { return this.#tiers; } get totalChance() { return this.#totalChance; } #configTiers(items: GachaData<ItemType>[]) { let i = 0; const tiers = new Set<number>(); while (i < items.length) { tiers.add(items[i].tier || 1); i += 1; } for (const tier of tiers) { if (tier > this.maxTier) this.#maxTier = tier; } const itemsInTier = new Uint8Array(this.maxTier + 1); const totalChanceInTier = new Uint8Array(this.maxTier + 1); i = 0; while (i < items.length) { Atomics.add(itemsInTier, items[i].tier || 1, 1); Atomics.add(totalChanceInTier, items[i].tier || 1, items[i].chance); i += 1; } for (const tier of tiers) { this.#tiers.push({ tier: tier, totalChance: totalChanceInTier[tier], items: itemsInTier[tier], }); } this.#pool = Array.from(tiers); } #configItems(items: GachaData<ItemType>[]) { let i = 0; let cumulativeChance = 0; while (i < items.length) { cumulativeChance += items[i].chance; this.#items.push({ result: items[i].result, chance: items[i].chance, cumulativeChance: cumulativeChance, tier: items[i].tier || 1, }); i += 1; } this.#totalChance = cumulativeChance; } get(count = 1): ItemType[] { if (count === 1) { return [ GachaMachine.rollWithBinarySearch(this.items, this.totalChance), ]; } const result = new Array(count); let i = 0; while (i < count) { result[i] = GachaMachine.rollWithBinarySearch( this.items, this.totalChance, ); i += 1; } return result; } getUnique(count = 1): ItemType[] { if (count > this.items.length) { throw new RangeError( `Cannot pick ${count} unique items from a collection of ${this.items.length} items.`, ); } if (count === 1) { return [ GachaMachine.rollWithBinarySearch(this.items, this.totalChance), ]; } const tempItems = this.items.slice(0); const result = new Array(count); let i = 0; while (i < count) { const res = GachaMachine.#rollWithBinarySearchDetailed( tempItems, this.totalChance, ); result[i] = res.result; tempItems.splice( tempItems.findIndex((x) => x.cumulativeChance === res.cumulativeChance), 1, ); i += 1; } return result; } getFromTier(tiers: number[], count = 1): ItemType[] { const toRoll: ComputedGachaData<ItemType>[] = []; let i = 0; let cumulativeChance = 0; while (i < this.items.length) { if (tiers.includes(this.items[i].tier)) { cumulativeChance += this.items[i].chance; toRoll.push({ ...this.items[i], cumulativeChance }); } i += 1; } if (toRoll.length === 0) return []; const result = []; i = 0; while (i < count) { result.push(GachaMachine.rollWithBinarySearch(toRoll, cumulativeChance)); i += 1; } return result; } static rollWithBinarySearch<ItemType>( items: ComputedGachaData<ItemType>[], totalChance?: number, ): ItemType { return GachaMachine.#rollWithBinarySearchDetailed(items, totalChance) .result; } static #rollWithBinarySearchDetailed<ItemType>( items: ComputedGachaData<ItemType>[], totalChance?: number, ): ComputedGachaData<ItemType> { if (!totalChance) totalChance = items[items.length - 1].cumulativeChance; if (items.length === 1) return items[0]; const rng = Math.random() * totalChance; let lower = 0; let max = items.length - 1; let mid = Math.floor((max + lower) / 2); while ( mid != 0 && lower <= max ) { if ( (items[mid].cumulativeChance > rng && items[mid - 1].cumulativeChance < rng) || items[mid].cumulativeChance == rng ) return items[mid]; if (items[mid].cumulativeChance < rng) { lower = mid + 1; mid = Math.floor((max + lower) / 2); } else { max = mid - 1; mid = Math.floor((max + lower) / 2); } } return items[mid]; } static rollWithLinearSearch = roll; } function roll<ItemType>( choices: GachaChoice<ItemType>[], totalChance = 0, ): ItemType { let total = totalChance; let i = 0; if (totalChance === 0) { while (i < choices.length) { total += choices[i].chance; i += 1; } } const result = Math.random() * total; let going = 0.0; i = 0; while (i < choices.length) { going += choices[i].chance; if (result < going) { return choices[i].result; } i += 1; } return choices[Math.floor(Math.random() * choices.length)].result; }