server.js (39619B)
1 const fs = require("fs"); 2 const path = require("path"); 3 4 module.exports = function init(api) { 5 const BUILTIN_MAPS = [ 6 { 7 id: "studio", 8 title: "Studio (demo)", 9 owner: "", 10 // Placeholder image; replace with your own PNG in a real plugin build. 11 backgroundUrl: "/assets/logobzl.png", 12 thumbUrl: "/assets/logobzl.png", 13 world: { w: 1400, h: 900 }, 14 avatarSize: 36, 15 cameraZoom: 2.35, 16 collisions: [], 17 masks: [], 18 exits: [], 19 hiddenMasks: [], 20 occluders: [], 21 ttrpgEnabled: false, 22 sprites: [], 23 props: [], 24 walkiesEnabled: false 25 } 26 ]; 27 28 const DATA_DIR = path.join(process.cwd(), "data", "plugin-data"); 29 const MAPS_FILE = path.join(DATA_DIR, "maps.json"); 30 31 /** @type {Array<{id:string,title:string,owner:string,backgroundUrl:string,thumbUrl:string,world?:{w:number,h:number}|null,avatarSize?:number,cameraZoom?:number,collisions?:any[],masks?:any[],exits?:any[],ttrpgEnabled?:boolean,sprites?:any[],props?:any[],walkiesEnabled?:boolean}>} */ 32 let customMaps = []; 33 34 /** @type {Map<string, {users: Map<string, {x:number,y:number,color:string,image:string,invisible?:boolean,seq?:number}>, lastListAt:number, walkies?: Map<string, {url:string, pending:Set<string>, createdAt:number, mapId:string}>}>} */ 35 const rooms = new Map(); 36 37 function normId(raw) { 38 const s = typeof raw === "string" ? raw.trim().toLowerCase() : ""; 39 if (!s) return ""; 40 if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(s)) return ""; 41 return s; 42 } 43 44 function clampInt(n, min, max) { 45 const x = Math.floor(Number(n)); 46 if (!Number.isFinite(x)) return min; 47 return Math.max(min, Math.min(max, x)); 48 } 49 50 function isSafeImageUrl(url) { 51 const u = typeof url === "string" ? url.trim() : ""; 52 if (!u) return false; 53 if (u.startsWith("/uploads/")) return true; 54 if (u.startsWith("/assets/")) return true; 55 return false; 56 } 57 58 function isSafeUploadUrl(url) { 59 const u = typeof url === "string" ? url.trim() : ""; 60 if (!u.startsWith("/uploads/")) return false; 61 if (!/^\/uploads\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,220}$/.test(u)) return false; 62 return true; 63 } 64 65 function uploadsDir() { 66 return process.env.UPLOADS_DIR || path.join(process.cwd(), "data", "uploads"); 67 } 68 69 function tryDeleteUploadSoon(url, createdAt) { 70 if (!isSafeUploadUrl(url)) return false; 71 const filename = url.replace("/uploads/", ""); 72 const filePath = path.resolve(path.join(uploadsDir(), filename)); 73 const root = path.resolve(uploadsDir()) + path.sep; 74 if (!filePath.startsWith(root)) return false; 75 const now = api.now(); 76 // Only delete "fresh" uploads to avoid nuking older content. 77 if (now - Number(createdAt || 0) > 10 * 60 * 1000) return false; 78 try { 79 const st = fs.statSync(filePath); 80 if (!st.isFile()) return false; 81 if (now - st.mtimeMs > 10 * 60 * 1000) return false; 82 fs.unlinkSync(filePath); 83 return true; 84 } catch { 85 return false; 86 } 87 } 88 89 90 function normalizePolyList(list) { 91 const input = Array.isArray(list) ? list : []; 92 const out = []; 93 const maxPolys = 80; 94 const maxPoints = 60; 95 for (const raw of input.slice(0, maxPolys)) { 96 const points = Array.isArray(raw?.points) ? raw.points : []; 97 if (points.length < 3) continue; 98 const normPoints = []; 99 for (const p of points.slice(0, maxPoints)) { 100 const x = Number(p?.x); 101 const y = Number(p?.y); 102 if (!Number.isFinite(x) || !Number.isFinite(y)) continue; 103 normPoints.push({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }); 104 } 105 if (normPoints.length < 3) continue; 106 out.push({ points: normPoints }); 107 } 108 return out; 109 } 110 111 function normalizeExitList(list) { 112 const input = Array.isArray(list) ? list : []; 113 const out = []; 114 const maxExits = 40; 115 const maxPoints = 60; 116 for (const raw of input.slice(0, maxExits)) { 117 const points = Array.isArray(raw?.points) ? raw.points : []; 118 if (points.length < 3) continue; 119 const normPoints = []; 120 for (const p of points.slice(0, maxPoints)) { 121 const x = Number(p?.x); 122 const y = Number(p?.y); 123 if (!Number.isFinite(x) || !Number.isFinite(y)) continue; 124 normPoints.push({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }); 125 } 126 if (normPoints.length < 3) continue; 127 const name = typeof raw?.name === "string" ? raw.name.trim().slice(0, 40) : ""; 128 const actionRaw = typeof raw?.action === "string" ? raw.action.trim() : ""; 129 const action = actionRaw === "toMap" ? "toMap" : "toMaps"; 130 const toMapId = action === "toMap" ? normId(raw?.toMapId || "") : ""; 131 if (action === "toMap" && !toMapId) continue; 132 const targetExit = action === "toMap" && typeof raw?.targetExit === "string" ? raw.targetExit.trim().slice(0, 40) : ""; 133 out.push({ points: normPoints, name, action, toMapId, targetExit }); 134 } 135 return out; 136 } 137 138 function normalizeSpriteList(list) { 139 const input = Array.isArray(list) ? list : []; 140 const out = []; 141 const max = 120; 142 for (const raw of input.slice(0, max)) { 143 const id = typeof raw?.id === "string" ? raw.id.trim() : ""; 144 const safeId = id && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,64}$/.test(id) ? id : randId("spr"); 145 const kind = raw?.kind === "token" ? "token" : "prop"; 146 const name = typeof raw?.name === "string" ? raw.name.trim().slice(0, 40) : ""; 147 const url = typeof raw?.url === "string" ? raw.url.trim() : ""; 148 if (!url.startsWith("/uploads/")) continue; 149 if (!isSafeImageUrl(url)) continue; 150 const scale = clampFloat(raw?.scale, 0.1, 4.0, 1.0); 151 out.push({ id: safeId, kind, name, url, scale }); 152 } 153 return out; 154 } 155 156 function normalizePropList(list, allowedSpriteIds = null) { 157 const input = Array.isArray(list) ? list : []; 158 const out = []; 159 const max = 800; 160 for (const raw of input.slice(0, max)) { 161 const id = typeof raw?.id === "string" ? raw.id.trim() : ""; 162 const safeId = id && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,64}$/.test(id) ? id : randId("prop"); 163 const spriteId = typeof raw?.spriteId === "string" ? raw.spriteId.trim() : ""; 164 if (!spriteId) continue; 165 if (allowedSpriteIds && !allowedSpriteIds.has(spriteId)) continue; 166 const x = clamp01(raw?.x); 167 const y = clamp01(raw?.y); 168 const z = clampInt(raw?.z || 0, -10_000, 10_000); 169 const rot = clampFloat(raw?.rot, -180, 180, 0); 170 const scale = clampFloat(raw?.scale, 0.1, 4.0, 1.0); 171 const nickname = typeof raw?.nickname === "string" ? raw.nickname.trim().slice(0, 40) : ""; 172 const hpMax = clampInt(raw?.hpMax || 10, 0, 9999); 173 const hpCurrent = clampInt(raw?.hpCurrent || hpMax, 0, hpMax > 0 ? hpMax : 9999); 174 const controlledBy = typeof raw?.controlledBy === "string" ? normId(raw.controlledBy) : ""; 175 out.push({ id: safeId, spriteId, x, y, z, rot, scale, nickname, hpCurrent, hpMax, controlledBy }); 176 } 177 return out; 178 } 179 180 function canManageMaps(ws, map) { 181 const role = String(ws?.user?.role || "").toLowerCase(); 182 const username = userIdentity(ws); 183 if (role === "owner" || role === "moderator") return true; 184 if (map && username && map.owner && username === map.owner) return true; 185 return false; 186 } 187 188 function clamp01(n) { 189 const x = Number(n); 190 if (!Number.isFinite(x)) return 0; 191 return Math.max(0, Math.min(1, x)); 192 } 193 194 function clampSeq(n) { 195 const x = Math.floor(Number(n)); 196 if (!Number.isFinite(x) || x < 0) return 0; 197 return Math.min(1_000_000_000, x); 198 } 199 200 function clampFloat(n, min, max, fallback = min) { 201 const x = Number(n); 202 if (!Number.isFinite(x)) return fallback; 203 return Math.max(min, Math.min(max, x)); 204 } 205 206 function randId(prefix = "id") { 207 return `${prefix}_${api.now()}_${Math.random().toString(16).slice(2)}`; 208 } 209 210 const saveTimersByMapId = new Map(); 211 function scheduleSaveSoon(mapId) { 212 const mid = normId(mapId); 213 if (!mid) return; 214 const existing = saveTimersByMapId.get(mid); 215 if (existing) clearTimeout(existing); 216 saveTimersByMapId.set( 217 mid, 218 setTimeout(() => { 219 saveTimersByMapId.delete(mid); 220 try { 221 saveCustomMapsToDisk(); 222 } catch (e) { 223 console.warn("Maps plugin: failed to persist maps:", e?.message || e); 224 } 225 }, 500) 226 ); 227 } 228 229 function mapById(id) { 230 const mid = normId(id); 231 if (!mid) return null; 232 return BUILTIN_MAPS.find((m) => m.id === mid) || customMaps.find((m) => m.id === mid) || null; 233 } 234 235 function spriteById(map, spriteId) { 236 const sid = typeof spriteId === "string" ? spriteId.trim() : ""; 237 if (!sid) return null; 238 const sprites = Array.isArray(map?.sprites) ? map.sprites : []; 239 return sprites.find((s) => String(s?.id || "") === sid) || null; 240 } 241 242 function propById(map, propId) { 243 const pid = typeof propId === "string" ? propId.trim() : ""; 244 if (!pid) return { prop: null, index: -1 }; 245 const props = Array.isArray(map?.props) ? map.props : []; 246 const index = props.findIndex((p) => String(p?.id || "") === pid); 247 return { prop: index >= 0 ? props[index] : null, index }; 248 } 249 250 function roomFor(mapId) { 251 const mid = normId(mapId); 252 if (!mid) return null; 253 if (!rooms.has(mid)) rooms.set(mid, { users: new Map(), lastListAt: 0, walkies: new Map() }); 254 return rooms.get(mid) || null; 255 } 256 257 function userIdentity(ws) { 258 const u = ws?.user?.username ? String(ws.user.username).trim().toLowerCase() : ""; 259 return u && /^[a-z0-9][a-z0-9_.-]{0,31}$/.test(u) ? u : ""; 260 } 261 262 function listMapsPayload() { 263 const all = [...BUILTIN_MAPS, ...customMaps]; 264 return all.map((m) => { 265 const room = rooms.get(m.id); 266 const count = room ? Array.from(room.users.values()).filter((u) => !u?.invisible).length : 0; 267 return { 268 id: m.id, 269 title: m.title, 270 owner: m.owner || "", 271 thumbUrl: m.thumbUrl, 272 backgroundUrl: m.backgroundUrl, 273 world: m.world, 274 avatarSize: clampInt(m.avatarSize || 36, 18, 96), 275 cameraZoom: clampFloat(m.cameraZoom, 0.8, 5.0, 2.35), 276 walkiesEnabled: Boolean(m.walkiesEnabled), 277 ttrpgEnabled: Boolean(m.ttrpgEnabled), 278 spritesCount: Array.isArray(m.sprites) ? m.sprites.length : 0, 279 propsCount: Array.isArray(m.props) ? m.props.length : 0, 280 collisionsCount: Array.isArray(m.collisions) ? m.collisions.length : 0, 281 masksCount: Array.isArray(m.masks) ? m.masks.length : 0, 282 exitsCount: Array.isArray(m.exits) ? m.exits.length : 0, 283 userCount: count 284 }; 285 }); 286 } 287 288 function broadcastMapsListThrottled() { 289 // Avoid spamming when users move around maps frequently. 290 const t = api.now(); 291 let should = false; 292 for (const m of [...BUILTIN_MAPS, ...customMaps]) { 293 const r = roomFor(m.id); 294 if (!r) continue; 295 if (t - (r.lastListAt || 0) > 750) { 296 r.lastListAt = t; 297 should = true; 298 } 299 } 300 if (!should) return; 301 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 302 } 303 304 function usersInRoom(mapId) { 305 const room = rooms.get(normId(mapId)); 306 if (!room) return []; 307 return Array.from(room.users.keys()); 308 } 309 310 function broadcastRoomState(mapId) { 311 const mid = normId(mapId); 312 const room = rooms.get(mid); 313 if (!room) return; 314 const all = Array.from(room.users.entries()); 315 const recipients = usersInRoom(mid); 316 for (const recipient of recipients) { 317 const users = all 318 .filter(([username, u]) => username === recipient || !u?.invisible) 319 .map(([username, u]) => ({ 320 username, 321 x: u.x, 322 y: u.y, 323 color: u.color || "", 324 image: u.image || "" 325 })); 326 api.sendToUsers([recipient], { type: "plugin:maps:roomState", mapId: mid, users }); 327 } 328 broadcastMapsListThrottled(); 329 } 330 331 function leaveAnyRoom(ws) { 332 const username = userIdentity(ws); 333 if (!username) return; 334 const current = normId(ws.__mapsRoomId || ""); 335 if (!current) return; 336 const room = rooms.get(current); 337 if (!room) { 338 ws.__mapsRoomId = ""; 339 ws.__mapsInvisible = 0; 340 ws.__mapsSpeakAsPropId = ""; 341 return; 342 } 343 if (room.users.has(username)) room.users.delete(username); 344 ws.__mapsRoomId = ""; 345 ws.__mapsInvisible = 0; 346 ws.__mapsSpeakAsPropId = ""; 347 if (room.users.size === 0) rooms.delete(current); 348 broadcastRoomState(current); 349 } 350 351 api.onWsClose((ws) => { 352 leaveAnyRoom(ws); 353 }); 354 355 function loadCustomMapsFromDisk() { 356 try { 357 fs.mkdirSync(DATA_DIR, { recursive: true }); 358 if (!fs.existsSync(MAPS_FILE)) { 359 customMaps = []; 360 return; 361 } 362 const raw = fs.readFileSync(MAPS_FILE, "utf8"); 363 const json = JSON.parse(raw); 364 const list = Array.isArray(json?.maps) ? json.maps : []; 365 const next = []; 366 for (const m of list) { 367 const id = normId(m?.id || ""); 368 if (!id) continue; 369 if (BUILTIN_MAPS.some((b) => b.id === id)) continue; 370 const title = typeof m?.title === "string" ? m.title.trim().slice(0, 60) : id; 371 const owner = typeof m?.owner === "string" ? normId(m.owner) : ""; 372 const backgroundUrl = typeof m?.backgroundUrl === "string" ? m.backgroundUrl.trim() : ""; 373 const thumbUrl = typeof m?.thumbUrl === "string" ? m.thumbUrl.trim() : backgroundUrl; 374 if (!isSafeImageUrl(backgroundUrl) || !isSafeImageUrl(thumbUrl)) continue; 375 const avatarSize = clampInt(m?.avatarSize || 36, 18, 96); 376 const cameraZoom = clampFloat(m?.cameraZoom, 0.8, 5.0, 2.35); 377 const walkiesEnabled = Boolean(m?.walkiesEnabled); 378 const world = 379 m?.world && typeof m.world === "object" 380 ? { w: clampInt(m.world.w, 200, 10000), h: clampInt(m.world.h, 200, 10000) } 381 : null; 382 const collisions = normalizePolyList(m?.collisions); 383 const masks = normalizePolyList(m?.masks); 384 const exits = normalizeExitList(m?.exits); 385 const hiddenMasks = normalizePolyList(m?.hiddenMasks); 386 const occluders = normalizePolyList(m?.occluders); 387 const ttrpgEnabled = Boolean(m?.ttrpgEnabled); 388 const sprites = normalizeSpriteList(m?.sprites); 389 const spriteIds = new Set(sprites.map((s) => s.id)); 390 const props = normalizePropList(m?.props, spriteIds); 391 next.push({ 392 id, 393 title, 394 owner, 395 backgroundUrl, 396 thumbUrl, 397 world, 398 avatarSize, 399 cameraZoom, 400 collisions, 401 masks, 402 exits, 403 hiddenMasks, 404 occluders, 405 ttrpgEnabled, 406 sprites, 407 props, 408 walkiesEnabled 409 }); 410 } 411 customMaps = next; 412 } catch (e) { 413 console.warn("Maps plugin: failed to load custom maps:", e?.message || e); 414 customMaps = []; 415 } 416 } 417 418 function saveCustomMapsToDisk() { 419 fs.mkdirSync(DATA_DIR, { recursive: true }); 420 fs.writeFileSync(MAPS_FILE, JSON.stringify({ maps: customMaps }, null, 2)); 421 } 422 423 loadCustomMapsFromDisk(); 424 425 api.registerWs("list", (ws) => { 426 ws.send(JSON.stringify({ type: "plugin:maps:mapsList", maps: listMapsPayload() })); 427 }); 428 429 api.registerWs("createMap", (ws, msg) => { 430 const username = userIdentity(ws); 431 if (!username) { 432 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Sign in required." })); 433 return; 434 } 435 const role = String(ws?.user?.role || "").toLowerCase(); 436 if (role !== "owner" && role !== "moderator") { 437 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Owner/mod access required to create maps." })); 438 return; 439 } 440 441 const id = normId(msg?.id || ""); 442 if (!id) { 443 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Invalid map id." })); 444 return; 445 } 446 if (mapById(id)) { 447 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map id already exists." })); 448 return; 449 } 450 451 const title = typeof msg?.title === "string" ? msg.title.trim().slice(0, 60) : ""; 452 if (!title) { 453 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Missing map title." })); 454 return; 455 } 456 const backgroundUrl = typeof msg?.backgroundUrl === "string" ? msg.backgroundUrl.trim() : ""; 457 const thumbUrl = typeof msg?.thumbUrl === "string" ? msg.thumbUrl.trim() : backgroundUrl; 458 if (!isSafeImageUrl(backgroundUrl) || !isSafeImageUrl(thumbUrl)) { 459 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Invalid map image URL." })); 460 return; 461 } 462 const avatarSize = clampInt(msg?.avatarSize || 36, 18, 96); 463 464 customMaps.push({ 465 id, 466 title, 467 owner: username, 468 backgroundUrl, 469 thumbUrl, 470 world: null, 471 avatarSize, 472 cameraZoom: 2.35, 473 collisions: [], 474 masks: [], 475 exits: [], 476 hiddenMasks: [], 477 occluders: [], 478 ttrpgEnabled: false, 479 sprites: [], 480 props: [], 481 walkiesEnabled: false 482 }); 483 try { 484 saveCustomMapsToDisk(); 485 } catch (e) { 486 customMaps = customMaps.filter((m) => m.id !== id); 487 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Failed to save map." })); 488 return; 489 } 490 491 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 492 }); 493 494 api.registerWs("updateMap", (ws, msg) => { 495 const mapId = normId(msg?.id || ""); 496 const map = mapById(mapId); 497 if (!map || BUILTIN_MAPS.some((m) => m.id === mapId)) { 498 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 499 return; 500 } 501 if (!canManageMaps(ws, map)) { 502 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 503 return; 504 } 505 const idx = customMaps.findIndex((m) => m.id === mapId); 506 if (idx < 0) return; 507 508 const next = { ...customMaps[idx] }; 509 const patch = {}; 510 if (msg && Object.prototype.hasOwnProperty.call(msg, "avatarSize")) { 511 next.avatarSize = clampInt(msg.avatarSize, 18, 96); 512 patch.avatarSize = next.avatarSize; 513 } 514 if (msg && Object.prototype.hasOwnProperty.call(msg, "cameraZoom")) { 515 next.cameraZoom = clampFloat(msg.cameraZoom, 0.8, 5.0, 2.35); 516 patch.cameraZoom = next.cameraZoom; 517 } 518 if (msg && Object.prototype.hasOwnProperty.call(msg, "collisions")) { 519 next.collisions = normalizePolyList(msg.collisions); 520 patch.collisions = next.collisions; 521 } 522 if (msg && Object.prototype.hasOwnProperty.call(msg, "masks")) { 523 next.masks = normalizePolyList(msg.masks); 524 patch.masks = next.masks; 525 } 526 if (msg && Object.prototype.hasOwnProperty.call(msg, "exits")) { 527 next.exits = normalizeExitList(msg.exits); 528 patch.exits = next.exits; 529 } 530 if (msg && Object.prototype.hasOwnProperty.call(msg, "hiddenMasks")) { 531 next.hiddenMasks = normalizePolyList(msg.hiddenMasks); 532 patch.hiddenMasks = next.hiddenMasks; 533 } 534 if (msg && Object.prototype.hasOwnProperty.call(msg, "occluders")) { 535 next.occluders = normalizePolyList(msg.occluders); 536 patch.occluders = next.occluders; 537 } 538 if (msg && Object.prototype.hasOwnProperty.call(msg, "ttrpgEnabled")) { 539 next.ttrpgEnabled = Boolean(msg.ttrpgEnabled); 540 } 541 if (msg && Object.prototype.hasOwnProperty.call(msg, "sprites")) { 542 next.sprites = normalizeSpriteList(msg.sprites); 543 } 544 if (msg && Object.prototype.hasOwnProperty.call(msg, "props")) { 545 const spriteIds = new Set((Array.isArray(next.sprites) ? next.sprites : []).map((s) => s?.id).filter(Boolean)); 546 next.props = normalizePropList(msg.props, spriteIds); 547 } 548 if (msg && Object.prototype.hasOwnProperty.call(msg, "walkiesEnabled")) { 549 next.walkiesEnabled = Boolean(msg.walkiesEnabled); 550 patch.walkiesEnabled = next.walkiesEnabled; 551 } 552 customMaps[idx] = next; 553 scheduleSaveSoon(mapId); 554 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 555 if (Object.keys(patch).length) { 556 sendToRoom(mapId, { type: "plugin:maps:mapPatched", mapId, patch }); 557 } 558 }); 559 560 function customMapIndex(mapId) { 561 const mid = normId(mapId); 562 if (!mid) return -1; 563 return customMaps.findIndex((m) => m.id === mid); 564 } 565 566 function sendToRoom(mapId, msg) { 567 const mid = normId(mapId); 568 if (!mid) return 0; 569 return api.sendToUsers(usersInRoom(mid), msg); 570 } 571 572 api.registerWs("ttrpgSetEnabled", (ws, msg) => { 573 const mapId = normId(msg?.mapId || msg?.id || ""); 574 const idx = customMapIndex(mapId); 575 if (idx < 0) { 576 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 577 return; 578 } 579 const map = customMaps[idx]; 580 if (!canManageMaps(ws, map)) { 581 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 582 return; 583 } 584 map.ttrpgEnabled = Boolean(msg?.enabled); 585 scheduleSaveSoon(mapId); 586 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 587 sendToRoom(mapId, { type: "plugin:maps:ttrpgEnabled", mapId, enabled: Boolean(map.ttrpgEnabled) }); 588 }); 589 590 api.registerWs("ttrpgSpriteAdd", (ws, msg) => { 591 const mapId = normId(msg?.mapId || ""); 592 const idx = customMapIndex(mapId); 593 if (idx < 0) { 594 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 595 return; 596 } 597 const map = customMaps[idx]; 598 if (!canManageMaps(ws, map)) { 599 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 600 return; 601 } 602 const url = typeof msg?.url === "string" ? msg.url.trim() : ""; 603 if (!url.startsWith("/uploads/") || !isSafeImageUrl(url)) { 604 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Invalid sprite image URL." })); 605 return; 606 } 607 const kind = msg?.kind === "token" ? "token" : "prop"; 608 const name = typeof msg?.name === "string" ? msg.name.trim().slice(0, 40) : ""; 609 const scale = clampFloat(msg?.scale, 0.1, 4.0, 1.0); 610 const id = typeof msg?.id === "string" && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,64}$/.test(msg.id) ? msg.id : randId("spr"); 611 const sprite = { id, kind, name, url, scale }; 612 if (!Array.isArray(map.sprites)) map.sprites = []; 613 map.sprites = normalizeSpriteList([...map.sprites, sprite]); 614 scheduleSaveSoon(mapId); 615 sendToRoom(mapId, { type: "plugin:maps:spriteAdded", mapId, sprite }); 616 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 617 }); 618 619 api.registerWs("ttrpgSpriteRemove", (ws, msg) => { 620 const mapId = normId(msg?.mapId || ""); 621 const idx = customMapIndex(mapId); 622 if (idx < 0) return; 623 const map = customMaps[idx]; 624 if (!canManageMaps(ws, map)) { 625 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 626 return; 627 } 628 const spriteId = typeof msg?.spriteId === "string" ? msg.spriteId.trim() : ""; 629 if (!spriteId) return; 630 map.sprites = (Array.isArray(map.sprites) ? map.sprites : []).filter((s) => String(s?.id || "") !== spriteId); 631 map.props = (Array.isArray(map.props) ? map.props : []).filter((p) => String(p?.spriteId || "") !== spriteId); 632 scheduleSaveSoon(mapId); 633 sendToRoom(mapId, { type: "plugin:maps:spriteRemoved", mapId, spriteId }); 634 sendToRoom(mapId, { type: "plugin:maps:propsReset", mapId, props: Array.isArray(map.props) ? map.props : [] }); 635 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 636 }); 637 638 api.registerWs("ttrpgPropAdd", (ws, msg) => { 639 const username = userIdentity(ws); 640 if (!username) return; 641 const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); 642 const idx = customMapIndex(mapId); 643 if (idx < 0) return; 644 const map = customMaps[idx]; 645 if (!map.ttrpgEnabled) return; 646 if (!canManageMaps(ws, map)) { 647 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 648 return; 649 } 650 const spriteId = typeof msg?.spriteId === "string" ? msg.spriteId.trim() : ""; 651 const spriteOk = (Array.isArray(map.sprites) ? map.sprites : []).some((s) => String(s?.id || "") === spriteId); 652 if (!spriteId || !spriteOk) return; 653 const x = clamp01(msg?.x); 654 const y = clamp01(msg?.y); 655 const z = clampInt(msg?.z || 0, -10_000, 10_000); 656 const rot = clampFloat(msg?.rot, -180, 180, 0); 657 const scale = clampFloat(msg?.scale, 0.1, 4.0, 1.0); 658 const sprite = spriteById(map, spriteId); 659 const nickname = typeof msg?.nickname === "string" ? msg.nickname.trim().slice(0, 40) : ""; 660 const hpMax = clampInt(msg?.hpMax || 10, 0, 9999); 661 const hpCurrent = clampInt(msg?.hpCurrent || hpMax, 0, hpMax > 0 ? hpMax : 9999); 662 const id = typeof msg?.id === "string" && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,64}$/.test(msg.id) ? msg.id : randId("prop"); 663 const prop = { 664 id, 665 spriteId, 666 x, 667 y, 668 z, 669 rot, 670 scale, 671 nickname: sprite?.kind === "token" ? nickname : "", 672 hpCurrent: sprite?.kind === "token" ? hpCurrent : 0, 673 hpMax: sprite?.kind === "token" ? hpMax : 0, 674 controlledBy: "" 675 }; 676 if (!Array.isArray(map.props)) map.props = []; 677 map.props = normalizePropList([...map.props, prop], new Set(map.sprites.map((s) => s.id))); 678 scheduleSaveSoon(mapId); 679 sendToRoom(mapId, { type: "plugin:maps:propAdded", mapId, prop }); 680 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 681 }); 682 683 api.registerWs("ttrpgPropMove", (ws, msg) => { 684 const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); 685 const idx = customMapIndex(mapId); 686 if (idx < 0) return; 687 const map = customMaps[idx]; 688 if (!map.ttrpgEnabled) return; 689 if (!canManageMaps(ws, map)) return; 690 const propId = typeof msg?.propId === "string" ? msg.propId.trim() : ""; 691 if (!propId) return; 692 const list = Array.isArray(map.props) ? map.props : []; 693 const pidx = list.findIndex((p) => String(p?.id || "") === propId); 694 if (pidx < 0) return; 695 const prev = list[pidx] || {}; 696 const x = clamp01(msg?.x); 697 const y = clamp01(msg?.y); 698 const z = Object.prototype.hasOwnProperty.call(msg || {}, "z") ? clampInt(msg?.z || 0, -10_000, 10_000) : prev.z || 0; 699 const rot = Object.prototype.hasOwnProperty.call(msg || {}, "rot") ? clampFloat(msg?.rot, -180, 180, 0) : prev.rot || 0; 700 const scale = Object.prototype.hasOwnProperty.call(msg || {}, "scale") ? clampFloat(msg?.scale, 0.1, 4.0, 1.0) : clampFloat(prev.scale, 0.1, 4.0, 1.0); 701 list[pidx] = { ...prev, x, y, z, rot, scale }; 702 map.props = list; 703 scheduleSaveSoon(mapId); 704 sendToRoom(mapId, { type: "plugin:maps:propMoved", mapId, propId, x, y, z, rot, scale }); 705 }); 706 707 api.registerWs("ttrpgPropPatch", (ws, msg) => { 708 const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); 709 const idx = customMapIndex(mapId); 710 if (idx < 0) return; 711 const map = customMaps[idx]; 712 if (!map.ttrpgEnabled) return; 713 if (!canManageMaps(ws, map)) return; 714 const propId = typeof msg?.propId === "string" ? msg.propId.trim() : ""; 715 const { prop: prev, index: pidx } = propById(map, propId); 716 if (!prev || pidx < 0) return; 717 const sprite = spriteById(map, String(prev.spriteId || "")); 718 const isToken = sprite?.kind === "token"; 719 const patch = {}; 720 if (Object.prototype.hasOwnProperty.call(msg || {}, "nickname")) { 721 patch.nickname = isToken ? String(msg?.nickname || "").trim().slice(0, 40) : ""; 722 } 723 if (Object.prototype.hasOwnProperty.call(msg || {}, "hpMax")) { 724 patch.hpMax = isToken ? clampInt(msg?.hpMax || 0, 0, 9999) : 0; 725 } 726 if (Object.prototype.hasOwnProperty.call(msg || {}, "hpCurrent")) { 727 const currentCap = Object.prototype.hasOwnProperty.call(patch, "hpMax") ? patch.hpMax : clampInt(prev.hpMax || 0, 0, 9999); 728 patch.hpCurrent = isToken ? clampInt(msg?.hpCurrent || 0, 0, currentCap > 0 ? currentCap : 9999) : 0; 729 } 730 if (!Object.keys(patch).length) return; 731 const next = { ...prev, ...patch }; 732 map.props[pidx] = next; 733 scheduleSaveSoon(mapId); 734 sendToRoom(mapId, { type: "plugin:maps:propPatched", mapId, prop: next }); 735 }); 736 737 api.registerWs("ttrpgTokenPossess", (ws, msg) => { 738 const username = userIdentity(ws); 739 if (!username) return; 740 const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); 741 const idx = customMapIndex(mapId); 742 if (idx < 0) return; 743 const map = customMaps[idx]; 744 if (!map.ttrpgEnabled) return; 745 if (!canManageMaps(ws, map)) return; 746 const action = msg?.action === "release" ? "release" : "possess"; 747 const props = Array.isArray(map.props) ? map.props : []; 748 749 // Release always clears *all* tokens controlled by this user (prevents "stuck" control). 750 if (action === "release") { 751 let changed = false; 752 for (let i = 0; i < props.length; i++) { 753 const p = props[i]; 754 if (!p) continue; 755 if (String(p.controlledBy || "") !== username) continue; 756 const spr = spriteById(map, String(p.spriteId || "")); 757 if (!spr || spr.kind !== "token") continue; 758 props[i] = { ...p, controlledBy: "" }; 759 changed = true; 760 sendToRoom(mapId, { type: "plugin:maps:propPatched", mapId, prop: props[i] }); 761 } 762 ws.__mapsSpeakAsPropId = ""; 763 if (changed) { 764 map.props = props; 765 scheduleSaveSoon(mapId); 766 } 767 return; 768 } 769 770 const propId = typeof msg?.propId === "string" ? msg.propId.trim() : ""; 771 const { prop: prev, index: pidx } = propById(map, propId); 772 if (!prev || pidx < 0) return; 773 const sprite = spriteById(map, String(prev.spriteId || "")); 774 if (!sprite || sprite.kind !== "token") return; 775 if (prev.controlledBy && prev.controlledBy !== username) return; 776 777 // Possession is exclusive per user: release any other controlled tokens first. 778 for (let i = 0; i < props.length; i++) { 779 const p = props[i]; 780 if (!p) continue; 781 if (String(p.id || "") === propId) continue; 782 if (String(p.controlledBy || "") !== username) continue; 783 const spr = spriteById(map, String(p.spriteId || "")); 784 if (!spr || spr.kind !== "token") continue; 785 props[i] = { ...p, controlledBy: "" }; 786 sendToRoom(mapId, { type: "plugin:maps:propPatched", mapId, prop: props[i] }); 787 } 788 789 const next = { ...prev, controlledBy: username }; 790 props[pidx] = next; 791 map.props = props; 792 ws.__mapsSpeakAsPropId = propId; 793 scheduleSaveSoon(mapId); 794 sendToRoom(mapId, { type: "plugin:maps:propPatched", mapId, prop: next }); 795 }); 796 797 api.registerWs("ttrpgPropRemove", (ws, msg) => { 798 const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); 799 const idx = customMapIndex(mapId); 800 if (idx < 0) return; 801 const map = customMaps[idx]; 802 if (!map.ttrpgEnabled) return; 803 if (!canManageMaps(ws, map)) return; 804 const propId = typeof msg?.propId === "string" ? msg.propId.trim() : ""; 805 if (!propId) return; 806 map.props = (Array.isArray(map.props) ? map.props : []).filter((p) => String(p?.id || "") !== propId); 807 scheduleSaveSoon(mapId); 808 sendToRoom(mapId, { type: "plugin:maps:propRemoved", mapId, propId }); 809 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 810 }); 811 812 api.registerWs("deleteMap", (ws, msg) => { 813 const mapId = normId(msg?.id || ""); 814 const map = mapById(mapId); 815 if (!map || BUILTIN_MAPS.some((m) => m.id === mapId)) { 816 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 817 return; 818 } 819 if (!canManageMaps(ws, map)) { 820 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 821 return; 822 } 823 customMaps = customMaps.filter((m) => m.id !== mapId); 824 try { 825 saveCustomMapsToDisk(); 826 } catch (e) { 827 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Failed to delete map." })); 828 return; 829 } 830 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 831 }); 832 833 api.registerWs("join", (ws, msg) => { 834 const username = userIdentity(ws); 835 if (!username) { 836 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Sign in required." })); 837 return; 838 } 839 const mapId = normId(msg?.mapId || ""); 840 const map = mapById(mapId); 841 if (!map) { 842 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 843 return; 844 } 845 846 leaveAnyRoom(ws); 847 848 const room = roomFor(mapId); 849 if (!room) return; 850 851 const prof = api.getProfile(username) || {}; 852 const color = typeof prof.color === "string" ? prof.color : ""; 853 const image = typeof prof.image === "string" ? prof.image : ""; 854 room.users.set(username, { x: Math.random(), y: Math.random(), color, image, invisible: false, seq: 0 }); 855 ws.__mapsRoomId = mapId; 856 ws.__mapsInvisible = 0; 857 858 ws.send( 859 JSON.stringify({ 860 type: "plugin:maps:joinOk", 861 map: { 862 id: map.id, 863 title: map.title, 864 owner: map.owner || "", 865 backgroundUrl: map.backgroundUrl, 866 world: map.world || null, 867 avatarSize: clampInt(map.avatarSize || 36, 18, 96), 868 cameraZoom: clampFloat(map.cameraZoom, 0.8, 5.0, 2.35), 869 collisions: Array.isArray(map.collisions) ? map.collisions : [], 870 masks: Array.isArray(map.masks) ? map.masks : [], 871 exits: Array.isArray(map.exits) ? map.exits : [], 872 hiddenMasks: Array.isArray(map.hiddenMasks) ? map.hiddenMasks : [], 873 occluders: Array.isArray(map.occluders) ? map.occluders : [], 874 ttrpgEnabled: Boolean(map.ttrpgEnabled), 875 sprites: Array.isArray(map.sprites) ? map.sprites : [], 876 props: Array.isArray(map.props) ? map.props : [], 877 walkiesEnabled: Boolean(map.walkiesEnabled) 878 }, 879 selfInvisible: false 880 }) 881 ); 882 broadcastRoomState(mapId); 883 }); 884 885 api.registerWs("leave", (ws) => { 886 leaveAnyRoom(ws); 887 ws.send(JSON.stringify({ type: "plugin:maps:left" })); 888 }); 889 890 api.registerWs("move", (ws, msg) => { 891 const username = userIdentity(ws); 892 if (!username) return; 893 const mapId = normId(ws.__mapsRoomId || ""); 894 if (!mapId) return; 895 const room = rooms.get(mapId); 896 if (!room) return; 897 const u = room.users.get(username); 898 if (!u) return; 899 900 const t = api.now(); 901 const last = Number(ws.__mapsLastMoveAt || 0) || 0; 902 if (t - last < 50) return; // ~20Hz 903 ws.__mapsLastMoveAt = t; 904 905 const x = clamp01(msg?.x); 906 const y = clamp01(msg?.y); 907 const seq = clampSeq(msg?.seq); 908 const prevSeq = clampSeq(u?.seq || 0); 909 if (seq && seq < prevSeq) return; 910 const next = { ...u, x, y, seq: seq || prevSeq }; 911 room.users.set(username, next); 912 913 const payload = { type: "plugin:maps:userMoved", mapId, username, x, y, seq: seq || prevSeq }; 914 if (next.invisible) { 915 api.sendToUsers([username], payload); 916 } else { 917 api.sendToUsers(usersInRoom(mapId), payload); 918 } 919 }); 920 921 api.registerWs("say", (ws, msg) => { 922 const username = userIdentity(ws); 923 if (!username) return; 924 const mapId = normId(ws.__mapsRoomId || ""); 925 if (!mapId) return; 926 const map = mapById(mapId); 927 if (!map) return; 928 const room = rooms.get(mapId); 929 if (!room) return; 930 const u = room.users.get(username); 931 if (!u) return; 932 933 const text = typeof msg?.text === "string" ? msg.text.replace(/\s+/g, " ").trim().slice(0, 120) : ""; 934 if (!text) return; 935 let actorType = "user"; 936 let actorPropId = ""; 937 let displayName = `@${username}`; 938 const color = typeof u.color === "string" ? u.color : ""; 939 const requestedPropId = typeof msg?.actorPropId === "string" ? msg.actorPropId.trim() : ""; 940 if (requestedPropId && map.ttrpgEnabled && canManageMaps(ws, map)) { 941 const { prop } = propById(map, requestedPropId); 942 const sprite = prop ? spriteById(map, String(prop.spriteId || "")) : null; 943 if (prop && sprite && sprite.kind === "token") { 944 if (!prop.controlledBy || prop.controlledBy === username) { 945 actorType = "token"; 946 actorPropId = requestedPropId; 947 ws.__mapsSpeakAsPropId = requestedPropId; 948 displayName = String(prop.nickname || sprite.name || sprite.id || "token").slice(0, 40); 949 } 950 } 951 } 952 const payload = { type: "plugin:maps:bubble", mapId, username, actorType, actorPropId, displayName, color, text, createdAt: api.now() }; 953 if (u.invisible) { 954 api.sendToUsers([username], payload); 955 } else { 956 api.sendToUsers(usersInRoom(mapId), payload); 957 } 958 }); 959 960 api.registerWs("setInvisible", (ws, msg) => { 961 const username = userIdentity(ws); 962 if (!username) return; 963 const mapId = normId(msg?.mapId || ws.__mapsRoomId || ""); 964 if (!mapId) return; 965 const map = mapById(mapId); 966 if (!map) { 967 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 968 return; 969 } 970 if (!canManageMaps(ws, map)) { 971 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 972 return; 973 } 974 const room = rooms.get(mapId); 975 if (!room) return; 976 const u = room.users.get(username); 977 if (!u) return; 978 const invisible = Boolean(msg?.invisible); 979 room.users.set(username, { ...u, invisible }); 980 ws.__mapsInvisible = invisible ? 1 : 0; 981 ws.send(JSON.stringify({ type: "plugin:maps:selfInvisible", mapId, invisible })); 982 broadcastRoomState(mapId); 983 }); 984 985 api.registerWs("walkieSend", (ws, msg) => { 986 const username = userIdentity(ws); 987 if (!username) return; 988 const mapId = normId(ws.__mapsRoomId || ""); 989 if (!mapId) return; 990 const map = mapById(mapId); 991 if (!map) return; 992 if (!map.walkiesEnabled) { 993 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Walkies are disabled for this map." })); 994 return; 995 } 996 const room = roomFor(mapId); 997 if (!room) return; 998 if (!room.users.has(username)) return; 999 1000 const idRaw = typeof msg?.id === "string" ? msg.id.trim() : ""; 1001 const id = idRaw && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,80}$/.test(idRaw) ? idRaw : `${api.now()}_${Math.random().toString(16).slice(2)}`; 1002 const url = typeof msg?.url === "string" ? msg.url.trim() : ""; 1003 if (!isSafeUploadUrl(url)) return; 1004 const x = clamp01(msg?.x); 1005 const y = clamp01(msg?.y); 1006 1007 const createdAt = api.now(); 1008 const pending = new Set(usersInRoom(mapId)); 1009 if (!room.walkies) room.walkies = new Map(); 1010 room.walkies.set(id, { url, pending, createdAt, mapId }); 1011 1012 api.sendToUsers(usersInRoom(mapId), { type: "plugin:maps:walkie", mapId, id, username, url, x, y, createdAt }); 1013 1014 // Hard timeout to ensure cleanup even if clients never ack. 1015 setTimeout(() => { 1016 const r = rooms.get(mapId); 1017 const entry = r?.walkies?.get(id); 1018 if (!entry) return; 1019 r.walkies.delete(id); 1020 tryDeleteUploadSoon(url, createdAt); 1021 }, 2 * 60 * 1000); 1022 }); 1023 1024 api.registerWs("walkiePlayed", (ws, msg) => { 1025 const username = userIdentity(ws); 1026 if (!username) return; 1027 const mapId = normId(ws.__mapsRoomId || ""); 1028 if (!mapId) return; 1029 const room = rooms.get(mapId); 1030 if (!room || !room.walkies) return; 1031 const id = typeof msg?.id === "string" ? msg.id.trim() : ""; 1032 if (!id) return; 1033 const entry = room.walkies.get(id); 1034 if (!entry) return; 1035 entry.pending.delete(username); 1036 if (entry.pending.size === 0) { 1037 room.walkies.delete(id); 1038 tryDeleteUploadSoon(entry.url, entry.createdAt); 1039 } 1040 }); 1041 };