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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  JavaScript client for the ER:LC API v2.
4
4
 
5
- Built for Node 18+ with no runtime dependencies.
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
- perBucketConcurrency?: number, // default: 1
81
- globalConcurrency?: number, // default: 1
82
- unauthorizedThreshold?: number, // default: 3
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: { clear: () => void };
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-beta.1",
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