@juande_pl/rocketleague-stats-api-client 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JuandePL
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 CHANGED
@@ -1,3 +1,177 @@
1
- # Temporary Holding Version
1
+ # rocketleague-stats-api-client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Fully typed WebSocket client for the official [Rocket League Stats API](https://www.rocketleague.com/developer/stats-api), with runtime type guards for every event and command.
4
+
5
+ - Typed interfaces for all 22 events and 6 commands
6
+ - Type guards that validate each message at runtime (enabled by default)
7
+ - `RocketLeagueStatsClient`: connects automatically, reconnects with backoff, and gives you typed `on()` / `onAny()` / `send()`
8
+ - No runtime dependencies. Works in Node.js >= 22 and in browsers. ESM and CommonJS builds.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm install @juande_pl/rocketleague-stats-api-client
14
+ ```
15
+
16
+ ## Enable the Stats API in Rocket League
17
+
18
+ Edit `<Install Dir>\TAGame\Config\TAStatsAPI.ini` (or `DefaultStatsAPI.ini` if it doesn't exist) **before launching the game**:
19
+
20
+ ```ini
21
+ [TAGame.MatchStatsExporter_TA]
22
+ PacketSendRate=30
23
+ WebPort=49124
24
+ ```
25
+
26
+ | Setting | Default | Description |
27
+ | ---------------- | ------- | ------------------------------------------------------------------- |
28
+ | `PacketSendRate` | `0` | `UpdateState` packets per second (max 120). `0` disables the feature. |
29
+ | `Port` | `49123` | TCP port. `0` disables it. |
30
+ | `WebPort` | `49124` | WebSocket port used by this library. `0` disables it. |
31
+
32
+ ### Automatic setup (Node.js)
33
+
34
+ On Node.js the client checks the `.ini` for you. It looks for `TAStatsAPI.ini` first, then `DefaultStatsAPI.ini`. On Windows it finds the Epic Games and Steam installs by itself. If `PacketSendRate` is `0` or less, missing, or above the maximum of `120`, it sets it to `30` and warns that **you must restart the game** for the Stats API to work. The game only reads this file at launch. In browsers the check is skipped and the Node-only code is never loaded.
35
+
36
+ ```ts
37
+ import { RocketLeagueStatsClient } from '@juande_pl/rocketleague-stats-api-client';
38
+
39
+ const client = new RocketLeagueStatsClient({
40
+ ensureStatsApi: true, // default. Or false, or { installDir, iniPath, packetSendRate, warn }
41
+ });
42
+
43
+ const config = await client.statsApiConfig;
44
+ // { status: 'updated', iniPath, previous: 0, packetSendRate: 30, restartRequired: true }
45
+ // or 'enabled' | 'not-found' | 'write-failed' (e.g. no permission under Program Files)
46
+ ```
47
+
48
+ The helpers are exported from `@juande_pl/rocketleague-stats-api-client/node`. Run the check on its own with `ensureStatsApiEnabled(options)`, or use `editPacketSendRate(content)` to edit the `.ini` text without touching the disk.
49
+
50
+ ## Usage
51
+
52
+ ```ts
53
+ import { RocketLeagueStatsClient } from "@juande_pl/rocketleague-stats-api-client";
54
+
55
+ const client = new RocketLeagueStatsClient(); // ws://127.0.0.1:49124, connects automatically
56
+
57
+ // A single event: `data` is typed as GoalScoredData
58
+ client.on('GoalScored', (data) => {
59
+ console.log(`${data.Scorer.Name} scored at ${data.GoalSpeed} uu/s`);
60
+ });
61
+
62
+ // Every event, as a discriminated union
63
+ client.onAny((event) => {
64
+ if (event.Event === 'StatfeedEvent') {
65
+ console.log(event.Data.MainTarget.Name, event.Data.EventName);
66
+ }
67
+ });
68
+
69
+ // Commands: checked at compile time and validated at runtime
70
+ client.send('ChangePOV', { Focus: '1', Perspective: 'PlayerView' });
71
+ client.send({ Command: 'SetHUDVisibility', Data: { bVisible: false } });
72
+ ```
73
+
74
+ ### Options
75
+
76
+ ```ts
77
+ new RocketLeagueStatsClient({
78
+ host: '127.0.0.1', // default
79
+ port: 49124, // default (WebPort)
80
+ secure: false, // use wss://
81
+ url: undefined, // full URL, overrides host/port/secure
82
+ autoConnect: true, // set to false and call client.connect() yourself
83
+ reconnect: true, // or false, or { initialDelay, maxDelay, factor, maxAttempts }
84
+ validateEvents: true, // run type guards on incoming events
85
+ WebSocket: undefined, // custom implementation, defaults to globalThis.WebSocket
86
+ ensureStatsApi: true, // Node.js only: enable PacketSendRate in the .ini (see above)
87
+ });
88
+ ```
89
+
90
+ With `autoConnect: true`, the connection opens on the next microtask, so listeners you add right after `new` still get the `connect` event. If the game isn't running yet, the client keeps retrying with exponential backoff (1 s, 2 s, 4 s … up to 30 s).
91
+
92
+ On Node.js < 22, pass the `ws` package: `new RocketLeagueStatsClient({ WebSocket: (await import('ws')).default })`.
93
+
94
+ ### Listening
95
+
96
+ | Method | Description |
97
+ | ------------------------------- | --------------------------------------------------------------------------- |
98
+ | `on(name, listener)` | Game event (`'GoalScored'`, …) or lifecycle event. Returns an unsubscribe function. |
99
+ | `once(name, listener)` | Same as `on`, but the listener runs only once. |
100
+ | `off(name, listener)` | Removes a listener. |
101
+ | `onAny(listener)` / `offAny()` | Every game event, as `StatsApiEvent` (narrow with `event.Event`). |
102
+ | `waitFor(name, { timeout })` | Promise that resolves with the next payload of that event. |
103
+ | `removeAllListeners(name?)` | Removes every listener, or every listener for one name. |
104
+
105
+ Game event listeners receive `(data, event)`: the typed payload and the full `{ Event, Data }` message.
106
+
107
+ Lifecycle events:
108
+
109
+ | Name | Arguments |
110
+ | ---------------- | ------------------------------------------- |
111
+ | `connect` | none |
112
+ | `disconnect` | `{ code, reason, willReconnect }` |
113
+ | `reconnecting` | `{ attempt, delay }` |
114
+ | `error` | `Error` (`ConnectionError`, `ListenerError`) |
115
+ | `invalidMessage` | `ParseFailure` (`reason`, `raw`, `event?`, `data?`) |
116
+
117
+ Errors are only reported to `error` listeners. A throwing listener doesn't stop the other listeners from running.
118
+
119
+ ### Sending commands
120
+
121
+ `send(command, data)` or `send({ Command, Data })`. It throws:
122
+
123
+ - `CommandValidationError` if the command name or payload is invalid (for example, `ChangePOV` with neither `Focus` nor `Perspective`, or `SetGameSpeed` with a negative speed).
124
+ - `NotConnectedError` if the socket is not open.
125
+
126
+ | Command | Payload |
127
+ | ------------------ | --------------------------------------------------------------- |
128
+ | `ChangePOV` | `{ Focus?: 'Ball' \| '<shortcut>', Perspective?: Perspective }` (at least one) |
129
+ | `LoadReplay` | `{ FileName?: string, Path?: string }` (at least one) |
130
+ | `SeekReplay` | `{ Frame?: number, TimeSeconds?: number }` (at least one) |
131
+ | `SetGameSpeed` | `{ Speed: number }` (≥ 0) |
132
+ | `SetHUDVisibility` | `{ bVisible: boolean }` |
133
+ | `SetMatchPaused` | `{ bPaused: boolean }` |
134
+
135
+ `Perspective` is one of `'Fly' | 'SoftAttach' | 'HardAttach' | 'PlayerView' | 'AutoCam' | 'Camera_Director'`.
136
+
137
+ ## Type guards
138
+
139
+ Every guard is exported, so you can validate messages from any source (the TCP port, recordings, a relay server…):
140
+
141
+ ```ts
142
+ import { isStatsApiEvent, isGoalScoredData, isEventData, isStatsApiCommand, parseEventMessage } from '@juande_pl/rocketleague-stats-api-client';
143
+
144
+ isGoalScoredData(payload); // payload is GoalScoredData
145
+ isEventData('BallHit', payload); // payload is BallHitData
146
+ isStatsApiEvent(message); // message is StatsApiEvent
147
+ isStatsApiEvent(message, 'MatchEnded'); // message is StatsApiEventOf<'MatchEnded'>
148
+ isStatsApiCommand(command, 'ChangePOV'); // command is StatsApiCommandOf<'ChangePOV'>
149
+
150
+ // Decodes the wire format ({ Event, Data: "<json string>" }). Never throws.
151
+ const result = parseEventMessage(rawText);
152
+ if (result.ok) console.log(result.event.Event);
153
+ else console.warn(result.reason); // 'invalid-json' | 'invalid-envelope' | 'invalid-data-json' | 'unknown-event' | 'invalid-data'
154
+ ```
155
+
156
+ Guards accept extra, undocumented fields, so new fields added by a game update don't break validation. Fields the docs mark `SPECTATOR` or `CONDITIONAL` (and `MatchGuid`, which is only set online or on LAN) are optional.
157
+
158
+ The building blocks (`shape`, `optional`, `arrayOf`, `oneOf`, `isString`, `isNumber`, …) are exported too.
159
+
160
+ ## Known quirks
161
+
162
+ - `StatfeedEvent.Type` is a localized label. Use `EventName` in your logic.
163
+ - `GoalScored.GoalTime` is the length of the previous round, not the match clock.
164
+ - In offline exhibitions or with splitscreen players, `StatfeedEvent` can arrive more than once.
165
+
166
+ ## Development
167
+
168
+ ```sh
169
+ npm install
170
+ npm run typecheck
171
+ npm test
172
+ npm run build
173
+ ```
174
+
175
+ ## License
176
+
177
+ MIT. This project is not affiliated with Psyonix or Epic Games.
@@ -0,0 +1,182 @@
1
+ // src/node/config.ts
2
+ import { access, readdir, readFile, writeFile } from "fs/promises";
3
+ import path from "path";
4
+ var STATS_API_SECTION = "TAGame.MatchStatsExporter_TA";
5
+ var DEFAULT_PACKET_SEND_RATE = 30;
6
+ var MAX_PACKET_SEND_RATE = 120;
7
+ var STATS_API_INI_FILES = ["TAStatsAPI.ini", "DefaultStatsAPI.ini"];
8
+ var EPIC_APP_NAME = "Sugar";
9
+ var SECTION_RE = /^\s*\[([^\]]+)\]\s*$/;
10
+ var KEY_RE = /^(\s*PacketSendRate\s*=\s*)([^;\r\n]*?)(\s*(?:;.*)?)$/i;
11
+ function editPacketSendRate(content, rate = DEFAULT_PACKET_SEND_RATE) {
12
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
13
+ const lines = content.split(/\r?\n/);
14
+ let sectionStart = -1;
15
+ let sectionEnd = lines.length;
16
+ for (let i = 0; i < lines.length; i++) {
17
+ const match = SECTION_RE.exec(lines[i]);
18
+ if (!match) continue;
19
+ if (sectionStart !== -1) {
20
+ sectionEnd = i;
21
+ break;
22
+ }
23
+ if (match[1].trim().toLowerCase() === STATS_API_SECTION.toLowerCase()) sectionStart = i;
24
+ }
25
+ let keyLine = -1;
26
+ let previous;
27
+ if (sectionStart !== -1) {
28
+ for (let i = sectionStart + 1; i < sectionEnd; i++) {
29
+ const match = KEY_RE.exec(lines[i]);
30
+ if (match) {
31
+ keyLine = i;
32
+ const value = Number.parseFloat(match[2]);
33
+ previous = Number.isNaN(value) ? void 0 : value;
34
+ break;
35
+ }
36
+ }
37
+ }
38
+ const needsUpdate = previous === void 0 || previous <= 0 || previous > MAX_PACKET_SEND_RATE;
39
+ if (!needsUpdate) return { previous, needsUpdate, content };
40
+ if (keyLine !== -1) {
41
+ lines[keyLine] = lines[keyLine].replace(KEY_RE, (_, prefix, _value, suffix) => `${prefix}${rate}${suffix}`);
42
+ } else if (sectionStart !== -1) {
43
+ lines.splice(sectionStart + 1, 0, `PacketSendRate=${rate}`);
44
+ } else {
45
+ const hasTrailingNewline = lines.length > 0 && lines.at(-1) === "";
46
+ if (hasTrailingNewline) lines.pop();
47
+ if (lines.length > 0 && lines.at(-1).trim() !== "") lines.push("");
48
+ lines.push(`[${STATS_API_SECTION}]`, `PacketSendRate=${rate}`);
49
+ if (hasTrailingNewline) lines.push("");
50
+ }
51
+ return { previous, needsUpdate, content: lines.join(eol) };
52
+ }
53
+ async function exists(file) {
54
+ try {
55
+ await access(file);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+ async function readJson(file) {
62
+ try {
63
+ return JSON.parse(await readFile(file, "utf8"));
64
+ } catch {
65
+ return void 0;
66
+ }
67
+ }
68
+ function epicInstallLocation(value) {
69
+ if (typeof value !== "object" || value === null) return void 0;
70
+ const { AppName, InstallLocation } = value;
71
+ return AppName === EPIC_APP_NAME && typeof InstallLocation === "string" ? InstallLocation : void 0;
72
+ }
73
+ async function findEpicInstalls(programData) {
74
+ const found = [];
75
+ const launcherInstalled = await readJson(path.join(programData, "Epic", "UnrealEngineLauncher", "LauncherInstalled.dat"));
76
+ const list = launcherInstalled?.InstallationList;
77
+ if (Array.isArray(list)) {
78
+ for (const item of list) {
79
+ const location = epicInstallLocation(item);
80
+ if (location) found.push(location);
81
+ }
82
+ }
83
+ const manifestsDir = path.join(programData, "Epic", "EpicGamesLauncher", "Data", "Manifests");
84
+ try {
85
+ for (const file of await readdir(manifestsDir)) {
86
+ if (!file.toLowerCase().endsWith(".item")) continue;
87
+ const location = epicInstallLocation(await readJson(path.join(manifestsDir, file)));
88
+ if (location) found.push(location);
89
+ }
90
+ } catch {
91
+ }
92
+ return found;
93
+ }
94
+ async function findSteamInstalls(steamDir) {
95
+ const libraries = [steamDir];
96
+ try {
97
+ const vdf = await readFile(path.join(steamDir, "steamapps", "libraryfolders.vdf"), "utf8");
98
+ for (const match of vdf.matchAll(/"path"\s+"([^"]+)"/g)) libraries.push(match[1].replace(/\\\\/g, "\\"));
99
+ } catch {
100
+ }
101
+ return libraries.map((library) => path.join(library, "steamapps", "common", "rocketleague"));
102
+ }
103
+ async function findRocketLeagueInstallDirs() {
104
+ if (process.platform !== "win32") return [];
105
+ const env = process.env;
106
+ const programData = env.ProgramData ?? "C:\\ProgramData";
107
+ const programFiles = env.ProgramFiles ?? "C:\\Program Files";
108
+ const programFilesX86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
109
+ const candidates = [
110
+ ...await findEpicInstalls(programData),
111
+ path.join(programFiles, "Epic Games", "rocketleague"),
112
+ ...await findSteamInstalls(path.join(programFilesX86, "Steam"))
113
+ ];
114
+ return [...new Set(candidates.map((dir) => path.normalize(dir)))];
115
+ }
116
+ function statsApiIniPaths(installDir) {
117
+ return STATS_API_INI_FILES.map((file) => path.join(installDir, "TAGame", "Config", file));
118
+ }
119
+ async function findStatsApiIni(installDir) {
120
+ const dirs = installDir ? [installDir] : await findRocketLeagueInstallDirs();
121
+ const searched = [];
122
+ for (const dir of dirs) {
123
+ for (const file of statsApiIniPaths(dir)) {
124
+ searched.push(file);
125
+ if (await exists(file)) return { iniPath: file, searched };
126
+ }
127
+ }
128
+ return { iniPath: void 0, searched };
129
+ }
130
+ function describeProblem(previous) {
131
+ return previous !== void 0 && previous > MAX_PACKET_SEND_RATE ? `PacketSendRate=${previous} is above the maximum of ${MAX_PACKET_SEND_RATE}` : `The Rocket League Stats API is disabled (PacketSendRate=${previous ?? 0})`;
132
+ }
133
+ async function ensureStatsApiEnabled(options = {}) {
134
+ const rate = options.packetSendRate ?? DEFAULT_PACKET_SEND_RATE;
135
+ if (!Number.isFinite(rate) || rate <= 0 || rate > MAX_PACKET_SEND_RATE) {
136
+ throw new RangeError(`packetSendRate must be between 1 and ${MAX_PACKET_SEND_RATE}, got ${rate}.`);
137
+ }
138
+ const warn = options.warn === false ? () => {
139
+ } : options.warn ?? ((message) => console.warn(message));
140
+ let iniPath = options.iniPath;
141
+ let searched = iniPath ? [iniPath] : [];
142
+ if (iniPath === void 0) {
143
+ ({ iniPath, searched } = await findStatsApiIni(options.installDir));
144
+ }
145
+ let content;
146
+ let encoding;
147
+ try {
148
+ if (!iniPath) throw new Error("not found");
149
+ const buffer = await readFile(iniPath);
150
+ encoding = buffer[0] === 255 && buffer[1] === 254 ? "utf16le" : "utf8";
151
+ content = buffer.toString(encoding);
152
+ } catch {
153
+ return { status: "not-found", searched };
154
+ }
155
+ const edit = editPacketSendRate(content, rate);
156
+ if (!edit.needsUpdate) return { status: "enabled", iniPath, packetSendRate: edit.previous };
157
+ try {
158
+ await writeFile(iniPath, edit.content, encoding);
159
+ } catch (error) {
160
+ warn(
161
+ `[@juande_pl/rocketleague-stats-api-client] ${describeProblem(edit.previous)}, but "${iniPath}" could not be written. Set PacketSendRate=${rate} manually (you may need administrator rights) and restart the game.`
162
+ );
163
+ return { status: "write-failed", iniPath, previous: edit.previous, error };
164
+ }
165
+ warn(
166
+ `[@juande_pl/rocketleague-stats-api-client] ${describeProblem(edit.previous)}. PacketSendRate has been set to ${rate} in "${iniPath}". Restart Rocket League for the Stats API to work.`
167
+ );
168
+ return { status: "updated", iniPath, previous: edit.previous, packetSendRate: rate, restartRequired: true };
169
+ }
170
+
171
+ export {
172
+ STATS_API_SECTION,
173
+ DEFAULT_PACKET_SEND_RATE,
174
+ MAX_PACKET_SEND_RATE,
175
+ STATS_API_INI_FILES,
176
+ editPacketSendRate,
177
+ findRocketLeagueInstallDirs,
178
+ statsApiIniPaths,
179
+ findStatsApiIni,
180
+ ensureStatsApiEnabled
181
+ };
182
+ //# sourceMappingURL=chunk-IUPCJADJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/node/config.ts"],"sourcesContent":["import { access, readdir, readFile, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\n\n/** Section of the Stats API `.ini` file that holds the exporter settings. */\nexport const STATS_API_SECTION = 'TAGame.MatchStatsExporter_TA';\n\n/** `PacketSendRate` written when the current value is invalid (`<= 0`, missing, or above the maximum). */\nexport const DEFAULT_PACKET_SEND_RATE = 30;\n\n/** Highest `PacketSendRate` the game accepts. */\nexport const MAX_PACKET_SEND_RATE = 120;\n\n/** `.ini` file names, in lookup order. `TAStatsAPI.ini` wins over `DefaultStatsAPI.ini`. */\nexport const STATS_API_INI_FILES = ['TAStatsAPI.ini', 'DefaultStatsAPI.ini'] as const;\n\n/** Epic Games Store app name of Rocket League. */\nconst EPIC_APP_NAME = 'Sugar';\n\nexport interface EnsureStatsApiOptions {\n\t/** Rocket League install directory (the folder containing `TAGame`). Auto-detected on Windows when omitted. */\n\tinstallDir?: string;\n\t/** Exact path of the `.ini` file to check. Overrides `installDir` and auto-detection. */\n\tiniPath?: string;\n\t/** `PacketSendRate` written when the current value is `0` or less, missing, or above `120`. Must be 1–120. @default 30 */\n\tpacketSendRate?: number;\n\t/**\n\t * Called with a human-readable message when the file was changed (restart required) or could not be written.\n\t * Pass `false` to stay silent.\n\t * @default console.warn\n\t */\n\twarn?: ((message: string) => void) | false;\n}\n\nexport type EnsureStatsApiResult =\n\t/** No Stats API `.ini` file was found. */\n\t| { status: 'not-found'; searched: string[] }\n\t/** `PacketSendRate` was already between 1 and 120. */\n\t| { status: 'enabled'; iniPath: string; packetSendRate: number }\n\t/** `PacketSendRate` was `0` or less, missing, or above 120, and has been set. The game must be restarted. */\n\t| { status: 'updated'; iniPath: string; previous: number | undefined; packetSendRate: number; restartRequired: true }\n\t/** The file needed a change but could not be written (e.g. no permission under `Program Files`). */\n\t| { status: 'write-failed'; iniPath: string; previous: number | undefined; error: unknown };\n\n// ---------------------------------------------------------------------------\n// INI editing (pure)\n// ---------------------------------------------------------------------------\n\nexport interface PacketSendRateEdit {\n\t/** Current value, or `undefined` if the key is missing (the game then uses `0`). */\n\tprevious: number | undefined;\n\t/** `true` when `PacketSendRate` is `0` or less, missing (Stats API disabled), or above {@link MAX_PACKET_SEND_RATE}. */\n\tneedsUpdate: boolean;\n\t/** The file contents with `PacketSendRate` set to the requested rate. Equals the input when `needsUpdate` is false. */\n\tcontent: string;\n}\n\nconst SECTION_RE = /^\\s*\\[([^\\]]+)\\]\\s*$/;\nconst KEY_RE = /^(\\s*PacketSendRate\\s*=\\s*)([^;\\r\\n]*?)(\\s*(?:;.*)?)$/i;\n\n/**\n * Reads `PacketSendRate` from the `[TAGame.MatchStatsExporter_TA]` section of an `.ini` file\n * and, if it is `0` or less (or missing), returns the contents with it set to `rate`.\n * Keeps comments, the other keys, the BOM and the line endings untouched.\n */\nexport function editPacketSendRate(content: string, rate = DEFAULT_PACKET_SEND_RATE): PacketSendRateEdit {\n\tconst eol = content.includes('\\r\\n') ? '\\r\\n' : '\\n';\n\tconst lines = content.split(/\\r?\\n/);\n\n\tlet sectionStart = -1;\n\tlet sectionEnd = lines.length;\n\tfor (let i = 0; i < lines.length; i++) {\n\t\tconst match = SECTION_RE.exec(lines[i]!);\n\t\tif (!match) continue;\n\t\tif (sectionStart !== -1) {\n\t\t\tsectionEnd = i;\n\t\t\tbreak;\n\t\t}\n\t\tif (match[1]!.trim().toLowerCase() === STATS_API_SECTION.toLowerCase()) sectionStart = i;\n\t}\n\n\tlet keyLine = -1;\n\tlet previous: number | undefined;\n\tif (sectionStart !== -1) {\n\t\tfor (let i = sectionStart + 1; i < sectionEnd; i++) {\n\t\t\tconst match = KEY_RE.exec(lines[i]!);\n\t\t\tif (match) {\n\t\t\t\tkeyLine = i;\n\t\t\t\tconst value = Number.parseFloat(match[2]!);\n\t\t\t\tprevious = Number.isNaN(value) ? undefined : value;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\tconst needsUpdate = previous === undefined || previous <= 0 || previous > MAX_PACKET_SEND_RATE;\n\tif (!needsUpdate) return { previous, needsUpdate, content };\n\n\tif (keyLine !== -1) {\n\t\tlines[keyLine] = lines[keyLine]!.replace(KEY_RE, (_, prefix: string, _value: string, suffix: string) => `${prefix}${rate}${suffix}`);\n\t} else if (sectionStart !== -1) {\n\t\tlines.splice(sectionStart + 1, 0, `PacketSendRate=${rate}`);\n\t} else {\n\t\t// Append the section, keeping a trailing newline if the file had one.\n\t\tconst hasTrailingNewline = lines.length > 0 && lines.at(-1) === '';\n\t\tif (hasTrailingNewline) lines.pop();\n\t\tif (lines.length > 0 && lines.at(-1)!.trim() !== '') lines.push('');\n\t\tlines.push(`[${STATS_API_SECTION}]`, `PacketSendRate=${rate}`);\n\t\tif (hasTrailingNewline) lines.push('');\n\t}\n\treturn { previous, needsUpdate, content: lines.join(eol) };\n}\n\n// ---------------------------------------------------------------------------\n// Install directory discovery\n// ---------------------------------------------------------------------------\n\nasync function exists(file: string): Promise<boolean> {\n\ttry {\n\t\tawait access(file);\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\nasync function readJson(file: string): Promise<unknown> {\n\ttry {\n\t\treturn JSON.parse(await readFile(file, 'utf8'));\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nfunction epicInstallLocation(value: unknown): string | undefined {\n\tif (typeof value !== 'object' || value === null) return undefined;\n\tconst { AppName, InstallLocation } = value as Record<string, unknown>;\n\treturn AppName === EPIC_APP_NAME && typeof InstallLocation === 'string' ? InstallLocation : undefined;\n}\n\n/** Install directories registered by the Epic Games Launcher. */\nasync function findEpicInstalls(programData: string): Promise<string[]> {\n\tconst found: string[] = [];\n\n\tconst launcherInstalled = await readJson(path.join(programData, 'Epic', 'UnrealEngineLauncher', 'LauncherInstalled.dat'));\n\tconst list = (launcherInstalled as { InstallationList?: unknown } | undefined)?.InstallationList;\n\tif (Array.isArray(list)) {\n\t\tfor (const item of list) {\n\t\t\tconst location = epicInstallLocation(item);\n\t\t\tif (location) found.push(location);\n\t\t}\n\t}\n\n\tconst manifestsDir = path.join(programData, 'Epic', 'EpicGamesLauncher', 'Data', 'Manifests');\n\ttry {\n\t\tfor (const file of await readdir(manifestsDir)) {\n\t\t\tif (!file.toLowerCase().endsWith('.item')) continue;\n\t\t\tconst location = epicInstallLocation(await readJson(path.join(manifestsDir, file)));\n\t\t\tif (location) found.push(location);\n\t\t}\n\t} catch {\n\t\t// No Epic launcher.\n\t}\n\treturn found;\n}\n\n/** Install directories in every Steam library. */\nasync function findSteamInstalls(steamDir: string): Promise<string[]> {\n\tconst libraries = [steamDir];\n\ttry {\n\t\tconst vdf = await readFile(path.join(steamDir, 'steamapps', 'libraryfolders.vdf'), 'utf8');\n\t\tfor (const match of vdf.matchAll(/\"path\"\\s+\"([^\"]+)\"/g)) libraries.push(match[1]!.replace(/\\\\\\\\/g, '\\\\'));\n\t} catch {\n\t\t// No Steam, or no extra libraries.\n\t}\n\treturn libraries.map((library) => path.join(library, 'steamapps', 'common', 'rocketleague'));\n}\n\n/**\n * Candidate Rocket League install directories (Epic Games Store and Steam, Windows only).\n * Not every returned directory necessarily exists.\n */\nexport async function findRocketLeagueInstallDirs(): Promise<string[]> {\n\tif (process.platform !== 'win32') return [];\n\tconst env = process.env;\n\tconst programData = env.ProgramData ?? 'C:\\\\ProgramData';\n\tconst programFiles = env.ProgramFiles ?? 'C:\\\\Program Files';\n\tconst programFilesX86 = env['ProgramFiles(x86)'] ?? 'C:\\\\Program Files (x86)';\n\n\tconst candidates = [\n\t\t...(await findEpicInstalls(programData)),\n\t\tpath.join(programFiles, 'Epic Games', 'rocketleague'),\n\t\t...(await findSteamInstalls(path.join(programFilesX86, 'Steam'))),\n\t];\n\treturn [...new Set(candidates.map((dir) => path.normalize(dir)))];\n}\n\n/** Paths of the Stats API `.ini` files inside an install directory, in lookup order. */\nexport function statsApiIniPaths(installDir: string): string[] {\n\treturn STATS_API_INI_FILES.map((file) => path.join(installDir, 'TAGame', 'Config', file));\n}\n\n/** Returns the Stats API `.ini` file in use (`TAStatsAPI.ini`, else `DefaultStatsAPI.ini`), or `undefined`. */\nexport async function findStatsApiIni(installDir?: string): Promise<{ iniPath: string | undefined; searched: string[] }> {\n\tconst dirs = installDir ? [installDir] : await findRocketLeagueInstallDirs();\n\tconst searched: string[] = [];\n\tfor (const dir of dirs) {\n\t\tfor (const file of statsApiIniPaths(dir)) {\n\t\t\tsearched.push(file);\n\t\t\tif (await exists(file)) return { iniPath: file, searched };\n\t\t}\n\t}\n\treturn { iniPath: undefined, searched };\n}\n\n/** Why `PacketSendRate` needs to be changed, for warnings. */\nfunction describeProblem(previous: number | undefined): string {\n\treturn previous !== undefined && previous > MAX_PACKET_SEND_RATE\n\t\t? `PacketSendRate=${previous} is above the maximum of ${MAX_PACKET_SEND_RATE}`\n\t\t: `The Rocket League Stats API is disabled (PacketSendRate=${previous ?? 0})`;\n}\n\n// ---------------------------------------------------------------------------\n// Public entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Looks for the Stats API `.ini` file (`TAStatsAPI.ini` or `DefaultStatsAPI.ini`). If `PacketSendRate`\n * is `0` or less, missing, or above `120`, sets it to `30` (or `options.packetSendRate`) and warns that the game must be restarted,\n * since Rocket League only reads this file at launch.\n *\n * Never throws: every outcome is reported in the returned result.\n */\nexport async function ensureStatsApiEnabled(options: EnsureStatsApiOptions = {}): Promise<EnsureStatsApiResult> {\n\tconst rate = options.packetSendRate ?? DEFAULT_PACKET_SEND_RATE;\n\tif (!Number.isFinite(rate) || rate <= 0 || rate > MAX_PACKET_SEND_RATE) {\n\t\tthrow new RangeError(`packetSendRate must be between 1 and ${MAX_PACKET_SEND_RATE}, got ${rate}.`);\n\t}\n\tconst warn = options.warn === false ? () => {} : (options.warn ?? ((message: string) => console.warn(message)));\n\n\tlet iniPath = options.iniPath;\n\tlet searched = iniPath ? [iniPath] : [];\n\tif (iniPath === undefined) {\n\t\t({ iniPath, searched } = await findStatsApiIni(options.installDir));\n\t}\n\n\tlet content: string;\n\tlet encoding: BufferEncoding;\n\ttry {\n\t\tif (!iniPath) throw new Error('not found');\n\t\tconst buffer = await readFile(iniPath);\n\t\t// Unreal may save .ini files as UTF-16LE (with BOM). Keep whatever encoding the file uses.\n\t\tencoding = buffer[0] === 0xff && buffer[1] === 0xfe ? 'utf16le' : 'utf8';\n\t\tcontent = buffer.toString(encoding);\n\t} catch {\n\t\treturn { status: 'not-found', searched };\n\t}\n\n\tconst edit = editPacketSendRate(content, rate);\n\tif (!edit.needsUpdate) return { status: 'enabled', iniPath, packetSendRate: edit.previous! };\n\n\ttry {\n\t\tawait writeFile(iniPath, edit.content, encoding);\n\t} catch (error) {\n\t\twarn(\n\t\t\t`[@juande_pl/rocketleague-stats-api-client] ${describeProblem(edit.previous)}, ` +\n\t\t\t\t`but \"${iniPath}\" could not be written. Set PacketSendRate=${rate} manually (you may need administrator rights) ` +\n\t\t\t\t'and restart the game.',\n\t\t);\n\t\treturn { status: 'write-failed', iniPath, previous: edit.previous, error };\n\t}\n\n\twarn(\n\t\t`[@juande_pl/rocketleague-stats-api-client] ${describeProblem(edit.previous)}. ` +\n\t\t\t`PacketSendRate has been set to ${rate} in \"${iniPath}\". Restart Rocket League for the Stats API to work.`,\n\t);\n\treturn { status: 'updated', iniPath, previous: edit.previous, packetSendRate: rate, restartRequired: true };\n}\n"],"mappings":"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","names":[]}