erlc-v2 1.0.0-beta.1 → 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/README.md +146 -11
- package/index.d.ts +102 -1
- package/package.json +7 -1
- package/src/Client.js +56 -2
- package/src/cache/Cache.js +247 -4
- package/src/map/renderPlayerMap.js +719 -0
- package/src/rest/RateLimiter.js +6 -6
- package/src/rest/RequestManager.js +24 -21
- package/src/util/constants.js +0 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
JavaScript client for the ER:LC API v2.
|
|
4
4
|
|
|
5
|
-
Built for Node 18
|
|
5
|
+
Built for Node 18+.
|
|
6
6
|
|
|
7
7
|
## Beta Notice
|
|
8
8
|
|
|
@@ -51,17 +51,17 @@ main()
|
|
|
51
51
|
.finally(() => client.destroy());
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
## Quick Start (ESM)
|
|
54
|
+
## Quick Start (ESM)
|
|
55
55
|
|
|
56
56
|
```js
|
|
57
57
|
import { Client } from "erlc-v2";
|
|
58
58
|
|
|
59
|
-
const client = new Client({
|
|
60
|
-
serverKey: "YOUR_SERVER_KEY",
|
|
61
|
-
});
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
## Options
|
|
59
|
+
const client = new Client({
|
|
60
|
+
serverKey: "YOUR_SERVER_KEY",
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Options
|
|
65
65
|
|
|
66
66
|
```ts
|
|
67
67
|
new Client({
|
|
@@ -73,13 +73,17 @@ new Client({
|
|
|
73
73
|
enabled?: boolean, // default: true
|
|
74
74
|
ttlMs?: number, // default: 1500
|
|
75
75
|
maxSize?: number, // default: 500
|
|
76
|
+
provider?: "memory" | "redis", // default: auto (redis if redis config is present)
|
|
77
|
+
redisUrl?: string, // optional (requires `npm i redis`)
|
|
78
|
+
redisPrefix?: string, // default: "erlc-v2:cache"
|
|
79
|
+
redisClient?: object, // optional pre-configured Redis client
|
|
76
80
|
},
|
|
77
81
|
rateLimit?: {
|
|
78
82
|
enabled?: boolean, // default: true
|
|
79
83
|
strictSerial?: boolean, // default: true (global one-at-a-time queue)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
bucketLimit?: number, // default: 1
|
|
85
|
+
totalLimit?: number, // default: 1
|
|
86
|
+
unauthLimit?: number, // default: 3
|
|
83
87
|
},
|
|
84
88
|
polling?: {
|
|
85
89
|
enabled?: boolean, // default: true
|
|
@@ -89,6 +93,54 @@ new Client({
|
|
|
89
93
|
});
|
|
90
94
|
```
|
|
91
95
|
|
|
96
|
+
Legacy aliases (`perBucketConcurrency`, `globalConcurrency`, `unauthorizedThreshold`) are still accepted.
|
|
97
|
+
|
|
98
|
+
## Redis Cache (Optional)
|
|
99
|
+
|
|
100
|
+
You can use Redis instead of in-memory cache by passing either `cache.redisUrl` or `cache.redisClient`.
|
|
101
|
+
|
|
102
|
+
If you use `redisUrl`, install the Redis client package:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm i redis
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Example with connection URL:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
const { Client } = require("erlc-v2");
|
|
112
|
+
|
|
113
|
+
const client = new Client({
|
|
114
|
+
serverKey: "YOUR_SERVER_KEY",
|
|
115
|
+
cache: {
|
|
116
|
+
provider: "redis",
|
|
117
|
+
redisUrl: "redis://localhost:6379",
|
|
118
|
+
redisPrefix: "myapp:erlc",
|
|
119
|
+
ttlMs: 2000,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Example with your own Redis client instance:
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
const { createClient } = require("redis");
|
|
128
|
+
const { Client } = require("erlc-v2");
|
|
129
|
+
|
|
130
|
+
(async () => {
|
|
131
|
+
const redis = createClient({ url: process.env.REDIS_URL });
|
|
132
|
+
await redis.connect();
|
|
133
|
+
|
|
134
|
+
const client = new Client({
|
|
135
|
+
serverKey: "YOUR_SERVER_KEY",
|
|
136
|
+
cache: {
|
|
137
|
+
redisClient: redis,
|
|
138
|
+
redisPrefix: "myapp:erlc",
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
})();
|
|
142
|
+
```
|
|
143
|
+
|
|
92
144
|
## API
|
|
93
145
|
|
|
94
146
|
Core:
|
|
@@ -100,6 +152,8 @@ Core:
|
|
|
100
152
|
Convenience methods:
|
|
101
153
|
|
|
102
154
|
- `await client.players.list()`
|
|
155
|
+
- `await client.map.render(options?)`
|
|
156
|
+
- `await client.map.renderUser(userId, options?)`
|
|
103
157
|
- `await client.staff.list()`
|
|
104
158
|
- `await client.logs.kills()`
|
|
105
159
|
- `await client.logs.joins()`
|
|
@@ -155,6 +209,87 @@ await client.commands.execute(":h Hey everyone!");
|
|
|
155
209
|
await client.commands.execute(":log recentban He was trolling!");
|
|
156
210
|
```
|
|
157
211
|
|
|
212
|
+
## Map Rendering
|
|
213
|
+
|
|
214
|
+
Render an ER:LC map (`3121x3121`) with player markers that use Roblox avatars.
|
|
215
|
+
|
|
216
|
+
```js
|
|
217
|
+
const result = await client.map.render();
|
|
218
|
+
|
|
219
|
+
// png buffer
|
|
220
|
+
console.log(result.buffer);
|
|
221
|
+
console.log(result.players.length);
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`client.map.render()` renders the full map with all players currently in the server.
|
|
225
|
+
|
|
226
|
+
Render an official season/type map preset:
|
|
227
|
+
|
|
228
|
+
```js
|
|
229
|
+
const fallBlank = await client.map.render({
|
|
230
|
+
season: "fall",
|
|
231
|
+
type: "blank",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const fallPostals = await client.map.render({
|
|
235
|
+
season: "fall",
|
|
236
|
+
type: "postals",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const winterBlank = await client.map.render({
|
|
240
|
+
season: "winter", // alias: "snow"
|
|
241
|
+
type: "blank",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const winterPostals = await client.map.render({
|
|
245
|
+
season: "winter",
|
|
246
|
+
type: "postals",
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Use your own map image URL:
|
|
251
|
+
|
|
252
|
+
```js
|
|
253
|
+
const customMap = await client.map.render({
|
|
254
|
+
mapUrl: "https://example.com/my-map.png", // mainly used in cases where we fail to add the most recent map when its released (sizing should be 3121x3121)
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Render only one player by Roblox user ID:
|
|
259
|
+
|
|
260
|
+
```js
|
|
261
|
+
const single = await client.map.renderUser(123456789, {
|
|
262
|
+
season: "winter",
|
|
263
|
+
type: "postals",
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Options:
|
|
268
|
+
|
|
269
|
+
- `userId?: number | string`
|
|
270
|
+
- `userIds?: Array<number | string>`
|
|
271
|
+
- `players?: any[]` (use your own pre-fetched player payload)
|
|
272
|
+
- `mapUrl?: string` (custom map URL; if set, this overrides `season`/`type`)
|
|
273
|
+
- `season?: string` (`"fall"` or `"winter"`/`"snow"` for official presets)
|
|
274
|
+
- `type?: string` (`"blank"` or `"postals"` for official presets)
|
|
275
|
+
- `mapSeason?: string` (alias of `season`)
|
|
276
|
+
- `mapType?: string` (alias of `type`)
|
|
277
|
+
- `coordinateBounds?: { minX, maxX, minY, maxY, invertY? }`
|
|
278
|
+
- `clampToMap?: boolean` (default: `true`)
|
|
279
|
+
- `robloxHeadshotSize?: string` (default: `"150x150"`)
|
|
280
|
+
- `marker?: { outerRadius, innerRadius, tipLength, tipWidth, fillColor, shadow }`
|
|
281
|
+
|
|
282
|
+
Map size is fixed to `3121x3121`.
|
|
283
|
+
|
|
284
|
+
Result shape:
|
|
285
|
+
|
|
286
|
+
- `buffer` (`image/png`)
|
|
287
|
+
- `map` (`{ url, season, type, width, height }`)
|
|
288
|
+
- `players` (rendered marker metadata)
|
|
289
|
+
- `skipped` (players skipped because coordinates were unavailable/invalid)
|
|
290
|
+
- `requestedUserIds` (IDs requested via `userId` / `userIds`)
|
|
291
|
+
- `unmatchedUserIds` (requested IDs not found in current player payload)
|
|
292
|
+
|
|
158
293
|
## Events
|
|
159
294
|
|
|
160
295
|
- `ready`
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from "events";
|
|
2
|
+
import { Buffer } from "buffer";
|
|
2
3
|
|
|
3
4
|
export interface LoggerLike {
|
|
4
5
|
info?: (...args: any[]) => void;
|
|
@@ -11,11 +12,32 @@ export interface CacheOptions {
|
|
|
11
12
|
enabled?: boolean;
|
|
12
13
|
ttlMs?: number;
|
|
13
14
|
maxSize?: number;
|
|
15
|
+
provider?: "memory" | "redis";
|
|
16
|
+
redisUrl?: string;
|
|
17
|
+
redisPrefix?: string;
|
|
18
|
+
redisClient?: {
|
|
19
|
+
get?: (key: string) => Promise<string | null> | string | null;
|
|
20
|
+
set?: (...args: any[]) => Promise<any> | any;
|
|
21
|
+
del?: (...args: any[]) => Promise<any> | any;
|
|
22
|
+
connect?: () => Promise<void> | void;
|
|
23
|
+
isOpen?: boolean;
|
|
24
|
+
status?: string;
|
|
25
|
+
sAdd?: (...args: any[]) => Promise<any> | any;
|
|
26
|
+
sadd?: (...args: any[]) => Promise<any> | any;
|
|
27
|
+
sRem?: (...args: any[]) => Promise<any> | any;
|
|
28
|
+
srem?: (...args: any[]) => Promise<any> | any;
|
|
29
|
+
sMembers?: (...args: any[]) => Promise<string[]> | string[];
|
|
30
|
+
smembers?: (...args: any[]) => Promise<string[]> | string[];
|
|
31
|
+
[key: string]: any;
|
|
32
|
+
};
|
|
14
33
|
}
|
|
15
34
|
|
|
16
35
|
export interface RateLimitOptions {
|
|
17
36
|
enabled?: boolean;
|
|
18
37
|
strictSerial?: boolean;
|
|
38
|
+
bucketLimit?: number;
|
|
39
|
+
totalLimit?: number;
|
|
40
|
+
unauthLimit?: number;
|
|
19
41
|
perBucketConcurrency?: number;
|
|
20
42
|
globalConcurrency?: number;
|
|
21
43
|
unauthorizedThreshold?: number;
|
|
@@ -70,6 +92,71 @@ export interface CommandExecuteResult {
|
|
|
70
92
|
};
|
|
71
93
|
}
|
|
72
94
|
|
|
95
|
+
export interface MapCoordinateBounds {
|
|
96
|
+
minX: number;
|
|
97
|
+
maxX: number;
|
|
98
|
+
minY: number;
|
|
99
|
+
maxY: number;
|
|
100
|
+
invertY?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface MapMarkerOptions {
|
|
104
|
+
outerRadius?: number;
|
|
105
|
+
innerRadius?: number;
|
|
106
|
+
tipLength?: number;
|
|
107
|
+
tipWidth?: number;
|
|
108
|
+
fillColor?: string;
|
|
109
|
+
shadow?: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface MapRenderOptions {
|
|
113
|
+
userId?: number | string;
|
|
114
|
+
userIds?: Array<number | string>;
|
|
115
|
+
players?: any[];
|
|
116
|
+
mapUrl?: string;
|
|
117
|
+
season?: string;
|
|
118
|
+
type?: string;
|
|
119
|
+
mapSeason?: string;
|
|
120
|
+
mapType?: string;
|
|
121
|
+
coordinateBounds?: MapCoordinateBounds;
|
|
122
|
+
clampToMap?: boolean;
|
|
123
|
+
robloxHeadshotSize?: string;
|
|
124
|
+
marker?: MapMarkerOptions;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface MapRenderedPlayer {
|
|
128
|
+
userId: number | null;
|
|
129
|
+
name: string | null;
|
|
130
|
+
avatarUrl: string | null;
|
|
131
|
+
sourceX: number;
|
|
132
|
+
sourceY: number;
|
|
133
|
+
sourceType: string;
|
|
134
|
+
pixelX: number;
|
|
135
|
+
pixelY: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface MapSkippedPlayer {
|
|
139
|
+
userId: number | null;
|
|
140
|
+
name: string | null;
|
|
141
|
+
reason: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface MapRenderResult {
|
|
145
|
+
buffer: Buffer;
|
|
146
|
+
mimeType: "image/png";
|
|
147
|
+
map: {
|
|
148
|
+
url: string;
|
|
149
|
+
season: string | null;
|
|
150
|
+
type: string | null;
|
|
151
|
+
width: number;
|
|
152
|
+
height: number;
|
|
153
|
+
};
|
|
154
|
+
players: MapRenderedPlayer[];
|
|
155
|
+
skipped: MapSkippedPlayer[];
|
|
156
|
+
requestedUserIds: number[];
|
|
157
|
+
unmatchedUserIds: number[];
|
|
158
|
+
}
|
|
159
|
+
|
|
73
160
|
export interface ServerResponse {
|
|
74
161
|
name: string | null;
|
|
75
162
|
ownerId: number | null;
|
|
@@ -130,7 +217,10 @@ export class ModuleOutOfDateError extends ERLCAPIError {}
|
|
|
130
217
|
|
|
131
218
|
export class Client extends EventEmitter {
|
|
132
219
|
constructor(options: ClientOptions);
|
|
133
|
-
cache: {
|
|
220
|
+
cache: {
|
|
221
|
+
clear: () => void | Promise<void>;
|
|
222
|
+
destroy?: () => void | Promise<void>;
|
|
223
|
+
};
|
|
134
224
|
server: {
|
|
135
225
|
fetch: (
|
|
136
226
|
flags?: ServerFetchFlags,
|
|
@@ -140,6 +230,17 @@ export class Client extends EventEmitter {
|
|
|
140
230
|
players: {
|
|
141
231
|
list: (requestOptions?: RequestOptions) => Promise<any[]>;
|
|
142
232
|
};
|
|
233
|
+
map: {
|
|
234
|
+
render: (
|
|
235
|
+
options?: MapRenderOptions,
|
|
236
|
+
requestOptions?: RequestOptions,
|
|
237
|
+
) => Promise<MapRenderResult>;
|
|
238
|
+
renderUser: (
|
|
239
|
+
userId: number | string,
|
|
240
|
+
options?: Omit<MapRenderOptions, "userId" | "userIds">,
|
|
241
|
+
requestOptions?: RequestOptions,
|
|
242
|
+
) => Promise<MapRenderResult>;
|
|
243
|
+
};
|
|
143
244
|
staff: {
|
|
144
245
|
list: (requestOptions?: RequestOptions) => Promise<any>;
|
|
145
246
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "erlc-v2",
|
|
3
|
-
"version": "1.0.0
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Premium, lightweight JavaScript wrapper for the ER:LC API v2.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"module": "index.mjs",
|
|
@@ -23,6 +23,12 @@
|
|
|
23
23
|
"engines": {
|
|
24
24
|
"node": ">=18"
|
|
25
25
|
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test:map": "node test-map-render.js"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@napi-rs/canvas": "^0.1.74"
|
|
31
|
+
},
|
|
26
32
|
"keywords": [
|
|
27
33
|
"erlc",
|
|
28
34
|
"police-roleplay-community",
|
package/src/Client.js
CHANGED
|
@@ -6,6 +6,7 @@ const { DEFAULT_OPTIONS, QUERY_FLAG_MAP } = require("./util/constants");
|
|
|
6
6
|
const { mergeOptions } = require("./util/options");
|
|
7
7
|
const { createLogger } = require("./util/logger");
|
|
8
8
|
const { normalizeServerResponse } = require("./util/normalize");
|
|
9
|
+
const { renderPlayerMap } = require("./map/renderPlayerMap");
|
|
9
10
|
const { ERLCError } = require("./errors");
|
|
10
11
|
|
|
11
12
|
const BASE_URL = "https://api.policeroleplay.community";
|
|
@@ -54,6 +55,14 @@ function getCommandKeyword(command) {
|
|
|
54
55
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
function isPromiseLike(value) {
|
|
59
|
+
return (
|
|
60
|
+
value !== null &&
|
|
61
|
+
typeof value === "object" &&
|
|
62
|
+
typeof value.then === "function"
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
57
66
|
class Client extends EventEmitter {
|
|
58
67
|
constructor(options = {}) {
|
|
59
68
|
super();
|
|
@@ -64,7 +73,7 @@ class Client extends EventEmitter {
|
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
this.logger = createLogger(this.options.logging, this.options.logger);
|
|
67
|
-
this.cache = new Cache(this.options.cache);
|
|
76
|
+
this.cache = new Cache(this.options.cache, this.logger);
|
|
68
77
|
|
|
69
78
|
this.state = {
|
|
70
79
|
disconnected: false,
|
|
@@ -96,6 +105,13 @@ class Client extends EventEmitter {
|
|
|
96
105
|
.then((d) => d.players),
|
|
97
106
|
};
|
|
98
107
|
|
|
108
|
+
this.map = {
|
|
109
|
+
render: (options = {}, requestOptions = {}) =>
|
|
110
|
+
this._renderMap(options, requestOptions),
|
|
111
|
+
renderUser: (userId, options = {}, requestOptions = {}) =>
|
|
112
|
+
this._renderMap({ ...options, userId }, requestOptions),
|
|
113
|
+
};
|
|
114
|
+
|
|
99
115
|
this.staff = {
|
|
100
116
|
list: (requestOptions = {}) =>
|
|
101
117
|
this.server.fetch({ staff: true }, requestOptions).then((d) => d.staff),
|
|
@@ -333,6 +349,17 @@ class Client extends EventEmitter {
|
|
|
333
349
|
});
|
|
334
350
|
}
|
|
335
351
|
|
|
352
|
+
async _renderMap(options = {}, requestOptions = {}) {
|
|
353
|
+
if (this.state.disconnected && this.state.disconnectError) {
|
|
354
|
+
throw this.state.disconnectError;
|
|
355
|
+
}
|
|
356
|
+
if (this.state.destroyed) {
|
|
357
|
+
throw new ERLCError("Client has been destroyed");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return renderPlayerMap(this, options, requestOptions);
|
|
361
|
+
}
|
|
362
|
+
|
|
336
363
|
_queueCommand(task) {
|
|
337
364
|
const run = this.commandQueue.then(task, task);
|
|
338
365
|
this.commandQueue = run.catch(() => {});
|
|
@@ -346,7 +373,34 @@ class Client extends EventEmitter {
|
|
|
346
373
|
this.requestManager.destroy(
|
|
347
374
|
this.state.disconnectError || new ERLCError("Client destroyed"),
|
|
348
375
|
);
|
|
349
|
-
this.cache.clear();
|
|
376
|
+
const clearResult = this.cache.clear();
|
|
377
|
+
const maybeLog = (promiseLike, msg) => {
|
|
378
|
+
if (!isPromiseLike(promiseLike)) return;
|
|
379
|
+
promiseLike.catch((error) => {
|
|
380
|
+
this.logger.warn({
|
|
381
|
+
msg,
|
|
382
|
+
error: error?.message || String(error),
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
if (isPromiseLike(clearResult)) {
|
|
388
|
+
maybeLog(
|
|
389
|
+
clearResult
|
|
390
|
+
.catch((error) => {
|
|
391
|
+
this.logger.warn({
|
|
392
|
+
msg: "cache_clear_failed",
|
|
393
|
+
error: error?.message || String(error),
|
|
394
|
+
});
|
|
395
|
+
})
|
|
396
|
+
.finally(() => this.cache.destroy?.()),
|
|
397
|
+
"cache_destroy_failed",
|
|
398
|
+
);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const destroyResult = this.cache.destroy?.();
|
|
403
|
+
maybeLog(destroyResult, "cache_destroy_failed");
|
|
350
404
|
}
|
|
351
405
|
}
|
|
352
406
|
|