diff --git a/README.md b/README.md index 89beb0d..03b6a82 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# fortuna +# fortuna v2 Weighted gacha system. ## Usage @@ -8,12 +8,11 @@ Create an item using `GachaMachine.createItem` More weight = more common ```js const items = [ - GachaMachine.createItem("SSR cool character", 1), - GachaMachine.createItem("Kinda rare character", 3), - GachaMachine.createItem("Mob character", 5), - GachaMachine.createItem("Mob character", 5), - GachaMachine.createItem("Mob character", 5), - } + { result: "SSR cool character", chance: 1 }, + { result: "Kinda rare character", chance: 3 }, + { result: "Mob character", chance: 5 }, + { result: "Mob character", chance: 5 }, + { result: "Mob character", chance: 5 }, ] const machine = new GachaMachine(items) @@ -42,23 +41,7 @@ You probably don't need all complicated stuff. Here's a quick way to just create (Only works on v1.1.0 and above) ```ts -import { GachaMachine } from 'https://deno.land/x/fortuna@v1.2.0/mod.ts' // wherever you are importing from. - -const items = [ - GachaMachine.createRollChoice("SSR cool character", 1), - GachaMachine.createRollChoice("Kinda rare character", 3), - GachaMachine.createRollChoice("Mob character", 5), - GachaMachine.createRollChoice("Mob character", 5), - GachaMachine.createRollChoice("Mob character", 5), -] - -GachaMachine._roll(items) // Rolls one item from the list of items -``` - -### Alternatively... -`GachaMachine.createRollChoice` just returns an object with `result` and `chance`. In otherwords, it's useless code. Just supply your own object ez. -```ts -import { GachaMachine } from 'https://deno.land/x/fortuna@v1.2.0/mod.ts' // wherever you are importing from. +import { GachaMachine } from 'https://deno.land/x/fortuna/mod.ts' // wherever you are importing from. const items = [ { result: "SSR cool character", chance: 1 }, @@ -68,29 +51,28 @@ const items = [ { result: "Mob character", chance: 5 }, ] -GachaMachine.roll(items) // Rolls one item from the list of items +GachaMachine.rollWithLinearSearch(items) // Rolls one item from the list of items using linear search. ``` +`GachaMachine#get()` works using Binary Search by default. Using the Binary Search method explicitly requires a different structure of data for input. -## Documentation -Documentation for the latest version can be found in [https://doc.deno.land/https://deno.land/x/fortuna/mod.ts](https://doc.deno.land/https://deno.land/x/fortuna/mod.ts) - -A guide for usage can be found in [docs.nekooftheabyss.moe](https://docs.nekooftheabyss.moe/fortuna) - - -## What I don't like about fortuna atm -I initially made fortuna for a very specific purpose. When I later decided to make it an open-source, general-purpose gacha system, I had to make a lot of changes which ended up making a large part of the code look niche. More like, it is niche. The only thing a person would need from fortuna is the `_roll` method which I have no idea why I prefixed with an underscore. - -~~Especially the `tier` and `pool` system. Have they ever been of use in any place? If any, those features only make the rest of the code worse.~~ As of v1.2.0, fortuna's earlier algorithm was replaced with a much simpler one (I realized that I was using a bunch of worthless stuff). Older algorithms can be accessed via the `failures` directory. +```ts +import { GachaMachine } from 'https://deno.land/x/fortuna/mod.ts' // wherever you are importing from. -### How to test a failure? +const items = [ + { result: "SSR cool character", cumulativeChance: 1 }, + { result: "Kinda rare character", cumulativeChance: 4 }, + { result: "Mob character", cumulativeChance: 9 }, + { result: "Mob character", cumulativeChance: 14 }, + { result: "Mob character", cumulativeChance: 19 }, +] -```ts -import { roll } from "https://deno.land/x/fortuna@v1.2.0/failures/roll1.ts" +GachaMachine.rollWithBinarySearch(items) // Rolls one item from the list of items using linear search. ``` -You can pass an array of items of the form `{ result: ItemType, chance: number}` where `result` is the value and `chance` is the weight of the value. `ItemType` is to be passed as a type parameter to `roll`. +## Documentation +Documentation for the latest version can be found in [https://doc.deno.land/https://deno.land/x/fortuna/mod.ts](https://doc.deno.land/https://deno.land/x/fortuna/mod.ts) + +A guide for usage can be found in [docs.nekooftheabyss.moe](https://docs.nekooftheabyss.moe/fortuna) (not updated for v2 yet). -~~So I'll be redoing a large part of the code, mainly reworking the two features I mentioned. Hence, v2 will be coming soon with breaking changes for world peace... Hopefully.~~ -v1.2.0 is out with better typings and a better algorithm already. `_roll` is now `roll`. diff --git a/benches/100k_rolls.ts b/benches/100k_rolls.ts index 67c7a85..fb373c7 100644 --- a/benches/100k_rolls.ts +++ b/benches/100k_rolls.ts @@ -9,7 +9,7 @@ import { GachaMachine as M1 } from "../history/v1.ts"; import pokemon from "../testdata/pokemon.json" assert { type: "json" }; -const items = pokemon.slice(0, 151).map((x) => ({ +const items = pokemon.slice().map((x) => ({ result: x.id, tier: x.tier === "legendary" ? 1 : x.tier === "mythic" ? 2 : 3, chance: x.tier === "legendary" ? 11 : x.tier === "mythic" ? 1 : 25, @@ -51,7 +51,7 @@ Deno.bench("Algorithm V4 _ Sub", () => { */ Deno.bench("Algorithm V4", () => { for (let i = 0; i < 1e6; ++i) { - roll4(items); + roll4(items, 3595); } }); /* diff --git a/history/algorithms/v4.ts b/history/algorithms/v4.ts index 12d135c..354b9b5 100644 --- a/history/algorithms/v4.ts +++ b/history/algorithms/v4.ts @@ -7,12 +7,15 @@ import type { GachaChoice } from "../../mod.ts"; */ export function roll( choices: GachaChoice[], + totalChance = 0, ): GachaChoice { - let total = 0; + let total = totalChance; let i = 0; - while (i < choices.length) { - total += choices[i].chance; - i += 1; + if (totalChance === 0) { + while (i < choices.length) { + total += choices[i].chance; + i += 1; + } } const result = Math.random() * total; let going = 0.0; diff --git a/mod.js b/mod.js index f734551..b7ffee2 100644 --- a/mod.js +++ b/mod.js @@ -47,36 +47,65 @@ class GachaMachine { #configItems(items1) { let i1 = 0; let cumulativeChance = 0; - const cumulativeChanceInTier = new Uint8Array(this.maxTier + 1); while(i1 < items1.length){ + cumulativeChance += items1[i1].chance; this.items.push({ result: items1[i1].result, + chance: items1[i1].chance, cumulativeChance: cumulativeChance, - cumulativeChanceInTier: Atomics.add(cumulativeChanceInTier, items1[i1].tier || 1, items1[i1].chance), tier: items1[i1].tier || 1 }); - cumulativeChance += items1[i1].chance; i1 += 1; } this.totalChance = cumulativeChance; } get(count = 1) { - const result = []; + 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; + } + getFromTier(tiers, count = 1) { + const toRoll = []; 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(this.#roll()); + result.push(GachaMachine.rollWithBinarySearch(toRoll, cumulativeChance)); i += 1; } return result; } - #roll() { - if (this.items.length === 1) return this.items[0].result; - const rng = Math.random() * this.totalChance; + static rollWithBinarySearch(items, totalChance) { + if (!totalChance) totalChance = items[items.length - 1].cumulativeChance; + if (items.length === 1) return items[0].result; + const rng = Math.random() * totalChance; let lower = 0; - let max = this.items.length - 1; + let max = items.length - 1; let mid = Math.floor((max + lower) / 2); - while(!(this.items[mid].cumulativeChance > rng && this.items[mid - 1].cumulativeChance < rng) && this.items[mid].cumulativeChance !== rng && mid != 0 && lower <= max){ - if (this.items[mid].cumulativeChance < rng) { + while(mid != 0 && lower <= max){ + if (items[mid].cumulativeChance > rng && items[mid - 1].cumulativeChance < rng || items[mid].cumulativeChance == rng) return items[mid].result; + if (items[mid].cumulativeChance < rng) { lower = mid + 1; mid = Math.floor((max + lower) / 2); } else { @@ -84,7 +113,28 @@ class GachaMachine { mid = Math.floor((max + lower) / 2); } } - return this.items[mid].result; + return items[mid].result; + } + static rollWithLinearSearch(choices, totalChance = 0) { + 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; } } export { GachaMachine as GachaMachine }; diff --git a/mod.ts b/mod.ts index 78b2b87..64a949c 100644 --- a/mod.ts +++ b/mod.ts @@ -1,33 +1,61 @@ +/** + * Data fed to the constructor. + * The `result` property holds the result that will be returned after rolling. + * `chance` is the weight of the result. + * `tier` is an optional parameter to group the items for rolling. + */ export interface GachaData { result: ItemType; chance: number; tier?: number; } +/** + * Raw data fed to the linear search function. + * The `result` property holds the result that will be returned after rolling. + * `chance` is the weight of the result. + */ export interface GachaChoice { result: ItemType; chance: number; } +/** + * Data transformed by the constructor, fed to the binary search function. + * The `result` property holds the result that will be returned after rolling. + * `chance` is the weight of the result. + * `cumulativeChance` is used to make it fit for binary search. + * `tier` is an optional parameter to group the items for rolling. + */ export interface ComputedGachaData { result: ItemType; + chance: number; cumulativeChance: number; - cumulativeChanceInTier: number; tier: number; } +/** + * Something unnecessary. + */ export interface ComputedTierData { totalChance: number; items: number; tier: number; } +/** + * A gacha machine for weighted selection. + */ export class GachaMachine { items: ComputedGachaData[]; tiers: ComputedTierData[]; maxTier: number; totalChance: number; pool: number[]; + /** + * Create a new gacha machine for weighted selection. + * @param items Array of items to roll from. + */ constructor(items: GachaData[]) { this.items = []; this.tiers = []; @@ -67,57 +95,167 @@ export class GachaMachine { #configItems(items: GachaData[]) { let i = 0; let cumulativeChance = 0; - const cumulativeChanceInTier = new Uint8Array(this.maxTier + 1); while (i < items.length) { + cumulativeChance += items[i].chance; this.items.push({ result: items[i].result, + chance: items[i].chance, cumulativeChance: cumulativeChance, - cumulativeChanceInTier: Atomics.add( - cumulativeChanceInTier, - items[i].tier || 1, - items[i].chance, - ), tier: items[i].tier || 1, }); - cumulativeChance += items[i].chance; i += 1; } this.totalChance = cumulativeChance; } + /** + * Roll items from the gacha machine. + * @param count Number of items to roll. + * @returns `count` number of items from the items fed to the constructor. + * @example + * ```ts + * const machine = new GachaMachine(items); + * machine.get(11) + * ``` + */ get(count = 1): ItemType[] { - const result = []; + if (count === 1) { + return [ + GachaMachine.rollWithBinarySearch(this.items, this.totalChance), + ]; + } + const result = new Array(count); let i = 0; while (i < count) { - result.push(this.#roll()); + result[i] = GachaMachine.rollWithBinarySearch( + this.items, + this.totalChance, + ); + i += 1; + } + return result; + } + /** + * Roll items from specific tiers. + * @param tiers List of tiers to roll from. + * @param count Number of items to roll. + * @returns `count` number of items from the items fed to the constructor. + */ + getFromTier(tiers: number[], count = 1): ItemType[] { + const toRoll: ComputedGachaData[] = []; + 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; } - #roll(): ItemType { - if (this.items.length === 1) return this.items[0].result; - const rng = Math.random() * this.totalChance; + /** + * Roll one item from a pool using binary search. Requires special transformation. + * @param items List of items to roll from, each having a `result` and `cumulativeChance`. + * @param totalChance Total weight of the pool. + * @returns An item from the pool. + * @example + * ```ts + * const items = [ + * { result: "SSR cool character", cumulativeChance: 1 }, + * { result: "Kinda rare character", cumulativeChance: 4 }, + * { result: "Mob character", cumulativeChance: 9 }, + * { result: "Mob character", cumulativeChance: 14 }, + * { result: "Mob character", cumulativeChance: 19 }, + * ] + * GachaMachine.rollWithBinarySearch(items) // Rolls one item from the list of items + * ``` + * Supplying totalChance does not affect the execution speed much. + */ + static rollWithBinarySearch( + items: ComputedGachaData[], + totalChance?: number, + ): ItemType { + if (!totalChance) totalChance = items[items.length - 1].cumulativeChance; + if (items.length === 1) return items[0].result; + const rng = Math.random() * totalChance; let lower = 0; - let max = this.items.length - 1; + let max = items.length - 1; let mid = Math.floor((max + lower) / 2); while ( - !(this.items[mid].cumulativeChance > rng && - this.items[mid - 1].cumulativeChance < rng) && - this.items[mid].cumulativeChance !== rng && mid != 0 && lower <= max + mid != 0 && lower <= max ) { - // console.log(this.items[mid].cumulativeChance, this.items[mid-1].cumulativeChance, rng) - // console.log(rng, mid) - if (this.items[mid].cumulativeChance < rng) { - // console.log("le", max, lower, mid) - // if(mid === 150) throw new Error("!%)") + if ( + (items[mid].cumulativeChance > rng && + items[mid - 1].cumulativeChance < rng) || + items[mid].cumulativeChance == rng + ) return items[mid].result; + if (items[mid].cumulativeChance < rng) { lower = mid + 1; mid = Math.floor((max + lower) / 2); } else { - // console.log("mo", max, lower, mid) - max = mid - 1; mid = Math.floor((max + lower) / 2); } } - return this.items[mid].result; + return items[mid].result; + } + /** + * Roll one item from a pool using linear search. Simple and great for smaller pools. + * @param items List of items to roll from, each having a `result` and `chance`. + * @param totalChance Total weight of the pool. + * @returns An item from the pool. + * @example + * ```ts + * const items = [ + * { result: "SSR cool character", chance: 1 }, + * { result: "Kinda rare character", chance: 3 }, + * { result: "Mob character", chance: 5 }, + * { result: "Mob character", chance: 5 }, + * { result: "Mob character", chance: 5 }, + * ] + * GachaMachine.rollWithLinearSearch(items) // Rolls one item from the list of items + * ``` + * @example + * ```ts + * const items = [ + * { result: "SSR cool character", chance: 1 }, + * { result: "Kinda rare character", chance: 3 }, + * { result: "Mob character", chance: 5 }, + * { result: "Mob character", chance: 5 }, + * { result: "Mob character", chance: 5 }, + * ] + * GachaMachine.rollWithLinearSearch(items, 19) // Rolls one item from the list of items, faster because the total chance is known. + * ``` + */ + static rollWithLinearSearch( + choices: GachaChoice[], + 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; } }