@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.
- package/.github/workflows/ci.yml +42 -0
- package/.github/workflows/publish.yml +26 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/index.cjs +2 -0
- package/eslint.config.mjs +34 -0
- package/package.json +44 -0
- package/src/gacha-engine.ts +58 -0
- package/src/index.ts +1 -0
- package/src/types.ts +16 -0
- package/test/index.test.ts +111 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +8 -0
|
@@ -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
|
+
[](https://www.npmjs.com/package/@allemandi/gacha-engine)
|
|
4
|
+
[](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
|
+
}
|