erlc-v2 1.0.0-beta.2 → 1.0.1

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,11 +2,11 @@
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
- ## Beta Notice
8
-
9
- This wrapper is in beta (`1.0.0-beta.x`) and is not guaranteed to work 100% in all environments or API states.
7
+ ## Stable Release
8
+
9
+ This wrapper is now on a stable release track (`1.0.0+`).
10
10
 
11
11
  ## Responsibility and API Safety
12
12
 
@@ -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({
@@ -69,77 +69,77 @@ new Client({
69
69
  globalKey?: string, // optional
70
70
  logging?: boolean, // default: false
71
71
  logger?: { info, warn, error, debug },
72
- cache?: {
73
- enabled?: boolean, // default: true
74
- ttlMs?: number, // default: 1500
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
80
- },
81
- rateLimit?: {
82
- enabled?: boolean, // default: true
83
- strictSerial?: boolean, // default: true (global one-at-a-time queue)
84
- bucketLimit?: number, // default: 1
85
- totalLimit?: number, // default: 1
86
- unauthLimit?: number, // default: 3
87
- },
72
+ cache?: {
73
+ enabled?: boolean, // default: true
74
+ ttlMs?: number, // default: 1500
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
80
+ },
81
+ rateLimit?: {
82
+ enabled?: boolean, // default: true
83
+ strictSerial?: boolean, // default: true (global one-at-a-time queue)
84
+ bucketLimit?: number, // default: 1
85
+ totalLimit?: number, // default: 1
86
+ unauthLimit?: number, // default: 3
87
+ },
88
88
  polling?: {
89
89
  enabled?: boolean, // default: true
90
90
  intervalMs?: number, // default: 2500 (min enforced: 250)
91
91
  bypassCache?: boolean, // default: true
92
92
  },
93
- });
94
- ```
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
- ```
93
+ });
94
+ ```
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
143
 
144
144
  ## API
145
145
 
@@ -152,6 +152,8 @@ Core:
152
152
  Convenience methods:
153
153
 
154
154
  - `await client.players.list()`
155
+ - `await client.map.render(options?)`
156
+ - `await client.map.renderUser(userId, options?)`
155
157
  - `await client.staff.list()`
156
158
  - `await client.logs.kills()`
157
159
  - `await client.logs.joins()`
@@ -207,6 +209,87 @@ await client.commands.execute(":h Hey everyone!");
207
209
  await client.commands.execute(":log recentban He was trolling!");
208
210
  ```
209
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
+
210
293
  ## Events
211
294
 
212
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;
@@ -91,6 +92,71 @@ export interface CommandExecuteResult {
91
92
  };
92
93
  }
