Skip to content

Commit

Permalink
Merge pull request #17 from retraigo/main
Browse files Browse the repository at this point in the history
I messed up
  • Loading branch information
retraigo authored Sep 29, 2022
2 parents fe39301 + 256ddb0 commit da7543d
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 89 deletions.
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"tasks": {
"bench": "deno bench --unstable benches/100k_rolls.ts",
"test": "deno run --allow-hrtime test.ts"
"test": "deno test --allow-hrtime test.ts"
}
}
35 changes: 29 additions & 6 deletions mod.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,26 @@ class GachaMachine {
}
return result;
}
getUnique(count = 1) {
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, count = 1) {
const toRoll = [];
let i = 0;
Expand All @@ -97,23 +117,26 @@ class GachaMachine {
return result;
}
static rollWithBinarySearch(items, totalChance) {
if (!totalChance) totalChance = items[items.length - 1].cumulativeChance;
if (items.length === 1) return items[0].result;
return GachaMachine.#rollWithBinarySearchDetailed(items, totalChance).result;
}
static #rollWithBinarySearchDetailed(items2, totalChance) {
if (!totalChance) totalChance = items2[items2.length - 1].cumulativeChance;
if (items2.length === 1) return items2[0];
const rng = Math.random() * totalChance;
let lower = 0;
let max = items.length - 1;
let max = items2.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].result;
if (items[mid].cumulativeChance < rng) {
if (items2[mid].cumulativeChance > rng && items2[mid - 1].cumulativeChance < rng || items2[mid].cumulativeChance == rng) return items2[mid];
if (items2[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].result;
return items2[mid];
}
static rollWithLinearSearch(choices, totalChance = 0) {
let total = totalChance;
Expand Down
48 changes: 45 additions & 3 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,42 @@ export class GachaMachine<ItemType> {
}
return result;
}
/**
* Roll unique items from the gacha machine.
* WARNING: This feature is currently unstable.
* @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)
* ```
*/
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;
}
/**
* Roll items from specific tiers.
* @param tiers List of tiers to roll from.
Expand Down Expand Up @@ -182,8 +218,14 @@ export class GachaMachine<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].result;
if (items.length === 1) return items[0];
const rng = Math.random() * totalChance;
let lower = 0;
let max = items.length - 1;
Expand All @@ -195,7 +237,7 @@ export class GachaMachine<ItemType> {
(items[mid].cumulativeChance > rng &&
items[mid - 1].cumulativeChance < rng) ||
items[mid].cumulativeChance == rng
) return items[mid].result;
) return items[mid];
if (items[mid].cumulativeChance < rng) {
lower = mid + 1;
mid = Math.floor((max + lower) / 2);
Expand All @@ -204,7 +246,7 @@ export class GachaMachine<ItemType> {
mid = Math.floor((max + lower) / 2);
}
}
return items[mid].result;
return items[mid];
}
/**
* Roll one item from a pool using linear search. Simple and great for smaller pools.
Expand Down
163 changes: 84 additions & 79 deletions test.ts
Original file line number Diff line number Diff line change
@@ -1,95 +1,100 @@
import {
assert,
assertArrayIncludes,
assertEquals,
assertExists,
assertThrows,
} from "https://deno.land/std@0.157.0/testing/asserts.ts";
import { GachaMachine } from "./mod.ts";
import { assertAlmostEquals } from "https://deno.land/std@0.145.0/testing/asserts.ts";

import { Duration } from "https://deno.land/x/durationjs@v4.0.0/mod.ts";
const testData = [
{ 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 sortedTestData = testData.sort((a, b) => a.chance - b.chance);
/*
import pokemon from "./testdata/pokemon.json" assert { type: "json" };
function countDupes(arr: string[]): Record<string, number> {
const items: Record<string, number> = {};
arr.forEach((v) => {
if (items[v]) items[v]++;
else items[v] = 1;
});
return items;
}

const timeStart = performance.now();

const items = pokemon.slice(0, 151).map((x) => ({
let testData = pokemon.slice().map((x) => ({
result: x.id,
id: x.id,
tier: x.tier === "legendary" ? 3 : x.tier === "mythic" ? 2 : 1,
chance: x.tier === "legendary" ? 10 : x.tier === "mythic" ? 1 : 25,
weight: x.tier === "legendary" ? 10 : x.tier === "mythic" ? 1 : 25,
chance: x.tier === "legendary" ? 11 : x.tier === "mythic" ? 1 : 25,
}));
testData = testData.concat(testData);
const timeConfig = performance.now();

const machine = new GachaMachine(items);

const timeInit = performance.now();

const res = machine.get(1000000);

const timeRoll = performance.now();

const dupes = countDupes(res.map((x) => String(x)));
testData = testData.concat(testData);
*/
Deno.test({
name: "Is GachaMachine defined?",
fn() {
const machine = new GachaMachine(testData);
assertExists(machine);
},
});

