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 +160 -77
- package/index.d.ts +77 -0
- package/package.json +7 -1
- package/src/Client.js +19 -0
- package/src/map/renderPlayerMap.js +719 -0
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
|
|
5
|
+
Built for Node 18+.
|
|
6
6
|
|
|
7
|
-
##
|
|
8
|
-
|
|
9
|
-
This wrapper is
|
|
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.
|
|
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
|
+
};
|