93
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
+
94
160
  export interface ServerResponse {
95
161
  name: string | null;
96
162
  ownerId: number | null;
@@ -164,6 +230,17 @@ export class Client extends EventEmitter {
164
230
  players: {
165
231
  list: (requestOptions?: RequestOptions) => Promise<any[]>;
166
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
+ };
167
244
  staff: {
168
245
  list: (requestOptions?: RequestOptions) => Promise<any>;
169
246
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "erlc-v2",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.1",
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";
@@ -104,6 +105,13 @@ class Client extends EventEmitter {
104
105
  .then((d) => d.players),
105
106
  };
106
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
+
107
115
  this.staff = {
108
116
  list: (requestOptions = {}) =>
109
117
  this.server.fetch({ staff: true }, requestOptions).then((d) => d.staff),
@@ -341,6 +349,17 @@ class Client extends EventEmitter {
341
349
  });
342
350
  }
343
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
+
344
363
  _queueCommand(task) {
345
364
  const run = this.commandQueue.then(task, task);
346
365
  this.commandQueue = run.catch(() => {});
@@ -0,0 +1,719 @@
1
+ const { ERLCError } = require("../errors");
2
+
3
+ const DEFAULT_MAP_URL =
4
+ "https://api.policeroleplay.community/maps/fall_blank.png";
5
+ const FIXED_MAP_SIZE = 3121;
6
+ const OFFICIAL_MAP_BASE_URL = "https://api.policeroleplay.community/maps";
7
+ const ROBLOX_HEADSHOT_URL = "https://thumbnails.roblox.com/v1/users/avatar";
8
+ const MAP_SEASON_ALIASES = {
9
+ fall: "fall",
10
+ autumn: "fall",
11
+ winter: "snow",
12
+ snow: "snow",
13
+ };
14
+ const MAP_TYPE_ALIASES = {
15
+ blank: "blank",
16
+ none: "blank",
17
+ postal: "postals",
18
+ postals: "postals",
19
+ };
20
+
21
+ let canvasModule = null;
22
+
23
+ function loadCanvasModule() {
24
+ if (canvasModule) return canvasModule;
25
+ try {
26
+ canvasModule = require("@napi-rs/canvas");
27
+ return canvasModule;
28
+ } catch (error) {
29
+ throw new ERLCError(
30
+ "Map rendering requires @napi-rs/canvas. Install dependencies and retry.",
31
+ { cause: error?.message || String(error) },
32
+ );
33
+ }
34
+ }
35
+
36
+ function toFiniteNumber(value) {
37
+ if (value === null || value === undefined) return null;
38
+ const numeric =
39
+ typeof value === "number" ? value : Number(String(value).trim());
40
+ if (!Number.isFinite(numeric)) return null;
41
+ return numeric;
42
+ }
43
+
44
+ function toUserId(value) {
45
+ const parsed = toFiniteNumber(value);
46
+ if (parsed === null) return null;
47
+ if (parsed <= 0) return null;
48
+ return Math.trunc(parsed);
49
+ }
50
+
51
+ function normalizeMapSelectionPart(value) {
52
+ if (typeof value !== "string") return null;
53
+ const trimmed = value.trim().toLowerCase();
54
+ if (!trimmed) return null;
55
+ return trimmed.replace(/\s+/g, "_");
56
+ }
57
+
58
+ function normalizeMapSeason(value) {
59
+ const normalized = normalizeMapSelectionPart(value);
60
+ if (!normalized) return null;
61
+ return MAP_SEASON_ALIASES[normalized] || normalized;
62
+ }
63
+
64
+ function normalizeMapType(value) {
65
+ const normalized = normalizeMapSelectionPart(value);
66
+ if (!normalized) return null;
67
+ return MAP_TYPE_ALIASES[normalized] || normalized;
68
+ }
69
+
70
+ function buildOfficialMapUrl(season, type) {
71
+ if (!season && !type) return null;
72
+ const resolvedSeason = season || "fall";
73
+ const resolvedType = type || "blank";
74
+ return {
75
+ url: `${OFFICIAL_MAP_BASE_URL}/${resolvedSeason}_${resolvedType}.png`,
76
+ season: resolvedSeason,
77
+ type: resolvedType,
78
+ };
79
+ }
80
+
81
+ function normalizeFixedMapDimension(value, keyName) {
82
+ const numeric = toFiniteNumber(value);
83
+ if (numeric === null) return FIXED_MAP_SIZE;
84
+ const rounded = Math.round(numeric);
85
+ if (rounded !== FIXED_MAP_SIZE) {
86
+ throw new ERLCError(
87
+ `${keyName} must be ${FIXED_MAP_SIZE}. Custom map sizing is not supported.`,
88
+ );
89
+ }
90
+ return FIXED_MAP_SIZE;
91
+ }
92
+
93
+ function getValueCaseInsensitive(object, targetKey) {
94
+ if (!object || typeof object !== "object") return undefined;
95
+ if (Object.prototype.hasOwnProperty.call(object, targetKey)) {
96
+ return object[targetKey];
97
+ }
98
+
99
+ const normalizedTarget = String(targetKey).toLowerCase();
100
+ for (const key of Object.keys(object)) {
101
+ if (String(key).toLowerCase() === normalizedTarget) {
102
+ return object[key];
103
+ }
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ function getFirstFiniteNumber(object, keys) {
109
+ for (const key of keys) {
110
+ const value = getValueCaseInsensitive(object, key);
111
+ const numeric = toFiniteNumber(value);
112
+ if (numeric !== null) return numeric;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ function extractUserId(player) {
118
+ const directUserId = toUserId(
119
+ getFirstFiniteNumber(player, [
120
+ "UserID",
121
+ "UserId",
122
+ "userId",
123
+ "userid",
124
+ "Id",
125
+ "id",
126
+ ]),
127
+ );
128
+ if (directUserId) return directUserId;
129
+
130
+ const playerField =
131
+ getValueCaseInsensitive(player, "Player") ??
132
+ getValueCaseInsensitive(player, "Name") ??
133
+ getValueCaseInsensitive(player, "Username");
134
+ if (typeof playerField !== "string") return null;
135
+ const trimmed = playerField.trim();
136
+ if (!trimmed) return null;
137
+
138
+ const colonMatch = trimmed.match(/:(\d+)\s*$/);
139
+ if (colonMatch) {
140
+ return toUserId(colonMatch[1]);
141
+ }
142
+
143
+ if (/^\d+$/.test(trimmed)) {
144
+ return toUserId(trimmed);
145
+ }
146
+
147
+ return null;
148
+ }
149
+
150
+ function extractName(player) {
151
+ if (!player || typeof player !== "object") return null;
152
+ const value =
153
+ getValueCaseInsensitive(player, "Player") ??
154
+ getValueCaseInsensitive(player, "Name") ??
155
+ getValueCaseInsensitive(player, "Username") ??
156
+ getValueCaseInsensitive(player, "DisplayName");
157
+ if (typeof value !== "string") return null;
158
+ let trimmed = value.trim();
159
+ const compoundMatch = trimmed.match(/^(.+?):\d+\s*$/);
160
+ if (compoundMatch?.[1]) {
161
+ trimmed = compoundMatch[1].trim();
162
+ }
163
+ return trimmed || null;
164
+ }
165
+
166
+ function extractAvatarUrlFromPlayer(player) {
167
+ if (!player || typeof player !== "object") return null;
168
+ const raw =
169
+ getValueCaseInsensitive(player, "Avatar") ??
170
+ getValueCaseInsensitive(player, "AvatarUrl") ??
171
+ getValueCaseInsensitive(player, "Thumbnail") ??
172
+ getValueCaseInsensitive(player, "ThumbnailUrl") ??
173
+ getValueCaseInsensitive(player, "Headshot") ??
174
+ getValueCaseInsensitive(player, "HeadshotUrl");
175
+ if (typeof raw !== "string") return null;
176
+ const trimmed = raw.trim();
177
+ if (!trimmed) return null;
178
+ return trimmed;
179
+ }
180
+
181
+ function parseCoordinateString(value) {
182
+ if (typeof value !== "string") return null;
183
+ const matches = value.match(/-?\d+(?:\.\d+)?/g);
184
+ if (!matches || matches.length < 2) return null;
185
+ const x = toFiniteNumber(matches[0]);
186
+ const y = toFiniteNumber(matches[1]);
187
+ if (x === null || y === null) return null;
188
+ return { x, y, source: "string" };
189
+ }
190
+
191
+ function extractCoordinatesFromObject(object) {
192
+ if (!object || typeof object !== "object") return null;
193
+
194
+ const x = getFirstFiniteNumber(object, [
195
+ "MapX",
196
+ "mapX",
197
+ "LocationX",
198
+ "locationX",
199
+ "PixelX",
200
+ "pixelX",
201
+ "X",
202
+ "x",
203
+ "PosX",
204
+ "posX",
205
+ "PositionX",
206
+ "positionX",
207
+ ]);
208
+ if (x === null) return null;
209
+
210
+ const y = getFirstFiniteNumber(object, [
211
+ "MapY",
212
+ "mapY",
213
+ "LocationY",
214
+ "locationY",
215
+ "LocationZ",
216
+ "locationZ",
217
+ "PixelY",
218
+ "pixelY",
219
+ "Z",
220
+ "z",
221
+ "Y",
222
+ "y",
223
+ "PosY",
224
+ "posY",
225
+ "PosZ",
226
+ "posZ",
227
+ "PositionY",
228
+ "positionY",
229
+ "PositionZ",
230
+ "positionZ",
231
+ ]);
232
+ if (y === null) return null;
233
+
234
+ return { x, y, source: "object" };
235
+ }
236
+
237
+ function extractCoordinates(player) {
238
+ if (player && typeof player === "object") {
239
+ const direct = extractCoordinatesFromObject(player);
240
+ if (direct) return direct;
241
+
242
+ const nestedKeys = [
243
+ "Location",
244
+ "location",
245
+ "Coords",
246
+ "coords",
247
+ "Coordinate",
248
+ "coordinate",
249
+ "Position",
250
+ "position",
251
+ "Pos",
252
+ "pos",
253
+ ];
254
+ for (const key of nestedKeys) {
255
+ const nested = getValueCaseInsensitive(player, key);
256
+ if (nested && typeof nested === "object") {
257
+ const nestedCoordinates = extractCoordinatesFromObject(nested);
258
+ if (nestedCoordinates) {
259
+ return {
260
+ ...nestedCoordinates,
261
+ source: `nested:${key}`,
262
+ };
263
+ }
264
+ }
265
+ const parsed = parseCoordinateString(nested);
266
+ if (parsed) {
267
+ return {
268
+ ...parsed,
269
+ source: `string:${key}`,
270
+ };
271
+ }
272
+ }
273
+ }
274
+
275
+ return parseCoordinateString(player);
276
+ }
277
+
278
+ function clamp(value, min, max) {
279
+ return Math.min(Math.max(value, min), max);
280
+ }
281
+
282
+ function toPixelCoordinate(rawCoordinates, options) {
283
+ const { mapWidth, mapHeight, coordinateBounds, clampToMap } = options;
284
+ let x = rawCoordinates.x;
285
+ let y = rawCoordinates.y;
286
+
287
+ if (coordinateBounds) {
288
+ const minX = toFiniteNumber(coordinateBounds.minX);
289
+ const maxX = toFiniteNumber(coordinateBounds.maxX);
290
+ const minY = toFiniteNumber(coordinateBounds.minY);
291
+ const maxY = toFiniteNumber(coordinateBounds.maxY);
292
+ if (
293
+ minX === null ||
294
+ maxX === null ||
295
+ minY === null ||
296
+ maxY === null ||
297
+ maxX === minX ||
298
+ maxY === minY
299
+ ) {
300
+ throw new ERLCError(
301
+ "Invalid coordinateBounds. minX/maxX/minY/maxY must be finite and non-equal.",
302
+ );
303
+ }
304
+
305
+ const normalizedX = (x - minX) / (maxX - minX);
306
+ let normalizedY = (y - minY) / (maxY - minY);
307
+ if (coordinateBounds.invertY === true) {
308
+ normalizedY = 1 - normalizedY;
309
+ }
310
+ x = normalizedX * (mapWidth - 1);
311
+ y = normalizedY * (mapHeight - 1);
312
+ }
313
+
314
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
315
+
316
+ if (clampToMap !== false) {
317
+ x = clamp(x, 0, mapWidth - 1);
318
+ y = clamp(y, 0, mapHeight - 1);
319
+ }
320
+
321
+ return { x, y };
322
+ }
323
+
324
+ async function fetchJson(url) {
325
+ const response = await fetch(url);
326
+ if (!response.ok) {
327
+ throw new ERLCError(`Request failed (${response.status}) for ${url}`);
328
+ }
329
+ return response.json();
330
+ }
331
+
332
+ async function fetchBuffer(url) {
333
+ const response = await fetch(url);
334
+ if (!response.ok) {
335
+ throw new ERLCError(`Request failed (${response.status}) for ${url}`);
336
+ }
337
+ const contentType = response.headers.get("content-type") || "";
338
+ if (!contentType.startsWith("image/")) {
339
+ throw new ERLCError(`Expected image response from ${url}`);
340
+ }
341
+ const bytes = await response.arrayBuffer();
342
+ return Buffer.from(bytes);
343
+ }
344
+
345
+ async function fetchRobloxHeadshots(userIds, size) {
346
+ if (!Array.isArray(userIds) || userIds.length === 0) {
347
+ return new Map();
348
+ }
349
+
350
+ const dedupedUserIds = [...new Set(userIds)];
351
+ const lookup = new Map();
352
+ const chunkSize = 100;
353
+
354
+ for (let index = 0; index < dedupedUserIds.length; index += chunkSize) {
355
+ const chunk = dedupedUserIds.slice(index, index + chunkSize);
356
+ const query = new URLSearchParams({
357
+ userIds: chunk.join(","),
358
+ size,
359
+ format: "Png",
360
+ isCircular: "false",
361
+ });
362
+ const payload = await fetchJson(`${ROBLOX_HEADSHOT_URL}?${query}`);
363
+ const data = Array.isArray(payload?.data) ? payload.data : [];
364
+ for (const item of data) {
365
+ const userId = toUserId(item?.targetId);
366
+ const imageUrl =
367
+ typeof item?.imageUrl === "string" ? item.imageUrl : null;
368
+ if (userId && imageUrl) {
369
+ lookup.set(userId, imageUrl);
370
+ }
371
+ }
372
+ }
373
+
374
+ return lookup;
375
+ }
376
+
377
+ function drawPlaceholderAvatar(ctx, centerX, centerY, radius) {
378
+ ctx.fillStyle = "#ffffff";
379
+ ctx.beginPath();
380
+ ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
381
+ ctx.fill();
382
+ }
383
+
384
+ let markerPath2D = null;
385
+
386
+ function drawMarker(ctx, avatarImage, position, markerOptions) {
387
+ const { x, y } = position;
388
+ const { Path2D } = loadCanvasModule();
389
+
390
+ if (!markerPath2D) {
391
+ markerPath2D = new Path2D(
392
+ "M12 2c-4.41 0-8 3.59-8 8c-.03 6.44 7.12 11.6 7.42 11.82c.17.12.38.19.58.19s.41-.06.58-.19c.3-.22 7.45-5.37 7.42-11.82c0-4.41-3.59-8-8-8m0 12c-2.21 0-4-1.79-4-4s1.79-4 4-4s4 1.79 4 4s-1.79 4-4 4",
393
+ );
394
+ }
395
+
396
+ const s = markerOptions.outerRadius / 15;
397
+
398
+ ctx.save();
399
+ ctx.translate(x, y);
400
+ ctx.scale(s, s);
401
+ ctx.translate(-12, -22);
402
+
403
+ ctx.fillStyle = markerOptions.fillColor;
404
+ ctx.fill(markerPath2D);
405
+
406
+ const avatarRadius = 6.8;
407
+ const scaledOffset = (markerOptions.avatarYOffset || 0) / s;
408
+
409
+ ctx.beginPath();
410
+ ctx.arc(12, 10 + scaledOffset, avatarRadius, 0, Math.PI * 2);
411
+ ctx.closePath();
412
+ ctx.clip();
413
+
414
+ ctx.fillStyle = "#ffffff";
415
+ ctx.fillRect(
416
+ 12 - avatarRadius,
417
+ 10 + scaledOffset - avatarRadius,
418
+ avatarRadius * 2,
419
+ avatarRadius * 2,
420
+ );
421
+
422
+ ctx.imageSmoothingEnabled = true;
423
+ ctx.imageSmoothingQuality = "high";
424
+
425
+ if (avatarImage) {
426
+ ctx.drawImage(
427
+ avatarImage,
428
+ 12 - avatarRadius,
429
+ 10 + scaledOffset - avatarRadius,
430
+ avatarRadius * 2,
431
+ avatarRadius * 2,
432
+ );
433
+ } else {
434
+ drawPlaceholderAvatar(ctx, 12, 10 + scaledOffset, avatarRadius);
435
+ }
436
+
437
+ ctx.restore();
438
+ }
439
+
440
+ function normalizeMarkerOptions(options = {}) {
441
+ const scaleRaw = toFiniteNumber(options.scale);
442
+ const scale = Number.isFinite(scaleRaw)
443
+ ? Math.max(0.15, Math.min(3, scaleRaw))
444
+ : 1;
445
+
446
+ const baseOuter = toFiniteNumber(options.outerRadius) ?? 45;
447
+ const baseInner = toFiniteNumber(options.innerRadius) ?? baseOuter * 0.78;
448
+ const baseTip = toFiniteNumber(options.tipLength) ?? baseOuter * 1.15;
449
+
450
+ const outerRadius = Math.max(6, Math.round(baseOuter * scale));
451
+ const innerRadius = Math.max(
452
+ 4,
453
+ Math.min(outerRadius - 3, Math.round(baseInner * scale)),
454
+ );
455
+ const tipLength = Math.max(8, Math.round(baseTip * scale));
456
+
457
+ const strokeWidth = Number.isFinite(toFiniteNumber(options.strokeWidth))
458
+ ? Math.max(0, Math.round(toFiniteNumber(options.strokeWidth) * scale))
459
+ : Math.max(2, Math.round(outerRadius * 0.16));
460
+
461
+ const baseAngle = Number.isFinite(toFiniteNumber(options.baseAngle))
462
+ ? Math.max(0.45, Math.min(0.75, toFiniteNumber(options.baseAngle)))
463
+ : 0.58;
464
+
465
+ return {
466
+ scale,
467
+ outerRadius,
468
+ innerRadius,
469
+ tipLength,
470
+ baseAngle,
471
+
472
+ fillColor:
473
+ typeof options.fillColor === "string" && options.fillColor.trim()
474
+ ? options.fillColor
475
+ : "#ffffff",
476
+
477
+ avatarYOffset: Number.isFinite(toFiniteNumber(options.avatarYOffset))
478
+ ? toFiniteNumber(options.avatarYOffset)
479
+ : 0,
480
+ };
481
+ }
482
+
483
+ function normalizeRenderOptions(input = {}) {
484
+ const mapWidth = normalizeFixedMapDimension(input.mapWidth, "mapWidth");
485
+ const mapHeight = normalizeFixedMapDimension(input.mapHeight, "mapHeight");
486
+ const customMapUrl =
487
+ typeof input.mapUrl === "string" && input.mapUrl.trim()
488
+ ? input.mapUrl.trim()
489
+ : null;
490
+ const requestedMapSeason = normalizeMapSeason(
491
+ input.mapSeason ?? input.season,
492
+ );
493
+ const requestedMapType = normalizeMapType(input.mapType ?? input.type);
494
+ const officialMap = customMapUrl
495
+ ? null
496
+ : buildOfficialMapUrl(
497
+ requestedMapSeason || "fall",
498
+ requestedMapType || "blank",
499
+ );
500
+
501
+ return {
502
+ mapUrl: customMapUrl || officialMap?.url || DEFAULT_MAP_URL,
503
+ mapSeason: officialMap?.season || null,
504
+ mapType: officialMap?.type || null,
505
+ mapWidth,
506
+ mapHeight,
507
+ userId: toUserId(input.userId),
508
+ userIds: Array.isArray(input.userIds)
509
+ ? input.userIds.map(toUserId).filter(Boolean)
510
+ : [],
511
+ players: Array.isArray(input.players) ? input.players : null,
512
+ coordinateBounds:
513
+ input.coordinateBounds && typeof input.coordinateBounds === "object"
514
+ ? {
515
+ minX: input.coordinateBounds.minX,
516
+ maxX: input.coordinateBounds.maxX,
517
+ minY: input.coordinateBounds.minY,
518
+ maxY: input.coordinateBounds.maxY,
519
+ invertY: input.coordinateBounds.invertY === true,
520
+ }
521
+ : null,
522
+ clampToMap: input.clampToMap !== false,
523
+ robloxHeadshotSize:
524
+ typeof input.robloxHeadshotSize === "string" &&
525
+ /^\d+x\d+$/i.test(input.robloxHeadshotSize)
526
+ ? input.robloxHeadshotSize
527
+ : "150x150",
528
+ marker: normalizeMarkerOptions(input.marker),
529
+ };
530
+ }
531
+
532
+ function buildPlayerRecords(players, renderOptions) {
533
+ const records = [];
534
+ const skipped = [];
535
+
536
+ for (const player of players) {
537
+ const userId = extractUserId(player);
538
+ const name = extractName(player);
539
+
540
+ const rawCoordinates = extractCoordinates(player);
541
+ if (!rawCoordinates) {
542
+ skipped.push({
543
+ userId,
544
+ name,
545
+ reason: "missing_coordinates",
546
+ });
547
+ continue;
548
+ }
549
+
550
+ const pixelCoordinates = toPixelCoordinate(rawCoordinates, renderOptions);
551
+ if (!pixelCoordinates) {
552
+ skipped.push({
553
+ userId,
554
+ name,
555
+ reason: "invalid_coordinates",
556
+ });
557
+ continue;
558
+ }
559
+
560
+ records.push({
561
+ userId,
562
+ name,
563
+ player,
564
+ rawCoordinates,
565
+ pixelCoordinates,
566
+ avatarUrl: extractAvatarUrlFromPlayer(player),
567
+ });
568
+ }
569
+
570
+ return { records, skipped };
571
+ }
572
+
573
+ function applyUserFilters(records, options) {
574
+ const targetUserIds = new Set(options.userIds);
575
+ if (options.userId) {
576
+ targetUserIds.add(options.userId);
577
+ }
578
+ if (targetUserIds.size === 0) {
579
+ return {
580
+ records,
581
+ requestedUserIds: [],
582
+ unmatchedUserIds: [],
583
+ };
584
+ }
585
+
586
+ const filteredRecords = records.filter(
587
+ (record) => record.userId && targetUserIds.has(record.userId),
588
+ );
589
+ const matchedUserIds = new Set(
590
+ filteredRecords.map((record) => record.userId).filter(Boolean),
591
+ );
592
+ const unmatchedUserIds = [...targetUserIds].filter(
593
+ (userId) => !matchedUserIds.has(userId),
594
+ );
595
+
596
+ return {
597
+ records: filteredRecords,
598
+ requestedUserIds: [...targetUserIds],
599
+ unmatchedUserIds,
600
+ };
601
+ }
602
+
603
+ async function loadImageSafe(loadImage, urlOrBuffer) {
604
+ try {
605
+ return await loadImage(urlOrBuffer);
606
+ } catch {
607
+ return null;
608
+ }
609
+ }
610
+
611
+ async function renderPlayerMap(client, options = {}, requestOptions = {}) {
612
+ const { createCanvas, loadImage } = loadCanvasModule();
613
+ const renderOptions = normalizeRenderOptions(options);
614
+
615
+ const rawPlayers =
616
+ renderOptions.players ||
617
+ (await client.server
618
+ .fetch(
619
+ {
620
+ players: true,
621
+ },
622
+ requestOptions,
623
+ )
624
+ .then((snapshot) => snapshot.players));
625
+
626
+ if (!Array.isArray(rawPlayers)) {
627
+ throw new ERLCError("Players response was not an array");
628
+ }
629
+
630
+ const { records, skipped } = buildPlayerRecords(rawPlayers, renderOptions);
631
+ const filtered = applyUserFilters(records, renderOptions);
632
+ const filteredRecords = filtered.records;
633
+
634
+ const userIdsForHeadshots = filteredRecords
635
+ .map((record) => record.userId)
636
+ .filter(Boolean);
637
+ const robloxHeadshots = await fetchRobloxHeadshots(
638
+ userIdsForHeadshots,
639
+ renderOptions.robloxHeadshotSize,
640
+ );
641
+
642
+ const avatarUrlToImage = new Map();
643
+ const avatarSources = [
644
+ ...new Set(
645
+ filteredRecords
646
+ .map((record) => robloxHeadshots.get(record.userId) || record.avatarUrl)
647
+ .filter(Boolean),
648
+ ),
649
+ ];
650
+
651
+ await Promise.all(
652
+ avatarSources.map(async (avatarUrl) => {
653
+ const buffer = await fetchBuffer(avatarUrl).catch(() => null);
654
+ if (!buffer) return;
655
+ const image = await loadImageSafe(loadImage, buffer);
656
+ if (image) {
657
+ avatarUrlToImage.set(avatarUrl, image);
658
+ }
659
+ }),
660
+ );
661
+
662
+ const mapBuffer = await fetchBuffer(renderOptions.mapUrl);
663
+ const mapImage = await loadImageSafe(loadImage, mapBuffer);
664
+ if (!mapImage) {
665
+ throw new ERLCError("Failed to decode map image");
666
+ }
667
+
668
+ const canvas = createCanvas(renderOptions.mapWidth, renderOptions.mapHeight);
669
+ const ctx = canvas.getContext("2d");
670
+ ctx.drawImage(
671
+ mapImage,
672
+ 0,
673
+ 0,
674
+ renderOptions.mapWidth,
675
+ renderOptions.mapHeight,
676
+ );
677
+
678
+ const renderedPlayers = [];
679
+ for (const record of filteredRecords) {
680
+ const resolvedAvatarUrl =
681
+ robloxHeadshots.get(record.userId) || record.avatarUrl;
682
+ const avatarImage = resolvedAvatarUrl
683
+ ? avatarUrlToImage.get(resolvedAvatarUrl) || null
684
+ : null;
685
+
686
+ drawMarker(ctx, avatarImage, record.pixelCoordinates, renderOptions.marker);
687
+ renderedPlayers.push({
688
+ userId: record.userId,
689
+ name: record.name,
690
+ avatarUrl: resolvedAvatarUrl || null,
691
+ sourceX: record.rawCoordinates.x,
692
+ sourceY: record.rawCoordinates.y,
693
+ sourceType: record.rawCoordinates.source,
694
+ pixelX: Math.round(record.pixelCoordinates.x * 100) / 100,
695
+ pixelY: Math.round(record.pixelCoordinates.y * 100) / 100,
696
+ });
697
+ }
698
+
699
+ return {
700
+ buffer: canvas.toBuffer("image/png"),
701
+ mimeType: "image/png",
702
+ map: {
703
+ url: renderOptions.mapUrl,
704
+ season: renderOptions.mapSeason,
705
+ type: renderOptions.mapType,
706
+ width: renderOptions.mapWidth,
707
+ height: renderOptions.mapHeight,
708
+ },
709
+ players: renderedPlayers,
710
+ skipped,
711
+ requestedUserIds: filtered.requestedUserIds,
712
+ unmatchedUserIds: filtered.unmatchedUserIds,
713
+ };
714
+ }
715
+
716
+ module.exports = {
717
+ DEFAULT_MAP_URL,
718
+ renderPlayerMap,
719
+ };