const timeReduce = performance.now();
Deno.test({
name:
"Is the initialization time less than 200 + (number of elements / 3) microseconds?",
fn() {
const t1 = performance.now();
new GachaMachine(testData);
const t2 = performance.now();
assert((t2 - t1) * 100 < 200 + (testData.length / 3));
},
});

console.log(
Object.entries(dupes).map((x) => {
const pk = pokemon.find((y) => y.id === Number(x[0]));
return { name: pk?.name, count: x[1], tier: pk?.tier };
}).sort((a, b) => a.count - b.count).reverse().map((x) =>
`Name: ${x.name}\nRate: ${x.count / 10000}%\nTier: ${x.tier}`
).join("\n\n"),
);
Deno.test({
name: `Roll 3 unique items from a collection of 5 items`,
fn() {
const machine = new GachaMachine(testData);
const res = machine.getUnique(3);
assertExists(res);
},
});

// TESTS
Deno.test({
name: `Roll 5 unique items from a collection of 5 items`,
fn() {
const machine = new GachaMachine(testData);
const res = machine.getUnique(5);
assertArrayIncludes(res, [
"SSR cool character",
"Kinda rare character",
"Mob character",
"Mob character",
"Mob character",
]);
},
});

Deno.test("Range Check", () => {
Object.entries(dupes).map((x) => {
const pk = pokemon.find((y) => y.id === Number(x[0]));
return { name: pk?.name, count: x[1], tier: pk?.tier };
}).sort((a, b) => a.count - b.count).reverse().forEach((x) => {
if (x.tier === "normal") assertAlmostEquals(x.count / 10000, 0.67, 1e-1);
else if (x.tier === "legendary") {
assertAlmostEquals(x.count / 10000, 0.26, 4e-2);
} else if (x.tier === "mythic") {
assertAlmostEquals(x.count / 10000, 0.03, 4e-2);
}
});
Deno.test({
name: `Roll 7 unique items from a collection of 5 items (throw error)`,
fn() {
const machine = new GachaMachine(testData);
assertThrows(() => machine.getUnique(7));
},
});

Deno.test("Time Check", () => {
assertAlmostEquals((timeRoll - timeInit) / 1000, 1.56, 1e-1)
})
// TESTS END
Deno.test({
name: `Roll 7 non-unique items from a collection of 5 items (don't throw error)`,
fn() {
const machine = new GachaMachine(testData);
const res = machine.get(7);
assertEquals(res.length, 7)
},
});

console.log("-----REPORT-----");
console.log(
"Time taken to configure:",
Duration.between(timeConfig, timeStart).toShortString(
["s", "ms", "us", "ns"],
),
);
console.log(
"Time taken to setup machine:",
Duration.between(timeConfig, timeInit).toShortString(
["s", "ms", "us", "ns"],
),
);
console.log(
"Time taken to roll 1000000 items:",
Duration.between(timeInit, timeRoll).toShortString(["s", "ms", "us", "ns"]),
);
console.log(
"Time taken to reduce data into result:",
Duration.between(timeReduce, timeRoll).toShortString(
["s", "ms", "us", "ns"],
),
);
// console.log(items.map(x => `GachaChoice {${JSON.stringify(x)}}`).join(",\n"))
/*
Deno.test({
name: `${sortedTestData[0].chance} in ${testData.reduce((acc, val) => acc + val.chance, 0)} rolls return a ${sortedTestData[0].result}?`,
fn() {
const machine = new GachaMachine(testData);
const res = machine.get(testData.reduce((acc, val) => acc + val.chance, 0));
assertArrayIncludes(res, sortedTestData[0].result)
},
});
*/

0 comments on commit da7543d

Please sign in to comment.