elo-mmr-kit 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/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # elo-mmr-kit
2
+
3
+ [![npm version](https://img.shields.io/npm/v/elo-mmr-kit.svg?style=flat-square)](https://www.npmjs.com/package/elo-mmr-kit)
4
+ [![CI](https://github.com/Koval09/elo-mmr-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/Koval09/elo-mmr-kit/actions)
5
+
6
+ A zero-dependency, fully configurable Elo/MMR rating and matchmaking toolkit for games.
7
+ It contains no hardcoded values and is completely stateless — storage is entirely your responsibility.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install elo-mmr-kit
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ```typescript
18
+ import { calculateElo } from "elo-mmr-kit";
19
+
20
+ const result = calculateElo({
21
+ playerA: { rating: 1500, gamesPlayed: 5 },
22
+ playerB: { rating: 1400, gamesPlayed: 12 },
23
+ outcome: "A", // "A" | "B" | "draw"
24
+ config: { kFactor: 32 }
25
+ });
26
+
27
+ console.log(result.playerA.rating); // 1512 (gained +12 rating)
28
+ console.log(result.playerA.delta); // 12
29
+ ```
30
+
31
+ ## Configuration Reference
32
+
33
+ ### `EloConfig` Options
34
+
35
+ | Option | Type | Default | Description |
36
+ | :--- | :--- | :--- | :--- |
37
+ | `kFactor` | `number` \| `Array<[number, number]>` \| `(p: PlayerState) => number` | **Required** | The K-factor value, a list of `[minGames, k]` threshold pairs, or a custom resolver function. |
38
+ | `divisor` | `number` | `400` | The scale divisor for probability curves (higher = slower difference scaling). |
39
+ | `minRating` | `number` | `0` | The rating floor. Calculated ratings will never drop below this floor. |
40
+ | `maxRating` | `number` | `undefined` | The optional rating ceiling. Calculated ratings will never exceed this ceiling. |
41
+ | `roundTo` | `"integer"` \| `"none"` | `"integer"` | Rounding method applied to ratings. `"integer"` uses standard rounding; `"none"` keeps floating decimals. |
42
+
43
+ ### Helper Functions
44
+
45
+ * `resolveKFactor(player: PlayerState, kFactor: number | KFactorTable | KFactorFn): number` — Resolves the K-factor value for a player based on their current rating/gamesPlayed state and configuration.
46
+
47
+ ---
48
+
49
+ ## Example: Telegram PvP game
50
+
51
+ Here is a realistic example demonstrating custom K-factor tiers, seasonal soft resets, and matchmaking pool expansion:
52
+
53
+ ```typescript
54
+ import {
55
+ calculateElo,
56
+ createKFactorTable,
57
+ softReset,
58
+ MatchmakingPool,
59
+ MatchmakingEntry
60
+ } from "elo-mmr-kit";
61
+
62
+ interface TelegramPlayer extends MatchmakingEntry {
63
+ username: string;
64
+ gamesPlayed: number;
65
+ }
66
+
67
+ // 1. Dynamic K-factors: [minGamesPlayed, kFactor]
68
+ const kFactorResolver = createKFactorTable([
69
+ [0, 50], // Placements / new players
70
+ [10, 30], // Casual players
71
+ [50, 20], // Experienced players
72
+ [100, 10] // Veterans
73
+ ]);
74
+
75
+ // 2. Seasonal Soft Reset (floor: 1200, scale factor: 0.5)
76
+ const oldRating = 1600;
77
+ const resetRating = softReset(oldRating, { floor: 1200, factor: 0.5 });
78
+ console.log(resetRating); // 1400
79
+
80
+ // 3. Matchmaking Queue (initial window 50, expands by 25 every 5 seconds)
81
+ const queue = new MatchmakingPool<TelegramPlayer>({
82
+ initialWindow: 50,
83
+ expandBy: 25,
84
+ expandEveryMs: 5000
85
+ });
86
+
87
+ // Add players who joined at t = 0ms
88
+ queue.add({ id: "1", rating: 1500, gamesPlayed: 5, joinedAt: 0, username: "alex" });
89
+ queue.add({ id: "2", rating: 1580, gamesPlayed: 12, joinedAt: 0, username: "boris" });
90
+
91
+ // At t = 0s, window is 50. Difference is 80, so they don't match
92
+ console.log(queue.findMatch("1", 0)); // null
93
+
94
+ // At t = 10s (10000ms), window expands to 50 + 2 * 25 = 100. They match!
95
+ const opponent = queue.findMatch("1", 10000);
96
+ console.log(opponent?.username); // "boris"
97
+
98
+ if (opponent) {
99
+ queue.remove("1");
100
+ queue.remove(opponent.id);
101
+
102
+ // Update ratings after "alex" wins
103
+ const result = calculateElo({
104
+ playerA: { rating: 1500, gamesPlayed: 5 },
105
+ playerB: { rating: opponent.rating, gamesPlayed: opponent.gamesPlayed },
106
+ outcome: "A",
107
+ config: { kFactor: kFactorResolver, minRating: 1000 }
108
+ });
109
+
110
+ console.log(result.playerA.rating); // 1531 (gained +31 rating)
111
+ }
112
+ ```
113
+
114
+ > [!NOTE]
115
+ > **Matchmaking Asymmetry:** Matchmaking searches are directed and asymmetric. A player who has been waiting in the queue longer has a wider search window and can match with a newer player, while that newer player might not match with the older player yet. Always remove both players from the pool once a match is found from either player's perspective.
116
+
117
+ ---
118
+
119
+ ## FAQ
120
+
121
+ ### Why zero dependencies?
122
+ To keep the toolkit extremely lightweight, secure, and fast. It compiles to tiny ESM and CommonJS bundles, running anywhere without bloating your `node_modules` — from edge environments (like Cloudflare Workers) to browsers and backend servers.
123
+
124
+ ### Where do I store ratings?
125
+ Since all functions are pure and the matchmaking pool is an in-memory data structure, the library has no database connection or I/O operations. Persisting player ratings and queue states is entirely up to you. You can use PostgreSQL, Redis, MongoDB, Firebase, DynamoDB, or any storage engine of your choice.
126
+
127
+ ## License
128
+
129
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,255 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ MatchmakingPool: () => MatchmakingPool,
24
+ calculateElo: () => calculateElo,
25
+ createKFactorTable: () => createKFactorTable,
26
+ resolveKFactor: () => resolveKFactor,
27
+ softReset: () => softReset
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/kfactor.ts
32
+ function createKFactorTable(tiers) {
33
+ if (tiers.length === 0) {
34
+ throw new Error("K-factor table must have at least one tier");
35
+ }
36
+ const sortedTiers = [...tiers].sort((a, b) => b[0] - a[0]);
37
+ return (player) => {
38
+ if (player.gamesPlayed === void 0) {
39
+ throw new Error("gamesPlayed is required when using a dynamic K-factor table");
40
+ }
41
+ for (const [minGames, k] of sortedTiers) {
42
+ if (player.gamesPlayed >= minGames) {
43
+ return k;
44
+ }
45
+ }
46
+ return sortedTiers[sortedTiers.length - 1][1];
47
+ };
48
+ }
49
+
50
+ // src/rating.ts
51
+ function resolveKFactor(player, kFactor) {
52
+ if (typeof kFactor === "number") {
53
+ return kFactor;
54
+ }
55
+ if (player.gamesPlayed === void 0) {
56
+ throw new Error("gamesPlayed is required when using a dynamic K-factor");
57
+ }
58
+ if (typeof kFactor === "function") {
59
+ return kFactor(player);
60
+ }
61
+ if (Array.isArray(kFactor)) {
62
+ return createKFactorTable(kFactor)(player);
63
+ }
64
+ throw new Error("Invalid kFactor configuration");
65
+ }
66
+ function calculateElo(input) {
67
+ const { playerA, playerB, outcome, config } = input;
68
+ const divisor = config.divisor ?? 400;
69
+ if (divisor <= 0) {
70
+ throw new Error(`divisor must be greater than 0, received ${divisor}`);
71
+ }
72
+ const minRating = config.minRating ?? 0;
73
+ const maxRating = config.maxRating;
74
+ if (maxRating !== void 0 && minRating > maxRating) {
75
+ throw new Error(`minRating (${minRating}) cannot be greater than maxRating (${maxRating})`);
76
+ }
77
+ const roundTo = config.roundTo ?? "integer";
78
+ const expectedScoreA = 1 / (1 + Math.pow(10, (playerB.rating - playerA.rating) / divisor));
79
+ const expectedScoreB = 1 - expectedScoreA;
80
+ let scoreA = 0.5;
81
+ let scoreB = 0.5;
82
+ if (outcome === "A") {
83
+ scoreA = 1;
84
+ scoreB = 0;
85
+ } else if (outcome === "B") {
86
+ scoreA = 0;
87
+ scoreB = 1;
88
+ }
89
+ const kA = resolveKFactor(playerA, config.kFactor);
90
+ const kB = resolveKFactor(playerB, config.kFactor);
91
+ let rawNewA = playerA.rating + kA * (scoreA - expectedScoreA);
92
+ let rawNewB = playerB.rating + kB * (scoreB - expectedScoreB);
93
+ if (roundTo === "integer") {
94
+ rawNewA = Math.round(rawNewA);
95
+ rawNewB = Math.round(rawNewB);
96
+ }
97
+ if (rawNewA < minRating) rawNewA = minRating;
98
+ if (rawNewB < minRating) rawNewB = minRating;
99
+ if (maxRating !== void 0) {
100
+ if (rawNewA > maxRating) rawNewA = maxRating;
101
+ if (rawNewB > maxRating) rawNewB = maxRating;
102
+ }
103
+ if (rawNewA === 0) rawNewA = 0;
104
+ if (rawNewB === 0) rawNewB = 0;
105
+ let deltaA = rawNewA - playerA.rating;
106
+ let deltaB = rawNewB - playerB.rating;
107
+ if (deltaA === 0) deltaA = 0;
108
+ if (deltaB === 0) deltaB = 0;
109
+ return {
110
+ playerA: {
111
+ rating: rawNewA,
112
+ delta: deltaA
113
+ },
114
+ playerB: {
115
+ rating: rawNewB,
116
+ delta: deltaB
117
+ },
118
+ expectedScoreA
119
+ };
120
+ }
121
+
122
+ // src/season.ts
123
+ function softReset(rating, options) {
124
+ if (typeof options === "function") {
125
+ return options(rating);
126
+ }
127
+ const { floor, factor } = options;
128
+ if (factor < 0 || factor > 1) {
129
+ throw new Error(`factor must be between 0 and 1, received ${factor}`);
130
+ }
131
+ const resetRating = floor + (rating - floor) * factor;
132
+ return Math.max(floor, resetRating);
133
+ }
134
+
135
+ // src/matchmaking.ts
136
+ var MatchmakingPool = class {
137
+ pool = /* @__PURE__ */ new Map();
138
+ initialWindow;
139
+ expandBy;
140
+ expandEveryMs;
141
+ maxWindow;
142
+ /**
143
+ * Constructs a new MatchmakingPool.
144
+ *
145
+ * @param config Configuration for the matchmaking pool window.
146
+ */
147
+ constructor(config) {
148
+ this.initialWindow = config.initialWindow;
149
+ this.expandBy = config.expandBy;
150
+ this.expandEveryMs = config.expandEveryMs;
151
+ this.maxWindow = config.maxWindow;
152
+ }
153
+ /**
154
+ * Adds a player to the matchmaking pool.
155
+ * If joinedAt is not provided, it defaults to the current timestamp.
156
+ *
157
+ * @param entry The matchmaking entry to add.
158
+ */
159
+ add(entry) {
160
+ const joinedAt = entry.joinedAt ?? Date.now();
161
+ this.pool.set(entry.id, {
162
+ entry,
163
+ joinedAt
164
+ });
165
+ }
166
+ /**
167
+ * Removes a player from the matchmaking pool.
168
+ *
169
+ * @param id The ID of the player to remove.
170
+ * @returns true if the player was found and removed, false otherwise.
171
+ */
172
+ remove(id) {
173
+ return this.pool.delete(id);
174
+ }
175
+ /**
176
+ * Finds the best opponent for a player within their current rating window.
177
+ *
178
+ * The rating window expands over time based on wait time.
179
+ * The "best" opponent is defined as:
180
+ * 1. The opponent with the smallest rating difference.
181
+ * 2. (Tie-breaker) The opponent who has been waiting the longest (earliest joinedAt).
182
+ * 3. (Tie-breaker) The opponent with the lexicographically smaller ID.
183
+ *
184
+ * This query is deterministic given the same inputs and does not modify the pool.
185
+ *
186
+ * @param id The ID of the player searching for a match.
187
+ * @param now The current timestamp. Defaults to Date.now().
188
+ * @returns The best opponent entry, or null if no opponent is within the window.
189
+ */
190
+ findMatch(id, now) {
191
+ const wrapper = this.pool.get(id);
192
+ if (!wrapper) {
193
+ return null;
194
+ }
195
+ const player = wrapper.entry;
196
+ const currentNow = now ?? Date.now();
197
+ const waitTime = Math.max(0, currentNow - wrapper.joinedAt);
198
+ let windowSize = this.initialWindow;
199
+ if (this.expandEveryMs > 0) {
200
+ windowSize += Math.floor(waitTime / this.expandEveryMs) * this.expandBy;
201
+ }
202
+ if (this.maxWindow != null && windowSize > this.maxWindow) {
203
+ windowSize = this.maxWindow;
204
+ }
205
+ let bestOpponentWrapper = null;
206
+ let minDiff = Infinity;
207
+ for (const candidateWrapper of this.pool.values()) {
208
+ const candidate = candidateWrapper.entry;
209
+ if (candidate.id === id) {
210
+ continue;
211
+ }
212
+ const diff = Math.abs(candidate.rating - player.rating);
213
+ if (diff <= windowSize) {
214
+ if (diff < minDiff) {
215
+ minDiff = diff;
216
+ bestOpponentWrapper = candidateWrapper;
217
+ } else if (diff === minDiff && bestOpponentWrapper) {
218
+ const candidateJoinedAt = candidateWrapper.joinedAt;
219
+ const bestJoinedAt = bestOpponentWrapper.joinedAt;
220
+ if (candidateJoinedAt < bestJoinedAt) {
221
+ bestOpponentWrapper = candidateWrapper;
222
+ } else if (candidateJoinedAt === bestJoinedAt) {
223
+ if (candidate.id < bestOpponentWrapper.entry.id) {
224
+ bestOpponentWrapper = candidateWrapper;
225
+ }
226
+ }
227
+ }
228
+ }
229
+ }
230
+ return bestOpponentWrapper ? bestOpponentWrapper.entry : null;
231
+ }
232
+ /**
233
+ * Returns the current size of the matchmaking pool.
234
+ *
235
+ * @returns The number of players in the pool.
236
+ */
237
+ size() {
238
+ return this.pool.size;
239
+ }
240
+ /**
241
+ * Clears all players from the matchmaking pool.
242
+ */
243
+ clear() {
244
+ this.pool.clear();
245
+ }
246
+ };
247
+ // Annotate the CommonJS export names for ESM import in node:
248
+ 0 && (module.exports = {
249
+ MatchmakingPool,
250
+ calculateElo,
251
+ createKFactorTable,
252
+ resolveKFactor,
253
+ softReset
254
+ });
255
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/kfactor.ts","../src/rating.ts","../src/season.ts","../src/matchmaking.ts"],"sourcesContent":["/**\n * elo-mmr-kit: A zero-dependency, fully configurable Elo/MMR rating and matchmaking toolkit.\n */\n\nexport { calculateElo, resolveKFactor } from \"./rating.js\";\nexport { createKFactorTable } from \"./kfactor.js\";\nexport { softReset } from \"./season.js\";\nexport { MatchmakingPool } from \"./matchmaking.js\";\n\n// Export types\nexport * from \"./types.js\";\n","import { PlayerState, KFactorTable, KFactorFn } from \"./types.js\";\n\n/**\n * Creates a helper function that resolves a K-factor based on a lookup table\n * of [minGames, kFactor] thresholds.\n * \n * @param tiers An array of [minGames, kFactor] tuples.\n * @returns A function that accepts a PlayerState and returns the resolved K-factor.\n */\nexport function createKFactorTable(tiers: KFactorTable): KFactorFn {\n if (tiers.length === 0) {\n throw new Error(\"K-factor table must have at least one tier\");\n }\n\n // Sort tiers descending by minGames threshold\n const sortedTiers = [...tiers].sort((a, b) => b[0] - a[0]);\n\n return (player: PlayerState): number => {\n if (player.gamesPlayed === undefined) {\n throw new Error(\"gamesPlayed is required when using a dynamic K-factor table\");\n }\n for (const [minGames, k] of sortedTiers) {\n if (player.gamesPlayed >= minGames) {\n return k;\n }\n }\n // Default to the tier with the lowest minGames threshold if none match\n // (Usually this is the 0 threshold tier)\n return sortedTiers[sortedTiers.length - 1][1];\n };\n}\n","import { CalculateEloInput, CalculateEloResult, PlayerState, KFactorTable, KFactorFn } from \"./types.js\";\nimport { createKFactorTable } from \"./kfactor.js\";\n\n/**\n * Resolves the K-factor for a player based on the config.\n * \n * @param player The player state containing rating and gamesPlayed.\n * @param kFactor The K-factor configuration (number, lookup table, or custom function).\n * @returns The resolved K-factor as a number.\n */\nexport function resolveKFactor(\n player: PlayerState,\n kFactor: number | KFactorTable | KFactorFn\n): number {\n if (typeof kFactor === \"number\") {\n return kFactor;\n }\n if (player.gamesPlayed === undefined) {\n throw new Error(\"gamesPlayed is required when using a dynamic K-factor\");\n }\n if (typeof kFactor === \"function\") {\n return kFactor(player);\n }\n if (Array.isArray(kFactor)) {\n return createKFactorTable(kFactor)(player);\n }\n throw new Error(\"Invalid kFactor configuration\");\n}\n\n/**\n * Calculates new Elo ratings for two players after a match outcome.\n * This function is pure and stateless.\n * \n * @param input The match outcome and configuration details.\n * @returns The new ratings, rating changes, and the expected score of player A.\n */\nexport function calculateElo(input: CalculateEloInput): CalculateEloResult {\n const { playerA, playerB, outcome, config } = input;\n\n const divisor = config.divisor ?? 400;\n if (divisor <= 0) {\n throw new Error(`divisor must be greater than 0, received ${divisor}`);\n }\n\n const minRating = config.minRating ?? 0;\n const maxRating = config.maxRating;\n\n if (maxRating !== undefined && minRating > maxRating) {\n throw new Error(`minRating (${minRating}) cannot be greater than maxRating (${maxRating})`);\n }\n\n const roundTo = config.roundTo ?? \"integer\";\n\n // Calculate expected scores\n const expectedScoreA = 1 / (1 + Math.pow(10, (playerB.rating - playerA.rating) / divisor));\n const expectedScoreB = 1 - expectedScoreA;\n\n // Map outcome to actual scores\n let scoreA = 0.5;\n let scoreB = 0.5;\n if (outcome === \"A\") {\n scoreA = 1;\n scoreB = 0;\n } else if (outcome === \"B\") {\n scoreA = 0;\n scoreB = 1;\n }\n\n // Resolve K-factors\n const kA = resolveKFactor(playerA, config.kFactor);\n const kB = resolveKFactor(playerB, config.kFactor);\n\n // Compute raw new ratings\n let rawNewA = playerA.rating + kA * (scoreA - expectedScoreA);\n let rawNewB = playerB.rating + kB * (scoreB - expectedScoreB);\n\n // Apply rounding\n if (roundTo === \"integer\") {\n rawNewA = Math.round(rawNewA);\n rawNewB = Math.round(rawNewB);\n }\n\n // Apply minRating floor\n if (rawNewA < minRating) rawNewA = minRating;\n if (rawNewB < minRating) rawNewB = minRating;\n\n // Apply maxRating ceiling if configured\n if (maxRating !== undefined) {\n if (rawNewA > maxRating) rawNewA = maxRating;\n if (rawNewB > maxRating) rawNewB = maxRating;\n }\n\n // Normalize -0 to +0\n if (rawNewA === 0) rawNewA = 0;\n if (rawNewB === 0) rawNewB = 0;\n\n let deltaA = rawNewA - playerA.rating;\n let deltaB = rawNewB - playerB.rating;\n\n if (deltaA === 0) deltaA = 0;\n if (deltaB === 0) deltaB = 0;\n\n return {\n playerA: {\n rating: rawNewA,\n delta: deltaA,\n },\n playerB: {\n rating: rawNewB,\n delta: deltaB,\n },\n expectedScoreA,\n };\n}\n","import { SoftResetOptions, SoftResetFn } from \"./types.js\";\n\n/**\n * Performs a seasonal soft reset on a player's rating.\n * \n * If a config object is provided, computes: floor + (rating - floor) * factor.\n * The resulting rating will never go below the specified floor.\n * \n * Alternatively, a custom reset function can be passed.\n * \n * @param rating The current rating of the player.\n * @param options A SoftResetOptions object containing floor and factor, or a custom reset function.\n * @returns The reset rating.\n */\nexport function softReset(\n rating: number,\n options: SoftResetOptions | SoftResetFn\n): number {\n if (typeof options === \"function\") {\n return options(rating);\n }\n\n const { floor, factor } = options;\n if (factor < 0 || factor > 1) {\n throw new Error(`factor must be between 0 and 1, received ${factor}`);\n }\n const resetRating = floor + (rating - floor) * factor;\n return Math.max(floor, resetRating);\n}\n","import { MatchmakingPoolConfig, MatchmakingEntry } from \"./types.js\";\n\n/**\n * An in-memory matchmaking pool that is generic over the user's player type.\n * It allows adding, removing, and finding matches for players based on their rating\n * and a waiting window that expands over time.\n */\nexport class MatchmakingPool<T extends MatchmakingEntry = MatchmakingEntry> {\n private pool: Map<string, { entry: T; joinedAt: number }> = new Map();\n private initialWindow: number;\n private expandBy: number;\n private expandEveryMs: number;\n private maxWindow?: number;\n\n /**\n * Constructs a new MatchmakingPool.\n * \n * @param config Configuration for the matchmaking pool window.\n */\n constructor(config: MatchmakingPoolConfig) {\n this.initialWindow = config.initialWindow;\n this.expandBy = config.expandBy;\n this.expandEveryMs = config.expandEveryMs;\n this.maxWindow = config.maxWindow;\n }\n\n /**\n * Adds a player to the matchmaking pool.\n * If joinedAt is not provided, it defaults to the current timestamp.\n * \n * @param entry The matchmaking entry to add.\n */\n add(entry: T): void {\n const joinedAt = entry.joinedAt ?? Date.now();\n this.pool.set(entry.id, {\n entry,\n joinedAt,\n });\n }\n\n /**\n * Removes a player from the matchmaking pool.\n * \n * @param id The ID of the player to remove.\n * @returns true if the player was found and removed, false otherwise.\n */\n remove(id: string): boolean {\n return this.pool.delete(id);\n }\n\n /**\n * Finds the best opponent for a player within their current rating window.\n * \n * The rating window expands over time based on wait time.\n * The \"best\" opponent is defined as:\n * 1. The opponent with the smallest rating difference.\n * 2. (Tie-breaker) The opponent who has been waiting the longest (earliest joinedAt).\n * 3. (Tie-breaker) The opponent with the lexicographically smaller ID.\n * \n * This query is deterministic given the same inputs and does not modify the pool.\n * \n * @param id The ID of the player searching for a match.\n * @param now The current timestamp. Defaults to Date.now().\n * @returns The best opponent entry, or null if no opponent is within the window.\n */\n findMatch(id: string, now?: number): T | null {\n const wrapper = this.pool.get(id);\n if (!wrapper) {\n return null;\n }\n const player = wrapper.entry;\n\n const currentNow = now ?? Date.now();\n const waitTime = Math.max(0, currentNow - wrapper.joinedAt);\n\n // Calculate expanding search window\n let windowSize = this.initialWindow;\n if (this.expandEveryMs > 0) {\n windowSize += Math.floor(waitTime / this.expandEveryMs) * this.expandBy;\n }\n if (this.maxWindow != null && windowSize > this.maxWindow) {\n windowSize = this.maxWindow;\n }\n\n let bestOpponentWrapper: { entry: T; joinedAt: number } | null = null;\n let minDiff = Infinity;\n\n for (const candidateWrapper of this.pool.values()) {\n const candidate = candidateWrapper.entry;\n if (candidate.id === id) {\n continue;\n }\n\n const diff = Math.abs(candidate.rating - player.rating);\n if (diff <= windowSize) {\n if (diff < minDiff) {\n minDiff = diff;\n bestOpponentWrapper = candidateWrapper;\n } else if (diff === minDiff && bestOpponentWrapper) {\n // Tie-breaker 1: longest waiting time (earliest joinedAt)\n const candidateJoinedAt = candidateWrapper.joinedAt;\n const bestJoinedAt = bestOpponentWrapper.joinedAt;\n\n if (candidateJoinedAt < bestJoinedAt) {\n bestOpponentWrapper = candidateWrapper;\n } else if (candidateJoinedAt === bestJoinedAt) {\n // Tie-breaker 2: lexicographical order of id\n if (candidate.id < bestOpponentWrapper.entry.id) {\n bestOpponentWrapper = candidateWrapper;\n }\n }\n }\n }\n }\n\n return bestOpponentWrapper ? bestOpponentWrapper.entry : null;\n }\n\n /**\n * Returns the current size of the matchmaking pool.\n * \n * @returns The number of players in the pool.\n */\n size(): number {\n return this.pool.size;\n }\n\n /**\n * Clears all players from the matchmaking pool.\n */\n clear(): void {\n this.pool.clear();\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACSO,SAAS,mBAAmB,OAAgC;AACjE,MAAI,MAAM,WAAW,GAAG;AACtB,UAAM,IAAI,MAAM,4CAA4C;AAAA,EAC9D;AAGA,QAAM,cAAc,CAAC,GAAG,KAAK,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC;AAEzD,SAAO,CAAC,WAAgC;AACtC,QAAI,OAAO,gBAAgB,QAAW;AACpC,YAAM,IAAI,MAAM,6DAA6D;AAAA,IAC/E;AACA,eAAW,CAAC,UAAU,CAAC,KAAK,aAAa;AACvC,UAAI,OAAO,eAAe,UAAU;AAClC,eAAO;AAAA,MACT;AAAA,IACF;AAGA,WAAO,YAAY,YAAY,SAAS,CAAC,EAAE,CAAC;AAAA,EAC9C;AACF;;;ACpBO,SAAS,eACd,QACA,SACQ;AACR,MAAI,OAAO,YAAY,UAAU;AAC/B,WAAO;AAAA,EACT;AACA,MAAI,OAAO,gBAAgB,QAAW;AACpC,UAAM,IAAI,MAAM,uDAAuD;AAAA,EACzE;AACA,MAAI,OAAO,YAAY,YAAY;AACjC,WAAO,QAAQ,MAAM;AAAA,EACvB;AACA,MAAI,MAAM,QAAQ,OAAO,GAAG;AAC1B,WAAO,mBAAmB,OAAO,EAAE,MAAM;AAAA,EAC3C;AACA,QAAM,IAAI,MAAM,+BAA+B;AACjD;AASO,SAAS,aAAa,OAA8C;AACzE,QAAM,EAAE,SAAS,SAAS,SAAS,OAAO,IAAI;AAE9C,QAAM,UAAU,OAAO,WAAW;AAClC,MAAI,WAAW,GAAG;AAChB,UAAM,IAAI,MAAM,4CAA4C,OAAO,EAAE;AAAA,EACvE;AAEA,QAAM,YAAY,OAAO,aAAa;AACtC,QAAM,YAAY,OAAO;AAEzB,MAAI,cAAc,UAAa,YAAY,WAAW;AACpD,UAAM,IAAI,MAAM,cAAc,SAAS,uCAAuC,SAAS,GAAG;AAAA,EAC5F;AAEA,QAAM,UAAU,OAAO,WAAW;AAGlC,QAAM,iBAAiB,KAAK,IAAI,KAAK,IAAI,KAAK,QAAQ,SAAS,QAAQ,UAAU,OAAO;AACxF,QAAM,iBAAiB,IAAI;AAG3B,MAAI,SAAS;AACb,MAAI,SAAS;AACb,MAAI,YAAY,KAAK;AACnB,aAAS;AACT,aAAS;AAAA,EACX,WAAW,YAAY,KAAK;AAC1B,aAAS;AACT,aAAS;AAAA,EACX;AAGA,QAAM,KAAK,eAAe,SAAS,OAAO,OAAO;AACjD,QAAM,KAAK,eAAe,SAAS,OAAO,OAAO;AAGjD,MAAI,UAAU,QAAQ,SAAS,MAAM,SAAS;AAC9C,MAAI,UAAU,QAAQ,SAAS,MAAM,SAAS;AAG9C,MAAI,YAAY,WAAW;AACzB,cAAU,KAAK,MAAM,OAAO;AAC5B,cAAU,KAAK,MAAM,OAAO;AAAA,EAC9B;AAGA,MAAI,UAAU,UAAW,WAAU;AACnC,MAAI,UAAU,UAAW,WAAU;AAGnC,MAAI,cAAc,QAAW;AAC3B,QAAI,UAAU,UAAW,WAAU;AACnC,QAAI,UAAU,UAAW,WAAU;AAAA,EACrC;AAGA,MAAI,YAAY,EAAG,WAAU;AAC7B,MAAI,YAAY,EAAG,WAAU;AAE7B,MAAI,SAAS,UAAU,QAAQ;AAC/B,MAAI,SAAS,UAAU,QAAQ;AAE/B,MAAI,WAAW,EAAG,UAAS;AAC3B,MAAI,WAAW,EAAG,UAAS;AAE3B,SAAO;AAAA,IACL,SAAS;AAAA,MACP,QAAQ;AAAA,MACR,OAAO;AAAA,IACT;AAAA,IACA,SAAS;AAAA,MACP,QAAQ;AAAA,MACR,OAAO;AAAA,IACT;AAAA,IACA;AAAA,EACF;AACF;;;ACnGO,SAAS,UACd,QACA,SACQ;AACR,MAAI,OAAO,YAAY,YAAY;AACjC,WAAO,QAAQ,MAAM;AAAA,EACvB;AAEA,QAAM,EAAE,OAAO,OAAO,IAAI;AAC1B,MAAI,SAAS,KAAK,SAAS,GAAG;AAC5B,UAAM,IAAI,MAAM,4CAA4C,MAAM,EAAE;AAAA,EACtE;AACA,QAAM,cAAc,SAAS,SAAS,SAAS;AAC/C,SAAO,KAAK,IAAI,OAAO,WAAW;AACpC;;;ACrBO,IAAM,kBAAN,MAAqE;AAAA,EAClE,OAAoD,oBAAI,IAAI;AAAA,EAC5D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOR,YAAY,QAA+B;AACzC,SAAK,gBAAgB,OAAO;AAC5B,SAAK,WAAW,OAAO;AACvB,SAAK,gBAAgB,OAAO;AAC5B,SAAK,YAAY,OAAO;AAAA,EAC1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,IAAI,OAAgB;AAClB,UAAM,WAAW,MAAM,YAAY,KAAK,IAAI;AAC5C,SAAK,KAAK,IAAI,MAAM,IAAI;AAAA,MACtB;AAAA,MACA;AAAA,IACF,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,OAAO,IAAqB;AAC1B,WAAO,KAAK,KAAK,OAAO,EAAE;AAAA,EAC5B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiBA,UAAU,IAAY,KAAwB;AAC5C,UAAM,UAAU,KAAK,KAAK,IAAI,EAAE;AAChC,QAAI,CAAC,SAAS;AACZ,aAAO;AAAA,IACT;AACA,UAAM,SAAS,QAAQ;AAEvB,UAAM,aAAa,OAAO,KAAK,IAAI;AACnC,UAAM,WAAW,KAAK,IAAI,GAAG,aAAa,QAAQ,QAAQ;AAG1D,QAAI,aAAa,KAAK;AACtB,QAAI,KAAK,gBAAgB,GAAG;AAC1B,oBAAc,KAAK,MAAM,WAAW,KAAK,aAAa,IAAI,KAAK;AAAA,IACjE;AACA,QAAI,KAAK,aAAa,QAAQ,aAAa,KAAK,WAAW;AACzD,mBAAa,KAAK;AAAA,IACpB;AAEA,QAAI,sBAA6D;AACjE,QAAI,UAAU;AAEd,eAAW,oBAAoB,KAAK,KAAK,OAAO,GAAG;AACjD,YAAM,YAAY,iBAAiB;AACnC,UAAI,UAAU,OAAO,IAAI;AACvB;AAAA,MACF;AAEA,YAAM,OAAO,KAAK,IAAI,UAAU,SAAS,OAAO,MAAM;AACtD,UAAI,QAAQ,YAAY;AACtB,YAAI,OAAO,SAAS;AAClB,oBAAU;AACV,gCAAsB;AAAA,QACxB,WAAW,SAAS,WAAW,qBAAqB;AAElD,gBAAM,oBAAoB,iBAAiB;AAC3C,gBAAM,eAAe,oBAAoB;AAEzC,cAAI,oBAAoB,cAAc;AACpC,kCAAsB;AAAA,UACxB,WAAW,sBAAsB,cAAc;AAE7C,gBAAI,UAAU,KAAK,oBAAoB,MAAM,IAAI;AAC/C,oCAAsB;AAAA,YACxB;AAAA,UACF;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAEA,WAAO,sBAAsB,oBAAoB,QAAQ;AAAA,EAC3D;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,OAAe;AACb,WAAO,KAAK,KAAK;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA,EAKA,QAAc;AACZ,SAAK,KAAK,MAAM;AAAA,EAClB;AACF;","names":[]}
@@ -0,0 +1,145 @@
1
+ interface PlayerState {
2
+ rating: number;
3
+ gamesPlayed?: number;
4
+ }
5
+ type KFactorFn = (player: PlayerState) => number;
6
+ type KFactorTable = Array<[minGames: number, k: number]>;
7
+ interface EloConfig {
8
+ kFactor: number | KFactorTable | KFactorFn;
9
+ divisor?: number;
10
+ minRating?: number;
11
+ maxRating?: number;
12
+ roundTo?: "integer" | "none";
13
+ }
14
+ interface CalculateEloInput {
15
+ playerA: PlayerState;
16
+ playerB: PlayerState;
17
+ outcome: "A" | "B" | "draw";
18
+ config: EloConfig;
19
+ }
20
+ interface PlayerResult {
21
+ rating: number;
22
+ delta: number;
23
+ }
24
+ interface CalculateEloResult {
25
+ playerA: PlayerResult;
26
+ playerB: PlayerResult;
27
+ expectedScoreA: number;
28
+ }
29
+ interface SoftResetOptions {
30
+ floor: number;
31
+ factor: number;
32
+ }
33
+ type SoftResetFn = (rating: number) => number;
34
+ interface MatchmakingPoolConfig {
35
+ initialWindow: number;
36
+ expandBy: number;
37
+ expandEveryMs: number;
38
+ maxWindow?: number;
39
+ }
40
+ interface MatchmakingEntry {
41
+ id: string;
42
+ rating: number;
43
+ joinedAt?: number;
44
+ }
45
+
46
+ /**
47
+ * Resolves the K-factor for a player based on the config.
48
+ *
49
+ * @param player The player state containing rating and gamesPlayed.
50
+ * @param kFactor The K-factor configuration (number, lookup table, or custom function).
51
+ * @returns The resolved K-factor as a number.
52
+ */
53
+ declare function resolveKFactor(player: PlayerState, kFactor: number | KFactorTable | KFactorFn): number;
54
+ /**
55
+ * Calculates new Elo ratings for two players after a match outcome.
56
+ * This function is pure and stateless.
57
+ *
58
+ * @param input The match outcome and configuration details.
59
+ * @returns The new ratings, rating changes, and the expected score of player A.
60
+ */
61
+ declare function calculateElo(input: CalculateEloInput): CalculateEloResult;
62
+
63
+ /**
64
+ * Creates a helper function that resolves a K-factor based on a lookup table
65
+ * of [minGames, kFactor] thresholds.
66
+ *
67
+ * @param tiers An array of [minGames, kFactor] tuples.
68
+ * @returns A function that accepts a PlayerState and returns the resolved K-factor.
69
+ */
70
+ declare function createKFactorTable(tiers: KFactorTable): KFactorFn;
71
+
72
+ /**
73
+ * Performs a seasonal soft reset on a player's rating.
74
+ *
75
+ * If a config object is provided, computes: floor + (rating - floor) * factor.
76
+ * The resulting rating will never go below the specified floor.
77
+ *
78
+ * Alternatively, a custom reset function can be passed.
79
+ *
80
+ * @param rating The current rating of the player.
81
+ * @param options A SoftResetOptions object containing floor and factor, or a custom reset function.
82
+ * @returns The reset rating.
83
+ */
84
+ declare function softReset(rating: number, options: SoftResetOptions | SoftResetFn): number;
85
+
86
+ /**
87
+ * An in-memory matchmaking pool that is generic over the user's player type.
88
+ * It allows adding, removing, and finding matches for players based on their rating
89
+ * and a waiting window that expands over time.
90
+ */
91
+ declare class MatchmakingPool<T extends MatchmakingEntry = MatchmakingEntry> {
92
+ private pool;
93
+ private initialWindow;
94
+ private expandBy;
95
+ private expandEveryMs;
96
+ private maxWindow?;
97
+ /**
98
+ * Constructs a new MatchmakingPool.
99
+ *
100
+ * @param config Configuration for the matchmaking pool window.
101
+ */
102
+ constructor(config: MatchmakingPoolConfig);
103
+ /**
104
+ * Adds a player to the matchmaking pool.
105
+ * If joinedAt is not provided, it defaults to the current timestamp.
106
+ *
107
+ * @param entry The matchmaking entry to add.
108
+ */
109
+ add(entry: T): void;
110
+ /**
111
+ * Removes a player from the matchmaking pool.
112
+ *
113
+ * @param id The ID of the player to remove.
114
+ * @returns true if the player was found and removed, false otherwise.
115
+ */
116
+ remove(id: string): boolean;
117
+ /**
118
+ * Finds the best opponent for a player within their current rating window.
119
+ *
120
+ * The rating window expands over time based on wait time.
121
+ * The "best" opponent is defined as:
122
+ * 1. The opponent with the smallest rating difference.
123
+ * 2. (Tie-breaker) The opponent who has been waiting the longest (earliest joinedAt).
124
+ * 3. (Tie-breaker) The opponent with the lexicographically smaller ID.
125
+ *
126
+ * This query is deterministic given the same inputs and does not modify the pool.
127
+ *
128
+ * @param id The ID of the player searching for a match.
129
+ * @param now The current timestamp. Defaults to Date.now().
130
+ * @returns The best opponent entry, or null if no opponent is within the window.
131
+ */
132
+ findMatch(id: string, now?: number): T | null;
133
+ /**
134
+ * Returns the current size of the matchmaking pool.
135
+ *
136
+ * @returns The number of players in the pool.
137
+ */
138
+ size(): number;
139
+ /**
140
+ * Clears all players from the matchmaking pool.
141
+ */
142
+ clear(): void;
143
+ }
144
+
145
+ export { type CalculateEloInput, type CalculateEloResult, type EloConfig, type KFactorFn, type KFactorTable, type MatchmakingEntry, MatchmakingPool, type MatchmakingPoolConfig, type PlayerResult, type PlayerState, type SoftResetFn, type SoftResetOptions, calculateElo, createKFactorTable, resolveKFactor, softReset };
@@ -0,0 +1,145 @@
1
+ interface PlayerState {
2
+ rating: number;
3
+ gamesPlayed?: number;
4
+ }
5
+ type KFactorFn = (player: PlayerState) => number;
6
+ type KFactorTable = Array<[minGames: number, k: number]>;
7
+ interface EloConfig {
8
+ kFactor: number | KFactorTable | KFactorFn;
9
+ divisor?: number;
10
+ minRating?: number;
11
+ maxRating?: number;
12
+ roundTo?: "integer" | "none";
13
+ }
14
+ interface CalculateEloInput {
15
+ playerA: PlayerState;
16
+ playerB: PlayerState;
17
+ outcome: "A" | "B" | "draw";
18
+ config: EloConfig;
19
+ }
20
+ interface PlayerResult {
21
+ rating: number;
22
+ delta: number;
23
+ }
24
+ interface CalculateEloResult {
25
+ playerA: PlayerResult;
26
+ playerB: PlayerResult;
27
+ expectedScoreA: number;
28
+ }
29
+ interface SoftResetOptions {
30
+ floor: number;
31
+ factor: number;
32
+ }
33
+ type SoftResetFn = (rating: number) => number;
34
+ interface MatchmakingPoolConfig {
35
+ initialWindow: number;
36
+ expandBy: number;
37
+ expandEveryMs: number;
38
+ maxWindow?: number;
39
+ }
40
+ interface MatchmakingEntry {
41
+ id: string;
42
+ rating: number;
43
+ joinedAt?: number;
44
+ }
45
+
46
+ /**
47
+ * Resolves the K-factor for a player based on the config.
48
+ *
49
+ * @param player The player state containing rating and gamesPlayed.
50
+ * @param kFactor The K-factor configuration (number, lookup table, or custom function).
51
+ * @returns The resolved K-factor as a number.
52
+ */
53
+ declare function resolveKFactor(player: PlayerState, kFactor: number | KFactorTable | KFactorFn): number;
54
+ /**
55
+ * Calculates new Elo ratings for two players after a match outcome.
56
+ * This function is pure and stateless.
57
+ *
58
+ * @param input The match outcome and configuration details.
59
+ * @returns The new ratings, rating changes, and the expected score of player A.
60
+ */
61
+ declare function calculateElo(input: CalculateEloInput): CalculateEloResult;
62
+
63
+ /**
64
+ * Creates a helper function that resolves a K-factor based on a lookup table
65
+ * of [minGames, kFactor] thresholds.
66
+ *
67
+ * @param tiers An array of [minGames, kFactor] tuples.
68
+ * @returns A function that accepts a PlayerState and returns the resolved K-factor.
69
+ */
70
+ declare function createKFactorTable(tiers: KFactorTable): KFactorFn;
71
+
72
+ /**
73
+ * Performs a seasonal soft reset on a player's rating.
74
+ *
75
+ * If a config object is provided, computes: floor + (rating - floor) * factor.
76
+ * The resulting rating will never go below the specified floor.
77
+ *
78
+ * Alternatively, a custom reset function can be passed.
79
+ *
80
+ * @param rating The current rating of the player.
81
+ * @param options A SoftResetOptions object containing floor and factor, or a custom reset function.
82
+ * @returns The reset rating.
83
+ */
84
+ declare function softReset(rating: number, options: SoftResetOptions | SoftResetFn): number;
85
+
86
+ /**
87
+ * An in-memory matchmaking pool that is generic over the user's player type.
88
+ * It allows adding, removing, and finding matches for players based on their rating
89
+ * and a waiting window that expands over time.
90
+ */
91
+ declare class MatchmakingPool<T extends MatchmakingEntry = MatchmakingEntry> {
92
+ private pool;
93
+ private initialWindow;
94
+ private expandBy;
95
+ private expandEveryMs;
96
+ private maxWindow?;
97
+ /**
98
+ * Constructs a new MatchmakingPool.
99
+ *
100
+ * @param config Configuration for the matchmaking pool window.
101
+ */
102
+ constructor(config: MatchmakingPoolConfig);
103
+ /**
104
+ * Adds a player to the matchmaking pool.
105
+ * If joinedAt is not provided, it defaults to the current timestamp.
106
+ *
107
+ * @param entry The matchmaking entry to add.
108
+ */
109
+ add(entry: T): void;
110
+ /**
111
+ * Removes a player from the matchmaking pool.
112
+ *
113
+ * @param id The ID of the player to remove.
114
+ * @returns true if the player was found and removed, false otherwise.
115
+ */
116
+ remove(id: string): boolean;
117
+ /**
118
+ * Finds the best opponent for a player within their current rating window.
119
+ *
120
+ * The rating window expands over time based on wait time.
121
+ * The "best" opponent is defined as:
122
+ * 1. The opponent with the smallest rating difference.
123
+ * 2. (Tie-breaker) The opponent who has been waiting the longest (earliest joinedAt).
124
+ * 3. (Tie-breaker) The opponent with the lexicographically smaller ID.
125
+ *
126
+ * This query is deterministic given the same inputs and does not modify the pool.
127
+ *
128
+ * @param id The ID of the player searching for a match.
129
+ * @param now The current timestamp. Defaults to Date.now().
130
+ * @returns The best opponent entry, or null if no opponent is within the window.
131
+ */
132
+ findMatch(id: string, now?: number): T | null;
133
+ /**
134
+ * Returns the current size of the matchmaking pool.
135
+ *
136
+ * @returns The number of players in the pool.
137
+ */
138
+ size(): number;
139
+ /**
140
+ * Clears all players from the matchmaking pool.
141
+ */
142
+ clear(): void;
143
+ }
144
+
145
+ export { type CalculateEloInput, type CalculateEloResult, type EloConfig, type KFactorFn, type KFactorTable, type MatchmakingEntry, MatchmakingPool, type MatchmakingPoolConfig, type PlayerResult, type PlayerState, type SoftResetFn, type SoftResetOptions, calculateElo, createKFactorTable, resolveKFactor, softReset };
package/dist/index.js ADDED
@@ -0,0 +1,224 @@
1
+ // src/kfactor.ts
2
+ function createKFactorTable(tiers) {
3
+ if (tiers.length === 0) {
4
+ throw new Error("K-factor table must have at least one tier");
5
+ }
6
+ const sortedTiers = [...tiers].sort((a, b) => b[0] - a[0]);
7
+ return (player) => {
8
+ if (player.gamesPlayed === void 0) {
9
+ throw new Error("gamesPlayed is required when using a dynamic K-factor table");
10
+ }
11
+ for (const [minGames, k] of sortedTiers) {
12
+ if (player.gamesPlayed >= minGames) {
13
+ return k;
14
+ }
15
+ }
16
+ return sortedTiers[sortedTiers.length - 1][1];
17
+ };
18
+ }
19
+
20
+ // src/rating.ts
21
+ function resolveKFactor(player, kFactor) {
22
+ if (typeof kFactor === "number") {
23
+ return kFactor;
24
+ }
25
+ if (player.gamesPlayed === void 0) {
26
+ throw new Error("gamesPlayed is required when using a dynamic K-factor");
27
+ }
28
+ if (typeof kFactor === "function") {
29
+ return kFactor(player);
30
+ }
31
+ if (Array.isArray(kFactor)) {
32
+ return createKFactorTable(kFactor)(player);
33
+ }
34
+ throw new Error("Invalid kFactor configuration");
35
+ }
36
+ function calculateElo(input) {
37
+ const { playerA, playerB, outcome, config } = input;
38
+ const divisor = config.divisor ?? 400;
39
+ if (divisor <= 0) {
40
+ throw new Error(`divisor must be greater than 0, received ${divisor}`);
41
+ }
42
+ const minRating = config.minRating ?? 0;
43
+ const maxRating = config.maxRating;
44
+ if (maxRating !== void 0 && minRating > maxRating) {
45
+ throw new Error(`minRating (${minRating}) cannot be greater than maxRating (${maxRating})`);
46
+ }
47
+ const roundTo = config.roundTo ?? "integer";
48
+ const expectedScoreA = 1 / (1 + Math.pow(10, (playerB.rating - playerA.rating) / divisor));
49
+ const expectedScoreB = 1 - expectedScoreA;
50
+ let scoreA = 0.5;
51
+ let scoreB = 0.5;
52
+ if (outcome === "A") {
53
+ scoreA = 1;
54
+ scoreB = 0;
55
+ } else if (outcome === "B") {
56
+ scoreA = 0;
57
+ scoreB = 1;
58
+ }
59
+ const kA = resolveKFactor(playerA, config.kFactor);
60
+ const kB = resolveKFactor(playerB, config.kFactor);
61
+ let rawNewA = playerA.rating + kA * (scoreA - expectedScoreA);
62
+ let rawNewB = playerB.rating + kB * (scoreB - expectedScoreB);
63
+ if (roundTo === "integer") {
64
+ rawNewA = Math.round(rawNewA);
65
+ rawNewB = Math.round(rawNewB);
66
+ }
67
+ if (rawNewA < minRating) rawNewA = minRating;
68
+ if (rawNewB < minRating) rawNewB = minRating;
69
+ if (maxRating !== void 0) {
70
+ if (rawNewA > maxRating) rawNewA = maxRating;
71
+ if (rawNewB > maxRating) rawNewB = maxRating;
72
+ }
73
+ if (rawNewA === 0) rawNewA = 0;
74
+ if (rawNewB === 0) rawNewB = 0;
75
+ let deltaA = rawNewA - playerA.rating;
76
+ let deltaB = rawNewB - playerB.rating;
77
+ if (deltaA === 0) deltaA = 0;
78
+ if (deltaB === 0) deltaB = 0;
79
+ return {
80
+ playerA: {
81
+ rating: rawNewA,
82
+ delta: deltaA
83
+ },
84
+ playerB: {
85
+ rating: rawNewB,
86
+ delta: deltaB
87
+ },
88
+ expectedScoreA
89
+ };
90
+ }
91
+
92
+ // src/season.ts
93
+ function softReset(rating, options) {
94
+ if (typeof options === "function") {
95
+ return options(rating);
96
+ }
97
+ const { floor, factor } = options;
98
+ if (factor < 0 || factor > 1) {
99
+ throw new Error(`factor must be between 0 and 1, received ${factor}`);
100
+ }
101
+ const resetRating = floor + (rating - floor) * factor;
102
+ return Math.max(floor, resetRating);
103
+ }
104
+
105
+ // src/matchmaking.ts
106
+ var MatchmakingPool = class {
107
+ pool = /* @__PURE__ */ new Map();
108
+ initialWindow;
109
+ expandBy;
110
+ expandEveryMs;
111
+ maxWindow;
112
+ /**
113
+ * Constructs a new MatchmakingPool.
114
+ *
115
+ * @param config Configuration for the matchmaking pool window.
116
+ */
117
+ constructor(config) {
118
+ this.initialWindow = config.initialWindow;
119
+ this.expandBy = config.expandBy;
120
+ this.expandEveryMs = config.expandEveryMs;
121
+ this.maxWindow = config.maxWindow;
122
+ }
123
+ /**
124
+ * Adds a player to the matchmaking pool.
125
+ * If joinedAt is not provided, it defaults to the current timestamp.
126
+ *
127
+ * @param entry The matchmaking entry to add.
128
+ */
129
+ add(entry) {
130
+ const joinedAt = entry.joinedAt ?? Date.now();
131
+ this.pool.set(entry.id, {
132
+ entry,
133
+ joinedAt
134
+ });
135
+ }
136
+ /**
137
+ * Removes a player from the matchmaking pool.
138
+ *
139
+ * @param id The ID of the player to remove.
140
+ * @returns true if the player was found and removed, false otherwise.
141
+ */
142
+ remove(id) {
143
+ return this.pool.delete(id);
144
+ }
145
+ /**
146
+ * Finds the best opponent for a player within their current rating window.
147
+ *
148
+ * The rating window expands over time based on wait time.
149
+ * The "best" opponent is defined as:
150
+ * 1. The opponent with the smallest rating difference.
151
+ * 2. (Tie-breaker) The opponent who has been waiting the longest (earliest joinedAt).
152
+ * 3. (Tie-breaker) The opponent with the lexicographically smaller ID.
153
+ *
154
+ * This query is deterministic given the same inputs and does not modify the pool.
155
+ *
156
+ * @param id The ID of the player searching for a match.
157
+ * @param now The current timestamp. Defaults to Date.now().
158
+ * @returns The best opponent entry, or null if no opponent is within the window.
159
+ */
160
+ findMatch(id, now) {
161
+ const wrapper = this.pool.get(id);
162
+ if (!wrapper) {
163
+ return null;
164
+ }
165
+ const player = wrapper.entry;
166
+ const currentNow = now ?? Date.now();
167
+ const waitTime = Math.max(0, currentNow - wrapper.joinedAt);
168
+ let windowSize = this.initialWindow;
169
+ if (this.expandEveryMs > 0) {
170
+ windowSize += Math.floor(waitTime / this.expandEveryMs) * this.expandBy;
171
+ }
172
+ if (this.maxWindow != null && windowSize > this.maxWindow) {
173
+ windowSize = this.maxWindow;
174
+ }
175
+ let bestOpponentWrapper = null;
176
+ let minDiff = Infinity;
177
+ for (const candidateWrapper of this.pool.values()) {
178
+ const candidate = candidateWrapper.entry;
179
+ if (candidate.id === id) {
180
+ continue;
181
+ }
182
+ const diff = Math.abs(candidate.rating - player.rating);
183
+ if (diff <= windowSize) {
184
+ if (diff < minDiff) {
185
+ minDiff = diff;
186
+ bestOpponentWrapper = candidateWrapper;
187
+ } else if (diff === minDiff && bestOpponentWrapper) {
188
+ const candidateJoinedAt = candidateWrapper.joinedAt;
189
+ const bestJoinedAt = bestOpponentWrapper.joinedAt;
190
+ if (candidateJoinedAt < bestJoinedAt) {
191
+ bestOpponentWrapper = candidateWrapper;
192
+ } else if (candidateJoinedAt === bestJoinedAt) {
193
+ if (candidate.id < bestOpponentWrapper.entry.id) {
194
+ bestOpponentWrapper = candidateWrapper;
195
+ }
196
+ }
197
+ }
198
+ }
199
+ }
200
+ return bestOpponentWrapper ? bestOpponentWrapper.entry : null;
201
+ }
202
+ /**
203
+ * Returns the current size of the matchmaking pool.
204
+ *
205
+ * @returns The number of players in the pool.
206
+ */
207
+ size() {
208
+ return this.pool.size;
209
+ }
210
+ /**
211
+ * Clears all players from the matchmaking pool.
212
+ */
213
+ clear() {
214
+ this.pool.clear();
215
+ }
216
+ };
217
+ export {
218
+ MatchmakingPool,
219
+ calculateElo,
220
+ createKFactorTable,
221
+ resolveKFactor,
222
+ softReset
223
+ };
224
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/kfactor.ts","../src/rating.ts","../src/season.ts","../src/matchmaking.ts"],"sourcesContent":["import { PlayerState, KFactorTable, KFactorFn } from \"./types.js\";\n\n/**\n * Creates a helper function that resolves a K-factor based on a lookup table\n * of [minGames, kFactor] thresholds.\n * \n * @param tiers An array of [minGames, kFactor] tuples.\n * @returns A function that accepts a PlayerState and returns the resolved K-factor.\n */\nexport function createKFactorTable(tiers: KFactorTable): KFactorFn {\n if (tiers.length === 0) {\n throw new Error(\"K-factor table must have at least one tier\");\n }\n\n // Sort tiers descending by minGames threshold\n const sortedTiers = [...tiers].sort((a, b) => b[0] - a[0]);\n\n return (player: PlayerState): number => {\n if (player.gamesPlayed === undefined) {\n throw new Error(\"gamesPlayed is required when using a dynamic K-factor table\");\n }\n for (const [minGames, k] of sortedTiers) {\n if (player.gamesPlayed >= minGames) {\n return k;\n }\n }\n // Default to the tier with the lowest minGames threshold if none match\n // (Usually this is the 0 threshold tier)\n return sortedTiers[sortedTiers.length - 1][1];\n };\n}\n","import { CalculateEloInput, CalculateEloResult, PlayerState, KFactorTable, KFactorFn } from \"./types.js\";\nimport { createKFactorTable } from \"./kfactor.js\";\n\n/**\n * Resolves the K-factor for a player based on the config.\n * \n * @param player The player state containing rating and gamesPlayed.\n * @param kFactor The K-factor configuration (number, lookup table, or custom function).\n * @returns The resolved K-factor as a number.\n */\nexport function resolveKFactor(\n player: PlayerState,\n kFactor: number | KFactorTable | KFactorFn\n): number {\n if (typeof kFactor === \"number\") {\n return kFactor;\n }\n if (player.gamesPlayed === undefined) {\n throw new Error(\"gamesPlayed is required when using a dynamic K-factor\");\n }\n if (typeof kFactor === \"function\") {\n return kFactor(player);\n }\n if (Array.isArray(kFactor)) {\n return createKFactorTable(kFactor)(player);\n }\n throw new Error(\"Invalid kFactor configuration\");\n}\n\n/**\n * Calculates new Elo ratings for two players after a match outcome.\n * This function is pure and stateless.\n * \n * @param input The match outcome and configuration details.\n * @returns The new ratings, rating changes, and the expected score of player A.\n */\nexport function calculateElo(input: CalculateEloInput): CalculateEloResult {\n const { playerA, playerB, outcome, config } = input;\n\n const divisor = config.divisor ?? 400;\n if (divisor <= 0) {\n throw new Error(`divisor must be greater than 0, received ${divisor}`);\n }\n\n const minRating = config.minRating ?? 0;\n const maxRating = config.maxRating;\n\n if (maxRating !== undefined && minRating > maxRating) {\n throw new Error(`minRating (${minRating}) cannot be greater than maxRating (${maxRating})`);\n }\n\n const roundTo = config.roundTo ?? \"integer\";\n\n // Calculate expected scores\n const expectedScoreA = 1 / (1 + Math.pow(10, (playerB.rating - playerA.rating) / divisor));\n const expectedScoreB = 1 - expectedScoreA;\n\n // Map outcome to actual scores\n let scoreA = 0.5;\n let scoreB = 0.5;\n if (outcome === \"A\") {\n scoreA = 1;\n scoreB = 0;\n } else if (outcome === \"B\") {\n scoreA = 0;\n scoreB = 1;\n }\n\n // Resolve K-factors\n const kA = resolveKFactor(playerA, config.kFactor);\n const kB = resolveKFactor(playerB, config.kFactor);\n\n // Compute raw new ratings\n let rawNewA = playerA.rating + kA * (scoreA - expectedScoreA);\n let rawNewB = playerB.rating + kB * (scoreB - expectedScoreB);\n\n // Apply rounding\n if (roundTo === \"integer\") {\n rawNewA = Math.round(rawNewA);\n rawNewB = Math.round(rawNewB);\n }\n\n // Apply minRating floor\n if (rawNewA < minRating) rawNewA = minRating;\n if (rawNewB < minRating) rawNewB = minRating;\n\n // Apply maxRating ceiling if configured\n if (maxRating !== undefined) {\n if (rawNewA > maxRating) rawNewA = maxRating;\n if (rawNewB > maxRating) rawNewB = maxRating;\n }\n\n // Normalize -0 to +0\n if (rawNewA === 0) rawNewA = 0;\n if (rawNewB === 0) rawNewB = 0;\n\n let deltaA = rawNewA - playerA.rating;\n let deltaB = rawNewB - playerB.rating;\n\n if (deltaA === 0) deltaA = 0;\n if (deltaB === 0) deltaB = 0;\n\n return {\n playerA: {\n rating: rawNewA,\n delta: deltaA,\n },\n playerB: {\n rating: rawNewB,\n delta: deltaB,\n },\n expectedScoreA,\n };\n}\n","import { SoftResetOptions, SoftResetFn } from \"./types.js\";\n\n/**\n * Performs a seasonal soft reset on a player's rating.\n * \n * If a config object is provided, computes: floor + (rating - floor) * factor.\n * The resulting rating will never go below the specified floor.\n * \n * Alternatively, a custom reset function can be passed.\n * \n * @param rating The current rating of the player.\n * @param options A SoftResetOptions object containing floor and factor, or a custom reset function.\n * @returns The reset rating.\n */\nexport function softReset(\n rating: number,\n options: SoftResetOptions | SoftResetFn\n): number {\n if (typeof options === \"function\") {\n return options(rating);\n }\n\n const { floor, factor } = options;\n if (factor < 0 || factor > 1) {\n throw new Error(`factor must be between 0 and 1, received ${factor}`);\n }\n const resetRating = floor + (rating - floor) * factor;\n return Math.max(floor, resetRating);\n}\n","import { MatchmakingPoolConfig, MatchmakingEntry } from \"./types.js\";\n\n/**\n * An in-memory matchmaking pool that is generic over the user's player type.\n * It allows adding, removing, and finding matches for players based on their rating\n * and a waiting window that expands over time.\n */\nexport class MatchmakingPool<T extends MatchmakingEntry = MatchmakingEntry> {\n private pool: Map<string, { entry: T; joinedAt: number }> = new Map();\n private initialWindow: number;\n private expandBy: number;\n private expandEveryMs: number;\n private maxWindow?: number;\n\n /**\n * Constructs a new MatchmakingPool.\n * \n * @param config Configuration for the matchmaking pool window.\n */\n constructor(config: MatchmakingPoolConfig) {\n this.initialWindow = config.initialWindow;\n this.expandBy = config.expandBy;\n this.expandEveryMs = config.expandEveryMs;\n this.maxWindow = config.maxWindow;\n }\n\n /**\n * Adds a player to the matchmaking pool.\n * If joinedAt is not provided, it defaults to the current timestamp.\n * \n * @param entry The matchmaking entry to add.\n */\n add(entry: T): void {\n const joinedAt = entry.joinedAt ?? Date.now();\n this.pool.set(entry.id, {\n entry,\n joinedAt,\n });\n }\n\n /**\n * Removes a player from the matchmaking pool.\n * \n * @param id The ID of the player to remove.\n * @returns true if the player was found and removed, false otherwise.\n */\n remove(id: string): boolean {\n return this.pool.delete(id);\n }\n\n /**\n * Finds the best opponent for a player within their current rating window.\n * \n * The rating window expands over time based on wait time.\n * The \"best\" opponent is defined as:\n * 1. The opponent with the smallest rating difference.\n * 2. (Tie-breaker) The opponent who has been waiting the longest (earliest joinedAt).\n * 3. (Tie-breaker) The opponent with the lexicographically smaller ID.\n * \n * This query is deterministic given the same inputs and does not modify the pool.\n * \n * @param id The ID of the player searching for a match.\n * @param now The current timestamp. Defaults to Date.now().\n * @returns The best opponent entry, or null if no opponent is within the window.\n */\n findMatch(id: string, now?: number): T | null {\n const wrapper = this.pool.get(id);\n if (!wrapper) {\n return null;\n }\n const player = wrapper.entry;\n\n const currentNow = now ?? Date.now();\n const waitTime = Math.max(0, currentNow - wrapper.joinedAt);\n\n // Calculate expanding search window\n let windowSize = this.initialWindow;\n if (this.expandEveryMs > 0) {\n windowSize += Math.floor(waitTime / this.expandEveryMs) * this.expandBy;\n }\n if (this.maxWindow != null && windowSize > this.maxWindow) {\n windowSize = this.maxWindow;\n }\n\n let bestOpponentWrapper: { entry: T; joinedAt: number } | null = null;\n let minDiff = Infinity;\n\n for (const candidateWrapper of this.pool.values()) {\n const candidate = candidateWrapper.entry;\n if (candidate.id === id) {\n continue;\n }\n\n const diff = Math.abs(candidate.rating - player.rating);\n if (diff <= windowSize) {\n if (diff < minDiff) {\n minDiff = diff;\n bestOpponentWrapper = candidateWrapper;\n } else if (diff === minDiff && bestOpponentWrapper) {\n // Tie-breaker 1: longest waiting time (earliest joinedAt)\n const candidateJoinedAt = candidateWrapper.joinedAt;\n const bestJoinedAt = bestOpponentWrapper.joinedAt;\n\n if (candidateJoinedAt < bestJoinedAt) {\n bestOpponentWrapper = candidateWrapper;\n } else if (candidateJoinedAt === bestJoinedAt) {\n // Tie-breaker 2: lexicographical order of id\n if (candidate.id < bestOpponentWrapper.entry.id) {\n bestOpponentWrapper = candidateWrapper;\n }\n }\n }\n }\n }\n\n return bestOpponentWrapper ? bestOpponentWrapper.entry : null;\n }\n\n /**\n * Returns the current size of the matchmaking pool.\n * \n * @returns The number of players in the pool.\n */\n size(): number {\n return this.pool.size;\n }\n\n /**\n * Clears all players from the matchmaking pool.\n */\n clear(): void {\n this.pool.clear();\n }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "elo-mmr-kit",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency, fully configurable Elo/MMR rating and matchmaking toolkit for games",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "test": "vitest",
22
+ "test:run": "vitest run",
23
+ "lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\"",
24
+ "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\""
25
+ },
26
+ "keywords": [
27
+ "elo",
28
+ "mmr",
29
+ "matchmaking",
30
+ "rating",
31
+ "ranking",
32
+ "game"
33
+ ],
34
+ "author": "",
35
+ "license": "MIT",
36
+ "sideEffects": false,
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "devDependencies": {
41
+ "@eslint/js": "^9.7.0",
42
+ "@types/node": "^20.11.0",
43
+ "eslint": "^9.7.0",
44
+ "fast-check": "^3.19.0",
45
+ "prettier": "^3.3.3",
46
+ "tsup": "^8.2.3",
47
+ "typescript": "^5.5.3",
48
+ "typescript-eslint": "^8.0.0",
49
+ "vitest": "^2.0.3"
50
+ }
51
+ }