@allemandi/gacha-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v3
13
+ - uses: actions/setup-node@v3
14
+ with:
15
+ node-version: 18
16
+ cache: 'yarn'
17
+ - run: yarn install --frozen-lockfile
18
+ - run: yarn lint
19
+
20
+ test:
21
+ needs: lint
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v3
25
+ - uses: actions/setup-node@v3
26
+ with:
27
+ node-version: 18
28
+ cache: 'yarn'
29
+ - run: yarn install --frozen-lockfile
30
+ - run: yarn test
31
+
32
+ build:
33
+ needs: test
34
+ runs-on: ubuntu-latest
35
+ steps:
36
+ - uses: actions/checkout@v3
37
+ - uses: actions/setup-node@v3
38
+ with:
39
+ node-version: 18
40
+ cache: 'yarn'
41
+ - run: yarn install --frozen-lockfile
42
+ - run: yarn build
@@ -0,0 +1,26 @@
1
+ name: Publish to NPM
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*.*.*'
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: actions/setup-node@v4
16
+ with:
17
+ node-version: '18'
18
+ registry-url: 'https://registry.npmjs.org/'
19
+ cache: 'yarn'
20
+
21
+ - run: yarn install --frozen-lockfile
22
+ - run: yarn build
23
+
24
+ - run: yarn publish --non-interactive --access public
25
+ env:
26
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Daniel Lam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # ๐Ÿ“– @allemandi/gacha-engine
2
+
3
+ [![NPM Version](https://img.shields.io/npm/v/@allemandi/gacha-engine)](https://www.npmjs.com/package/@allemandi/gacha-engine)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/allemandi/gacha-engine/blob/main/LICENSE)
5
+
6
+ > **Practical, type-safe toolkit for simulating and understanding gacha rates and rate-ups.**
7
+ >
8
+ > Works in Node.js, browsers โ€“ supports ESM, CommonJS, and UMD
9
+
10
+ <!-- omit from toc -->
11
+ ## ๐Ÿ”– Table of Contents
12
+ - [โœจ Features](#-features)
13
+ - [๐Ÿ› ๏ธ Installation](#๏ธ-installation)
14
+ - [๐Ÿš€ Quick Usage Examples](#-quick-usage-examples)
15
+ - [๐Ÿ“˜ API](#-api)
16
+ - [๐Ÿงช Tests](#-tests)
17
+ - [๐Ÿ”— Related Projects](#-related-projects)
18
+ - [๐Ÿค Contributing](#-contributing)
19
+
20
+
21
+ ## โœจ Features
22
+ - ๐Ÿ” Determine how many rolls are needed to reach a target probability for an item
23
+ - ๐Ÿ“ Estimate cumulative probabilities over multiple rolls
24
+ - โšก Lightweight and fast rarity probabilities
25
+
26
+ ## ๐Ÿ› ๏ธ Installation
27
+ ```bash
28
+ # Yarn
29
+ yarn add @allemandi/gacha-engine
30
+
31
+ # NPM
32
+ npm install @allemandi/gacha-engine
33
+ ```
34
+
35
+
36
+ ## ๐Ÿš€ Quick Usage Examples
37
+
38
+ **ESM**
39
+ ```js
40
+ import { GachaEngine } from '@allemandi/gacha-engine';
41
+
42
+ const pools = [
43
+ {
44
+ rarity: 'SSR',
45
+ items: [
46
+ { name: 'Ultra Sword', probability: 0.01, rateUp: true },
47
+ { name: 'Magic Wand', probability: 0.02 },
48
+ { name: 'Useless SSR Loot', probability: 0.97 }
49
+ ]
50
+ },
51
+ {
52
+ rarity: 'SR',
53
+ items: [
54
+ { name: 'Steel Shield', probability: 0.1 },
55
+ { name: 'Healing Potion', probability: 0.2 }
56
+ ]
57
+ }
58
+ ];
59
+
60
+ const rarityRates = {
61
+ SSR: 0.05,
62
+ SR: 0.3,
63
+ };
64
+
65
+ const engine = new GachaEngine({ pools, rarityRates });
66
+
67
+ const dropRate = engine.getItemDropRate('Ultra Sword');
68
+ console.log(`Drop rate for Ultra Sword: ${dropRate}`);
69
+
70
+ const cumulativeProb = engine.getCumulativeProbabilityForItem('Ultra Sword', 10);
71
+ console.log(`Probability of getting Ultra Sword in 10 rolls: ${cumulativeProb}`);
72
+
73
+ const rollsNeeded = engine.getRollsForTargetProbability('Ultra Sword', 0.9);
74
+ console.log(`Rolls needed for 90% chance: ${rollsNeeded}`);
75
+
76
+ const rateUpItems = engine.getRateUpItems();
77
+ console.log(`Current rate-up items: ${rateUpItems.join(', ')}`);
78
+ ```
79
+
80
+ **CommonJS**
81
+ ```js
82
+ const { GachaEngine } = require('@allemandi/gacha-engine');
83
+ ```
84
+
85
+ **UMD**
86
+ ```html
87
+ <script src="https://unpkg.com/@allemandi/gacha-engine"></script>
88
+ <script>
89
+ const engine = new window.AllemandiGachaEngine.GachaEngine({
90
+ pools: [
91
+ {
92
+ rarity: '5โ˜…',
93
+ items: [{ name: 'Rate Up Character', probability: 0.008 }]
94
+ }
95
+ ]
96
+ });
97
+
98
+ console.log('Rate up:', engine.getItemDropRate('Rate Up Character'));
99
+ // Rate up: 0.008
100
+ </script>
101
+ ```
102
+
103
+ ## ๐Ÿ“˜ API
104
+ `new GachaEngine(config: GachaEngineConfig)`
105
+
106
+ Creates a new GachaEngine instance.
107
+
108
+ - `config.pools`: Array of item pools, each with a rarity and list of items (each with name, probability, optional `rateUp` flag).
109
+
110
+ - `config.rarityRates`: Optional object mapping rarities to base probabilities.
111
+
112
+ ### Methods
113
+ `getItemDropRate(name: string): number`
114
+ - Returns the effective drop rate of an item by name.
115
+
116
+ `getRarityProbability(rarity: string): number`
117
+ - Returns the base probability assigned to a rarity pool.
118
+
119
+ `getCumulativeProbabilityForItem(name: string, rolls: number): number`
120
+ - Returns the probability of obtaining the specified item at least once within the given number of rolls.
121
+
122
+ `getRollsForTargetProbability(name: string, targetProbability: number): number`
123
+ - Returns the minimum number of rolls needed to reach or exceed the target probability for the specified item.
124
+
125
+ `getRateUpItems(): string[]`
126
+ - Returns a list of all item names currently marked as rate-up.
127
+
128
+ `getAllItemDropRates(): { name: string; dropRate: number; rarity: string }[]`
129
+ - Returns an array of all items with their calculated drop rates and rarities.
130
+
131
+ ## ๐Ÿงช Tests
132
+
133
+ > Available in the GitHub repo only.
134
+
135
+ ```bash
136
+ # Run the test suite with Jest
137
+ yarn test
138
+ # or
139
+ npm test
140
+ ```
141
+
142
+ ## ๐Ÿ”— Related Projects
143
+ Check out these related projects that might interest you:
144
+
145
+ **[@allemandi/embed-utils](https://github.com/allemandi/embed-utils)**
146
+ - Fast, type-safe utilities for vector embedding comparison and search.
147
+
148
+ **[Embed Classify CLI](https://github.com/allemandi/embed-classify-cli)**
149
+ - Node.js CLI tool for local text classification using word embeddings.
150
+
151
+ ## ๐Ÿค Contributing
152
+ If you have ideas, improvements, or new features:
153
+
154
+ 1. Fork the project
155
+ 2. Create your feature branch (git checkout -b feature/amazing-feature)
156
+ 3. Commit your changes (git commit -m 'Add some amazing feature')
157
+ 4. Push to the branch (git push origin feature/amazing-feature)
158
+ 5. Open a Pull Request
package/dist/index.cjs ADDED
@@ -0,0 +1,2 @@
1
+ function t(t,r){(null==r||r>t.length)&&(r=t.length);for(var e=0,n=Array(r);e<r;e++)n[e]=t[e];return n}exports.GachaEngine=/*#__PURE__*/function(){function r(t){var r=t.rarityRates,e=void 0===r?{}:r,n=t.pools;this.pools=void 0,this.rarityRates=void 0,this.pools=n,this.rarityRates=e}var e=r.prototype;return e.getItemDropRate=function(r){for(var e,n=function(r){var e="undefined"!=typeof Symbol&&r[Symbol.iterator]||r["@@iterator"];if(e)return(e=e.call(r)).next.bind(e);if(Array.isArray(r)||(e=function(r,e){if(r){if("string"==typeof r)return t(r,e);var n={}.toString.call(r).slice(8,-1);return"Object"===n&&r.constructor&&(n=r.constructor.name),"Map"===n||"Set"===n?Array.from(r):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?t(r,e):void 0}}(r))){e&&(r=e);var n=0;return function(){return n>=r.length?{done:!0}:{done:!1,value:r[n++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}(this.pools);!(e=n()).done;){var o=e.value,i=o.items.find(function(t){return t.name===r});if(i){var a,u=o.items.reduce(function(t,r){return t+r.probability},0),l=null!=(a=this.rarityRates[o.rarity])?a:u;return i.probability/u*l}}throw new Error('Item "'+r+'" not found')},e.getRarityProbability=function(t){var r,e=this.pools.find(function(r){return r.rarity===t});if(!e)throw new Error('Rarity "'+t+'" not found');var n=e.items.reduce(function(t,r){return t+r.probability},0);return null!=(r=this.rarityRates[t])?r:n},e.getCumulativeProbabilityForItem=function(t,r){var e=this.getItemDropRate(t);return 1-Math.pow(1-e,r)},e.getRollsForTargetProbability=function(t,r){var e=this.getItemDropRate(t);return e<=0?Infinity:Math.ceil(Math.log(1-r)/Math.log(1-e))},e.getRateUpItems=function(){return this.pools.flatMap(function(t){return t.items.filter(function(t){return t.rateUp}).map(function(t){return t.name})})},e.getAllItemDropRates=function(){var t=this;return this.pools.flatMap(function(r){return r.items.map(function(e){return{name:e.name,dropRate:t.getItemDropRate(e.name),rarity:r.rarity}})})},r}();
2
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1,34 @@
1
+ import { defineConfig } from "eslint/config";
2
+ import typescriptEslint from "@typescript-eslint/eslint-plugin";
3
+ import tsParser from "@typescript-eslint/parser";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import js from "@eslint/js";
7
+ import { FlatCompat } from "@eslint/eslintrc";
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ const compat = new FlatCompat({
12
+ baseDirectory: __dirname,
13
+ recommendedConfig: js.configs.recommended,
14
+ allConfig: js.configs.all
15
+ });
16
+
17
+ export default defineConfig([{
18
+
19
+ ignores: ["dist/**"],
20
+
21
+ extends: compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"),
22
+
23
+ plugins: {
24
+ "@typescript-eslint": typescriptEslint,
25
+ },
26
+
27
+ languageOptions: {
28
+ parser: tsParser,
29
+ ecmaVersion: 2020,
30
+ sourceType: "module",
31
+ },
32
+
33
+ rules: {},
34
+ }]);
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@allemandi/gacha-engine",
3
+ "version": "0.1.0",
4
+ "description": "Practical, type-safe toolkit for simulating and understanding gacha rates and rate-ups.",
5
+ "main": "dist/index.cjs",
6
+ "module": "dist/index.module.js",
7
+ "types": "./dist/index.d.ts",
8
+ "unpkg": "dist/index.umd.js",
9
+ "source": "src/index.ts",
10
+ "repository": "https://github.com/allemandi/gacha-engine.git",
11
+ "author": "allemandi <69766017+allemandi@users.noreply.github.com>",
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "scripts": {
15
+ "build": "microbundle --name AllemandiGachaEngine",
16
+ "test": "vitest run",
17
+ "dev:test": "vitest",
18
+ "lint": "eslint src --ext .ts"
19
+ },
20
+ "keywords": [
21
+ "gacha",
22
+ "gacha-engine",
23
+ "rate-up",
24
+ "probability",
25
+ "simulation",
26
+ "gacha-simulator",
27
+ "lootbox",
28
+ "random-drop",
29
+ "gaming",
30
+ "game-probability",
31
+ "rolls",
32
+ "typescript",
33
+ "type-safe",
34
+ "drop-rate"
35
+ ],
36
+ "devDependencies": {
37
+ "@typescript-eslint/eslint-plugin": "^8.35.0",
38
+ "@typescript-eslint/parser": "^8.35.0",
39
+ "eslint": "^9.29.0",
40
+ "microbundle": "^0.15.1",
41
+ "typescript": "^5.8.3",
42
+ "vitest": "^3.2.4"
43
+ }
44
+ }
@@ -0,0 +1,58 @@
1
+ import { RarityInput, GachaEngineConfig } from './types';
2
+ export class GachaEngine {
3
+ private pools: RarityInput[];
4
+ private rarityRates: Record<string, number>;
5
+
6
+ constructor({ rarityRates = {}, pools }: GachaEngineConfig) {
7
+ this.pools = pools;
8
+ this.rarityRates = rarityRates;
9
+ }
10
+
11
+ getItemDropRate(name: string): number {
12
+ for (const pool of this.pools) {
13
+ const item = pool.items.find(i => i.name === name);
14
+ if (item) {
15
+ const totalPoolProb = pool.items.reduce((sum, i) => sum + i.probability, 0);
16
+ const baseRarityRate = this.rarityRates[pool.rarity] ?? totalPoolProb;
17
+ return (item.probability / totalPoolProb) * baseRarityRate;
18
+ }
19
+ }
20
+ throw new Error(`Item "${name}" not found`);
21
+ }
22
+
23
+ getRarityProbability(rarity: string): number {
24
+ const pool = this.pools.find(p => p.rarity === rarity);
25
+ if (!pool) throw new Error(`Rarity "${rarity}" not found`);
26
+
27
+ const totalProb = pool.items.reduce((sum, i) => sum + i.probability, 0);
28
+ const baseRate = this.rarityRates[rarity] ?? totalProb;
29
+ return baseRate;
30
+ }
31
+
32
+ getCumulativeProbabilityForItem(name: string, rolls: number): number {
33
+ const rate = this.getItemDropRate(name);
34
+ return 1 - Math.pow(1 - rate, rolls);
35
+ }
36
+
37
+ getRollsForTargetProbability(name: string, targetProbability: number): number {
38
+ const rate = this.getItemDropRate(name);
39
+ if (rate <= 0) return Infinity;
40
+ return Math.ceil(Math.log(1 - targetProbability) / Math.log(1 - rate));
41
+ }
42
+
43
+ getRateUpItems(): string[] {
44
+ return this.pools.flatMap(p =>
45
+ p.items.filter(i => i.rateUp).map(i => i.name)
46
+ );
47
+ }
48
+
49
+ getAllItemDropRates(): { name: string; dropRate: number; rarity: string }[] {
50
+ return this.pools.flatMap(p =>
51
+ p.items.map(i => ({
52
+ name: i.name,
53
+ dropRate: this.getItemDropRate(i.name),
54
+ rarity: p.rarity
55
+ }))
56
+ );
57
+ }
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { GachaEngine } from "./gacha-engine";
package/src/types.ts ADDED
@@ -0,0 +1,16 @@
1
+
2
+ export interface RarityInput {
3
+ rarity: string;
4
+ items: GachaItem[];
5
+ }
6
+
7
+ export interface GachaItem {
8
+ name: string;
9
+ probability: number;
10
+ rateUp?: boolean;
11
+ }
12
+
13
+ export interface GachaEngineConfig {
14
+ rarityRates?: Record<string, number>;
15
+ pools: RarityInput[];
16
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { GachaEngine } from '../src/gacha-engine';
3
+ import type { RarityInput, GachaEngineConfig } from '../src/types';
4
+
5
+ const mockPools: RarityInput[] = [
6
+ {
7
+ rarity: 'common',
8
+ items: [
9
+ { name: 'ItemA', probability: 0.5 },
10
+ { name: 'ItemB', probability: 0.5 },
11
+ ],
12
+ },
13
+ {
14
+ rarity: 'rare',
15
+ items: [
16
+ { name: 'ItemC', probability: 0.7 },
17
+ { name: 'ItemD', probability: 0.3, rateUp: true },
18
+ ],
19
+ },
20
+ ];
21
+
22
+ const config: GachaEngineConfig = {
23
+ rarityRates: {
24
+ common: 0.8,
25
+ rare: 0.2,
26
+ },
27
+ pools: mockPools,
28
+ };
29
+
30
+ describe('GachaEngine', () => {
31
+ const engine = new GachaEngine(config);
32
+
33
+ describe('getItemDropRate', () => {
34
+ it('should return correct drop rate using rarityRates', () => {
35
+ expect(engine.getItemDropRate('ItemA')).toBeCloseTo(0.4); // 0.5 * 0.8
36
+ expect(engine.getItemDropRate('ItemD')).toBeCloseTo(0.06); // 0.3 * 0.2
37
+ });
38
+
39
+ it('throws if item does not exist', () => {
40
+ expect(() => engine.getItemDropRate('Unknown')).toThrow('Item "Unknown" not found');
41
+ });
42
+ });
43
+
44
+ describe('getRarityProbability', () => {
45
+ it('should return correct base rarity rate', () => {
46
+ expect(engine.getRarityProbability('common')).toBe(0.8);
47
+ expect(engine.getRarityProbability('rare')).toBe(0.2);
48
+ });
49
+
50
+ it('throws if rarity not found', () => {
51
+ expect(() => engine.getRarityProbability('epic')).toThrow('Rarity "epic" not found');
52
+ });
53
+ });
54
+
55
+ describe('getCumulativeProbabilityForItem', () => {
56
+ it('should calculate cumulative probability correctly', () => {
57
+ const dropRate = engine.getItemDropRate('ItemA'); // 0.4
58
+ const rolls = 3;
59
+ const expected = 1 - Math.pow(1 - dropRate, rolls);
60
+ expect(engine.getCumulativeProbabilityForItem('ItemA', rolls)).toBeCloseTo(expected);
61
+ });
62
+ });
63
+
64
+ describe('getRollsForTargetProbability', () => {
65
+ it('should calculate rolls to reach target probability', () => {
66
+ const target = 0.9;
67
+ const rate = engine.getItemDropRate('ItemA'); // 0.4
68
+ const expected = Math.ceil(Math.log(1 - target) / Math.log(1 - rate));
69
+ expect(engine.getRollsForTargetProbability('ItemA', target)).toBe(expected);
70
+ });
71
+
72
+ it('returns Infinity if drop rate is zero', () => {
73
+ const zeroRateEngine = new GachaEngine({
74
+ pools: [{
75
+ rarity: 'none',
76
+ items: [
77
+ { name: 'NeverDrops', probability: 0 },
78
+ { name: 'Other', probability: 1 },
79
+ ],
80
+ }],
81
+ });
82
+
83
+ expect(zeroRateEngine.getRollsForTargetProbability('NeverDrops', 0.5)).toBe(Infinity);
84
+ });
85
+ });
86
+
87
+ describe('getRateUpItems', () => {
88
+ it('returns only rate-up item names', () => {
89
+ expect(engine.getRateUpItems()).toEqual(['ItemD']);
90
+ });
91
+
92
+ it('returns empty array if no rate-up items', () => {
93
+ const noRateUpEngine = new GachaEngine({
94
+ pools: [{
95
+ rarity: 'common',
96
+ items: [{ name: 'NoRateUp', probability: 1 }],
97
+ }],
98
+ });
99
+ expect(noRateUpEngine.getRateUpItems()).toEqual([]);
100
+ });
101
+ });
102
+
103
+ describe('getAllItemDropRates', () => {
104
+ it('returns all items with correct drop rates and rarities', () => {
105
+ const results = engine.getAllItemDropRates();
106
+ expect(results).toContainEqual({ name: 'ItemA', dropRate: 0.4, rarity: 'common' });
107
+ expect(results).toContainEqual({ name: 'ItemD', dropRate: 0.06, rarity: 'rare' });
108
+ expect(results).toHaveLength(4);
109
+ });
110
+ });
111
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Node",
6
+ "declaration": true,
7
+ "outDir": "dist",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true
12
+ },
13
+ "include": ["src"],
14
+ "exclude": ["node_modules", "dist", "test"]
15
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node'
7
+ }
8
+ })