server.js (70287B)
1 const fs = require("fs"); 2 const path = require("path"); 3 4 module.exports = function init(api) { 5 const MAP_CHAT_GLOBAL_MAX = 200; 6 const MAP_CHAT_LOCAL_RADIUS = Number.isFinite(Number(process.env.MAP_CHAT_LOCAL_RADIUS)) 7 ? Math.max(0.01, Math.min(1.0, Number(process.env.MAP_CHAT_LOCAL_RADIUS))) 8 : 0.12; // positions are normalized 0..1 9 10 const BUILTIN_MAPS = [ 11 { 12 id: "studio", 13 title: "Studio (demo)", 14 owner: "", 15 // Placeholder image; replace with your own PNG in a real plugin build. 16 backgroundUrl: "/assets/logobzl.png", 17 thumbUrl: "/assets/logobzl.png", 18 world: { w: 1400, h: 900 }, 19 avatarSize: 36, 20 cameraZoom: 2.35, 21 collisions: [], 22 masks: [], 23 exits: [], 24 hiddenMasks: [], 25 occluders: [], 26 ttrpgEnabled: false, 27 sprites: [], 28 props: [], 29 walkiesEnabled: false 30 } 31 ]; 32 33 const DATA_DIR = path.join(process.cwd(), "data", "plugin-data"); 34 const MAPS_FILE = path.join(DATA_DIR, "maps.json"); 35 const AVATAR_PREFS_FILE = path.join(DATA_DIR, "maps-avatar-prefs.json"); 36 37 /** @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}>} */ 38 let customMaps = []; 39 /** @type {Array<{id:string,name:string,description:string,tags:string[],mode:string,avatar:any,createdBy:string,updatedBy:string,createdAt:number,updatedAt:number,published:boolean}>} */ 40 let avatarPresets = []; 41 /** @type {Map<string, {mode:"profile_token",displayName:string,showUsername:boolean}>} */ 42 let avatarPrefsByUser = new Map(); 43 44 /** @type {Map<string, {users: Map<string, {x:number,y:number,color:string,image:string,invisible?:boolean,seq?:number}>, lastListAt:number, lastActiveAt:number, typing?: Map<string, number>, walkies?: Map<string, {url:string, pending:Set<string>, createdAt:number, mapId:string, timeout?:NodeJS.Timeout}>, chatGlobal?: Array<{id:string,fromUser:string,text:string,createdAt:number}>}>} */ 45 const rooms = new Map(); 46 const avatarSnapshotNeededByUser = new Set(); 47 48 function normId(raw) { 49 const s = typeof raw === "string" ? raw.trim().toLowerCase() : ""; 50 if (!s) return ""; 51 if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(s)) return ""; 52 return s; 53 } 54 55 function clampInt(n, min, max) { 56 const x = Math.floor(Number(n)); 57 if (!Number.isFinite(x)) return min; 58 return Math.max(min, Math.min(max, x)); 59 } 60 61 function isSafeImageUrl(url) { 62 const u = typeof url === "string" ? url.trim() : ""; 63 if (!u) return false; 64 if (u.startsWith("/uploads/")) return true; 65 if (u.startsWith("/assets/")) return true; 66 return false; 67 } 68 69 function isSafeUploadUrl(url) { 70 const u = typeof url === "string" ? url.trim() : ""; 71 if (!u.startsWith("/uploads/")) return false; 72 if (!/^\/uploads\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,220}$/.test(u)) return false; 73 return true; 74 } 75 76 function uploadsDir() { 77 return process.env.UPLOADS_DIR || path.join(process.cwd(), "data", "uploads"); 78 } 79 80 function tryDeleteUploadSoon(url, createdAt) { 81 if (!isSafeUploadUrl(url)) return false; 82 const filename = url.replace("/uploads/", ""); 83 const filePath = path.resolve(path.join(uploadsDir(), filename)); 84 const root = path.resolve(uploadsDir()) + path.sep; 85 if (!filePath.startsWith(root)) return false; 86 const now = api.now(); 87 // Only delete "fresh" uploads to avoid nuking older content. 88 if (now - Number(createdAt || 0) > 10 * 60 * 1000) return false; 89 try { 90 const st = fs.statSync(filePath); 91 if (!st.isFile()) return false; 92 if (now - st.mtimeMs > 10 * 60 * 1000) return false; 93 fs.unlinkSync(filePath); 94 return true; 95 } catch { 96 return false; 97 } 98 } 99 100 const walkieTelemetry = { 101 counters: new Map(), 102 lastFlushAt: 0 103 }; 104 105 function walkieMetricKey(stage, mapId) { 106 return `${String(stage || "unknown")}:${String(mapId || "_")}`; 107 } 108 109 function noteWalkie(stage, mapId, extra) { 110 const key = walkieMetricKey(stage, mapId); 111 walkieTelemetry.counters.set(key, Number(walkieTelemetry.counters.get(key) || 0) + 1); 112 const now = api.now(); 113 if (now - walkieTelemetry.lastFlushAt < 60_000) return; 114 walkieTelemetry.lastFlushAt = now; 115 const snapshot = {}; 116 for (const [k, v] of walkieTelemetry.counters.entries()) snapshot[k] = v; 117 if (extra && typeof extra === "object") { 118 console.info("[maps/walkie]", stage, { mapId, ...extra, counters: snapshot }); 119 return; 120 } 121 console.info("[maps/walkie]", stage, { mapId, counters: snapshot }); 122 } 123 124 function dropWalkiePendingForUser(room, username, reason) { 125 if (!room || !room.walkies || !username) return; 126 for (const [walkieId, entry] of room.walkies.entries()) { 127 if (!entry?.pending || !entry.pending.has(username)) continue; 128 entry.pending.delete(username); 129 noteWalkie("pending-drop", entry.mapId || "", { walkieId, reason }); 130 if (entry.pending.size === 0) { 131 cleanupWalkieEntry(room, walkieId, "cleanup-all-acked", { reason }); 132 } 133 } 134 } 135 136 function clearRoomWalkies(room, reason) { 137 if (!room || !room.walkies) return; 138 for (const walkieId of room.walkies.keys()) cleanupWalkieEntry(room, walkieId, "cleanup-room-clear", { reason }); 139 } 140 141 function cleanupWalkieEntry(room, walkieId, stage, extra) { 142 if (!room?.walkies) return; 143 const entry = room.walkies.get(walkieId); 144 if (!entry) return; 145 if (entry.timeout) { 146 try { 147 clearTimeout(entry.timeout); 148 } catch { 149 // ignore 150 } 151 } 152 room.walkies.delete(walkieId); 153 tryDeleteUploadSoon(entry.url, entry.createdAt); 154 noteWalkie(stage || "cleanup", entry.mapId || "", { walkieId, ...(extra || {}) }); 155 } 156 157 158 function normalizePolyList(list) { 159 const input = Array.isArray(list) ? list : []; 160 const out = []; 161 const maxPolys = 80; 162 const maxPoints = 60; 163 for (const raw of input.slice(0, maxPolys)) { 164 const points = Array.isArray(raw?.points) ? raw.points : []; 165 if (points.length < 3) continue; 166 const normPoints = []; 167 for (const p of points.slice(0, maxPoints)) { 168 const x = Number(p?.x); 169 const y = Number(p?.y); 170 if (!Number.isFinite(x) || !Number.isFinite(y)) continue; 171 normPoints.push({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }); 172 } 173 if (normPoints.length < 3) continue; 174 out.push({ points: normPoints }); 175 } 176 return out; 177 } 178 179 function normalizeFogList(list) { 180 const input = Array.isArray(list) ? list : []; 181 const out = []; 182 const maxPolys = 80; 183 const maxPoints = 60; 184 for (const raw of input.slice(0, maxPolys)) { 185 const points = Array.isArray(raw?.points) ? raw.points : []; 186 if (points.length < 3) continue; 187 const normPoints = []; 188 for (const p of points.slice(0, maxPoints)) { 189 const x = Number(p?.x); 190 const y = Number(p?.y); 191 if (!Number.isFinite(x) || !Number.isFinite(y)) continue; 192 normPoints.push({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }); 193 } 194 if (normPoints.length < 3) continue; 195 const modeRaw = 196 typeof raw?.mode === "string" 197 ? raw.mode.trim().toLowerCase() 198 : typeof raw?.reveal === "string" 199 ? raw.reveal.trim().toLowerCase() 200 : ""; 201 const mode = modeRaw === "manual" ? "manual" : "auto"; 202 const name = typeof raw?.name === "string" ? raw.name.trim().slice(0, 40) : ""; 203 out.push({ points: normPoints, mode, name }); 204 } 205 return out; 206 } 207 208 function normalizeFallList(list) { 209 const input = Array.isArray(list) ? list : []; 210 const out = []; 211 const maxPolys = 60; 212 const maxPoints = 60; 213 for (const raw of input.slice(0, maxPolys)) { 214 const points = Array.isArray(raw?.points) ? raw.points : []; 215 if (points.length < 3) continue; 216 const normPoints = []; 217 for (const p of points.slice(0, maxPoints)) { 218 const x = Number(p?.x); 219 const y = Number(p?.y); 220 if (!Number.isFinite(x) || !Number.isFinite(y)) continue; 221 normPoints.push({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }); 222 } 223 if (normPoints.length < 3) continue; 224 const dirRaw = typeof raw?.direction === "string" ? raw.direction.trim().toLowerCase() : ""; 225 const direction = dirRaw === "up" || dirRaw === "left" || dirRaw === "right" ? dirRaw : "down"; 226 const offset = clampFloat(raw?.offset, 0.002, 0.08, 0.02); 227 const name = typeof raw?.name === "string" ? raw.name.trim().slice(0, 40) : ""; 228 out.push({ points: normPoints, direction, offset, name }); 229 } 230 return out; 231 } 232 233 function normalizeExitList(list) { 234 const input = Array.isArray(list) ? list : []; 235 const out = []; 236 const maxExits = 40; 237 const maxPoints = 60; 238 for (const raw of input.slice(0, maxExits)) { 239 const points = Array.isArray(raw?.points) ? raw.points : []; 240 if (points.length < 3) continue; 241 const normPoints = []; 242 for (const p of points.slice(0, maxPoints)) { 243 const x = Number(p?.x); 244 const y = Number(p?.y); 245 if (!Number.isFinite(x) || !Number.isFinite(y)) continue; 246 normPoints.push({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }); 247 } 248 if (normPoints.length < 3) continue; 249 const name = typeof raw?.name === "string" ? raw.name.trim().slice(0, 40) : ""; 250 const actionRaw = typeof raw?.action === "string" ? raw.action.trim() : ""; 251 const action = actionRaw === "toMap" ? "toMap" : "toMaps"; 252 const toMapId = action === "toMap" ? normId(raw?.toMapId || "") : ""; 253 if (action === "toMap" && !toMapId) continue; 254 const targetExit = action === "toMap" && typeof raw?.targetExit === "string" ? raw.targetExit.trim().slice(0, 40) : ""; 255 out.push({ points: normPoints, name, action, toMapId, targetExit }); 256 } 257 return out; 258 } 259 260 function normalizeSpriteList(list) { 261 const input = Array.isArray(list) ? list : []; 262 const out = []; 263 const max = 120; 264 for (const raw of input.slice(0, max)) { 265 const id = typeof raw?.id === "string" ? raw.id.trim() : ""; 266 const safeId = id && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,64}$/.test(id) ? id : randId("spr"); 267 const kind = raw?.kind === "token" ? "token" : "prop"; 268 const name = typeof raw?.name === "string" ? raw.name.trim().slice(0, 40) : ""; 269 const url = typeof raw?.url === "string" ? raw.url.trim() : ""; 270 if (!url.startsWith("/uploads/")) continue; 271 if (!isSafeImageUrl(url)) continue; 272 const scale = clampFloat(raw?.scale, 0.1, 4.0, 1.0); 273 out.push({ id: safeId, kind, name, url, scale }); 274 } 275 return out; 276 } 277 278 function normalizePropList(list, allowedSpriteIds = null) { 279 const input = Array.isArray(list) ? list : []; 280 const out = []; 281 const max = 800; 282 for (const raw of input.slice(0, max)) { 283 const id = typeof raw?.id === "string" ? raw.id.trim() : ""; 284 const safeId = id && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,64}$/.test(id) ? id : randId("prop"); 285 const spriteId = typeof raw?.spriteId === "string" ? raw.spriteId.trim() : ""; 286 if (!spriteId) continue; 287 if (allowedSpriteIds && !allowedSpriteIds.has(spriteId)) continue; 288 const x = clamp01(raw?.x); 289 const y = clamp01(raw?.y); 290 const z = clampInt(raw?.z || 0, -10_000, 10_000); 291 const rot = clampFloat(raw?.rot, -180, 180, 0); 292 const scale = clampFloat(raw?.scale, 0.1, 4.0, 1.0); 293 const nickname = typeof raw?.nickname === "string" ? raw.nickname.trim().slice(0, 40) : ""; 294 const hpMax = clampInt(raw?.hpMax || 10, 0, 9999); 295 const hpCurrent = clampInt(raw?.hpCurrent || hpMax, 0, hpMax > 0 ? hpMax : 9999); 296 const controlledBy = typeof raw?.controlledBy === "string" ? normId(raw.controlledBy) : ""; 297 out.push({ id: safeId, spriteId, x, y, z, rot, scale, nickname, hpCurrent, hpMax, controlledBy }); 298 } 299 return out; 300 } 301 302 function canManageMaps(ws, map) { 303 const role = String(ws?.user?.role || "").toLowerCase(); 304 const username = userIdentity(ws); 305 if (role === "owner" || role === "admin" || role === "moderator") return true; 306 if (map && username && map.owner && username === map.owner) return true; 307 return false; 308 } 309 310 function canManageAvatarPresets(ws) { 311 const role = String(ws?.user?.role || "").toLowerCase(); 312 return role === "owner" || role === "admin" || role === "moderator"; 313 } 314 315 function normalizePresetName(value) { 316 return String(value || "").replace(/\s+/g, " ").trim().slice(0, 40); 317 } 318 319 function normalizePresetDescription(value) { 320 return String(value || "").replace(/\s+/g, " ").trim().slice(0, 140); 321 } 322 323 function normalizePresetTags(list) { 324 const src = Array.isArray(list) ? list : []; 325 const out = []; 326 const seen = new Set(); 327 for (const raw of src.slice(0, 12)) { 328 const tag = String(raw || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "").slice(0, 24); 329 if (!tag || seen.has(tag)) continue; 330 seen.add(tag); 331 out.push(tag); 332 } 333 return out; 334 } 335 336 function clamp01(n) { 337 const x = Number(n); 338 if (!Number.isFinite(x)) return 0; 339 return Math.max(0, Math.min(1, x)); 340 } 341 342 function clampSeq(n) { 343 const x = Math.floor(Number(n)); 344 if (!Number.isFinite(x) || x < 0) return 0; 345 return Math.min(1_000_000_000, x); 346 } 347 348 function clampFloat(n, min, max, fallback = min) { 349 const x = Number(n); 350 if (!Number.isFinite(x)) return fallback; 351 return Math.max(min, Math.min(max, x)); 352 } 353 354 function randId(prefix = "id") { 355 return `${prefix}_${api.now()}_${Math.random().toString(16).slice(2)}`; 356 } 357 358 const saveTimersByMapId = new Map(); 359 function scheduleSaveSoon(mapId) { 360 const mid = normId(mapId); 361 if (!mid) return; 362 const existing = saveTimersByMapId.get(mid); 363 if (existing) clearTimeout(existing); 364 saveTimersByMapId.set( 365 mid, 366 setTimeout(() => { 367 saveTimersByMapId.delete(mid); 368 try { 369 saveCustomMapsToDisk(); 370 } catch (e) { 371 console.warn("Maps plugin: failed to persist maps:", e?.message || e); 372 } 373 }, 500) 374 ); 375 } 376 377 function mapById(id) { 378 const mid = normId(id); 379 if (!mid) return null; 380 return BUILTIN_MAPS.find((m) => m.id === mid) || customMaps.find((m) => m.id === mid) || null; 381 } 382 383 function spriteById(map, spriteId) { 384 const sid = typeof spriteId === "string" ? spriteId.trim() : ""; 385 if (!sid) return null; 386 const sprites = Array.isArray(map?.sprites) ? map.sprites : []; 387 return sprites.find((s) => String(s?.id || "") === sid) || null; 388 } 389 390 function propById(map, propId) { 391 const pid = typeof propId === "string" ? propId.trim() : ""; 392 if (!pid) return { prop: null, index: -1 }; 393 const props = Array.isArray(map?.props) ? map.props : []; 394 const index = props.findIndex((p) => String(p?.id || "") === pid); 395 return { prop: index >= 0 ? props[index] : null, index }; 396 } 397 398 function roomFor(mapId) { 399 const mid = normId(mapId); 400 if (!mid) return null; 401 if (!rooms.has(mid)) rooms.set(mid, { users: new Map(), lastListAt: 0, lastActiveAt: 0, typing: new Map(), walkies: new Map(), chatGlobal: [] }); 402 return rooms.get(mid) || null; 403 } 404 405 function touchRoomActivity(mapId) { 406 const room = roomFor(mapId); 407 if (!room) return; 408 room.lastActiveAt = api.now(); 409 broadcastMapsListThrottled(); 410 } 411 412 function mapsCapabilities(ws = null) { 413 return { 414 type: "plugin:maps:capabilities", 415 version: "0.4.0", 416 emittedAt: api.now(), 417 mapId: normId(ws?.__mapsRoomId || ""), 418 features: { 419 focusMode: true, 420 gmOverlay: true, 421 avatarModes: ["profile_token", "frame_animation"], 422 avatarPresets: true, 423 walkieV2: true, 424 spatialStreamAudio: false, 425 undoRedo: false 426 } 427 }; 428 } 429 430 function sanitizeDisplayName(name) { 431 const raw = typeof name === "string" ? name : ""; 432 return raw.replace(/\s+/g, " ").trim().slice(0, 32); 433 } 434 435 function sanitizeFrameStateName(name) { 436 const raw = typeof name === "string" ? name.trim() : ""; 437 if (!raw) return ""; 438 if (!/^[a-z][a-z0-9_]{0,31}$/i.test(raw)) return ""; 439 return raw; 440 } 441 442 function sanitizeHotkeyName(name) { 443 const raw = typeof name === "string" ? name.trim() : ""; 444 if (!raw) return ""; 445 if (!/^(Digit[0-9]|Key[A-Z])$/.test(raw)) return ""; 446 return raw; 447 } 448 449 function normalizeFrameAnimation(raw) { 450 const input = raw && typeof raw === "object" ? raw : {}; 451 const defaultFps = clampInt(input.defaultFps, 1, 24); 452 const renderScale = clampFloat(input.renderScale, 0.25, 4.0, 1.0); 453 const statesIn = input.states && typeof input.states === "object" ? input.states : {}; 454 const states = {}; 455 let totalFrames = 0; 456 const MAX_STATES = 24; 457 const MAX_FRAMES_PER_STATE = 48; 458 const MAX_TOTAL_FRAMES = 220; 459 for (const [stateRaw, defRaw] of Object.entries(statesIn).slice(0, MAX_STATES)) { 460 const state = sanitizeFrameStateName(stateRaw); 461 if (!state) continue; 462 const def = defRaw && typeof defRaw === "object" ? defRaw : {}; 463 const framesIn = Array.isArray(def.frames) ? def.frames : []; 464 const frames = []; 465 for (const frameRaw of framesIn.slice(0, MAX_FRAMES_PER_STATE)) { 466 const frameUrl = typeof frameRaw?.url === "string" ? frameRaw.url.trim() : ""; 467 if (!frameUrl || frameUrl.length > 240) continue; 468 if (!isSafeImageUrl(frameUrl)) continue; 469 const sx = clampInt(frameRaw?.sx, 0, 8192); 470 const sy = clampInt(frameRaw?.sy, 0, 8192); 471 const sw = clampInt(frameRaw?.sw, 1, 8192); 472 const sh = clampInt(frameRaw?.sh, 1, 8192); 473 const hasCrop = 474 Number.isFinite(Number(frameRaw?.sx)) && 475 Number.isFinite(Number(frameRaw?.sy)) && 476 Number.isFinite(Number(frameRaw?.sw)) && 477 Number.isFinite(Number(frameRaw?.sh)); 478 frames.push(hasCrop ? { url: frameUrl, sx, sy, sw, sh } : { url: frameUrl }); 479 totalFrames += 1; 480 if (totalFrames >= MAX_TOTAL_FRAMES) break; 481 } 482 if (!frames.length) continue; 483 states[state] = { 484 frames, 485 fps: clampInt(def.fps, 1, 24), 486 loop: Object.prototype.hasOwnProperty.call(def, "loop") ? Boolean(def.loop) : true, 487 flipXWithDirection: Object.prototype.hasOwnProperty.call(def, "flipXWithDirection") ? Boolean(def.flipXWithDirection) : true 488 }; 489 if (totalFrames >= MAX_TOTAL_FRAMES) break; 490 } 491 const movementMapIn = input.movementMap && typeof input.movementMap === "object" ? input.movementMap : {}; 492 const movementMap = {}; 493 const moveKeys = ["idle", "idleUp", "idleDown", "walkVertical", "walkHorizontal", "walkUp", "walkDown", "walkLeft", "walkRight"]; 494 for (const key of moveKeys) { 495 const state = sanitizeFrameStateName(movementMapIn[key]); 496 if (state && states[state]) movementMap[key] = state; 497 } 498 const emotesIn = Array.isArray(input.emotes) ? input.emotes : []; 499 const emotes = []; 500 for (const emoteRaw of emotesIn.slice(0, 16)) { 501 const emote = emoteRaw && typeof emoteRaw === "object" ? emoteRaw : {}; 502 const name = sanitizeFrameStateName(emote.name); 503 const state = sanitizeFrameStateName(emote.state); 504 if (!name || !state || !states[state]) continue; 505 emotes.push({ 506 name, 507 state, 508 hotkey: sanitizeHotkeyName(emote.hotkey), 509 loop: Object.prototype.hasOwnProperty.call(emote, "loop") ? Boolean(emote.loop) : false, 510 interruptible: Object.prototype.hasOwnProperty.call(emote, "interruptible") ? Boolean(emote.interruptible) : true 511 }); 512 } 513 if (!Object.keys(states).length) return null; 514 return { defaultFps, renderScale, states, movementMap, emotes }; 515 } 516 517 function estimateEmoteDurationMs(frameAnimation, emoteState) { 518 const anim = frameAnimation && typeof frameAnimation === "object" ? frameAnimation : null; 519 if (!anim) return 1000; 520 const states = anim.states && typeof anim.states === "object" ? anim.states : {}; 521 const state = states[emoteState] && typeof states[emoteState] === "object" ? states[emoteState] : null; 522 if (!state) return 1000; 523 if (state.loop) return 1200; 524 const frames = Array.isArray(state.frames) ? state.frames.length : 0; 525 const fps = clampInt(state.fps || anim.defaultFps || 8, 1, 24); 526 const raw = Math.round((Math.max(1, frames) / Math.max(1, fps)) * 1000); 527 return Math.max(320, Math.min(4000, raw)); 528 } 529 530 function resolveAvatarEmote(pref, msg) { 531 if (!pref || pref.mode !== "frame_animation" || !pref.frameAnimation) return null; 532 const anim = pref.frameAnimation; 533 const emotes = Array.isArray(anim.emotes) ? anim.emotes : []; 534 if (!emotes.length) return null; 535 const nameRaw = typeof msg?.name === "string" ? msg.name.trim().toLowerCase() : ""; 536 const idxRaw = Number(msg?.index); 537 let emote = null; 538 if (nameRaw) { 539 emote = emotes.find((e) => String(e?.name || "").toLowerCase() === nameRaw) || null; 540 } else if (Number.isFinite(idxRaw) && idxRaw >= 0 && idxRaw < emotes.length) { 541 emote = emotes[Math.floor(idxRaw)] || null; 542 } 543 if (!emote) return null; 544 const state = sanitizeFrameStateName(emote.state); 545 if (!state) return null; 546 const durationMs = estimateEmoteDurationMs(anim, state); 547 return { name: emote.name, state, loop: Boolean(emote.loop), durationMs }; 548 } 549 550 function normalizeAvatarPref(raw) { 551 const rawMode = String(raw?.mode || "profile_token").trim(); 552 const mode = rawMode === "frame_animation" ? "frame_animation" : "profile_token"; 553 const displayName = sanitizeDisplayName(raw?.displayName); 554 const showUsername = raw && Object.prototype.hasOwnProperty.call(raw, "showUsername") ? Boolean(raw.showUsername) : true; 555 const frameAnimation = mode === "frame_animation" ? normalizeFrameAnimation(raw?.frameAnimation) : null; 556 return { 557 mode: frameAnimation ? "frame_animation" : "profile_token", 558 displayName, 559 showUsername, 560 frameAnimation: frameAnimation || null 561 }; 562 } 563 564 function loadAvatarPrefsFromDisk() { 565 try { 566 fs.mkdirSync(DATA_DIR, { recursive: true }); 567 if (!fs.existsSync(AVATAR_PREFS_FILE)) { 568 avatarPrefsByUser = new Map(); 569 return; 570 } 571 const raw = fs.readFileSync(AVATAR_PREFS_FILE, "utf8"); 572 const json = JSON.parse(raw); 573 const users = json && typeof json === "object" ? json.users : null; 574 const next = new Map(); 575 if (users && typeof users === "object") { 576 for (const [usernameRaw, prefRaw] of Object.entries(users)) { 577 const username = normId(usernameRaw); 578 if (!username) continue; 579 next.set(username, normalizeAvatarPref(prefRaw)); 580 } 581 } 582 avatarPrefsByUser = next; 583 } catch (e) { 584 console.warn("Maps plugin: failed to load avatar prefs:", e?.message || e); 585 avatarPrefsByUser = new Map(); 586 } 587 } 588 589 function saveAvatarPrefsToDisk() { 590 fs.mkdirSync(DATA_DIR, { recursive: true }); 591 const users = {}; 592 for (const [username, pref] of avatarPrefsByUser.entries()) users[username] = normalizeAvatarPref(pref); 593 fs.writeFileSync(AVATAR_PREFS_FILE, JSON.stringify({ users }, null, 2)); 594 } 595 596 function getAvatarPref(username) { 597 const key = normId(username); 598 if (!key) return normalizeAvatarPref(null); 599 return normalizeAvatarPref(avatarPrefsByUser.get(key)); 600 } 601 602 function normalizeAvatarPreset(raw, actor = "") { 603 const name = normalizePresetName(raw?.name || ""); 604 if (!name) return null; 605 const idRaw = typeof raw?.id === "string" ? normId(raw.id) : ""; 606 const avatar = normalizeAvatarPref(raw?.avatar || {}); 607 const now = api.now(); 608 return { 609 id: idRaw || randId("preset"), 610 name, 611 description: normalizePresetDescription(raw?.description || ""), 612 tags: normalizePresetTags(raw?.tags), 613 mode: avatar.mode, 614 avatar: { 615 mode: avatar.mode, 616 frameAnimation: avatar.frameAnimation || null 617 }, 618 createdBy: normId(raw?.createdBy || actor || ""), 619 updatedBy: normId(actor || raw?.updatedBy || ""), 620 createdAt: clampInt(raw?.createdAt || now, 0, now + 365 * 24 * 60 * 60 * 1000), 621 updatedAt: clampInt(now, 0, now + 365 * 24 * 60 * 60 * 1000), 622 published: Boolean(raw?.published) 623 }; 624 } 625 626 function presetMetaPayload(preset) { 627 return { 628 id: preset.id, 629 name: preset.name, 630 description: preset.description || "", 631 tags: Array.isArray(preset.tags) ? preset.tags : [], 632 mode: preset.mode || "profile_token", 633 createdBy: preset.createdBy || "", 634 updatedBy: preset.updatedBy || "", 635 createdAt: Number(preset.createdAt || 0) || 0, 636 updatedAt: Number(preset.updatedAt || 0) || 0, 637 published: Boolean(preset.published) 638 }; 639 } 640 641 function sendAvatarPresets(ws) { 642 const canManage = canManageAvatarPresets(ws); 643 const presets = avatarPresets 644 .filter((preset) => canManage || Boolean(preset.published)) 645 .map((preset) => (canManage ? { ...presetMetaPayload(preset), avatar: preset.avatar } : presetMetaPayload(preset))); 646 ws.send(JSON.stringify({ type: "plugin:maps:avatarPresets", presets, canManage })); 647 } 648 649 function findAvatarPresetIndexById(rawId) { 650 const targetId = normId(rawId || ""); 651 if (!targetId) return -1; 652 return avatarPresets.findIndex((preset) => normId(preset?.id || "") === targetId); 653 } 654 655 function sanitizeMapChatText(text) { 656 const raw = typeof text === "string" ? text : ""; 657 return raw.replace(/\s+/g, " ").trim().slice(0, 420); 658 } 659 660 function distance01(ax, ay, bx, by) { 661 const dx = Number(ax) - Number(bx); 662 const dy = Number(ay) - Number(by); 663 return Math.sqrt(dx * dx + dy * dy); 664 } 665 666 function userIdentity(ws) { 667 const u = ws?.user?.username ? String(ws.user.username).trim().toLowerCase() : ""; 668 return u && /^[a-z0-9][a-z0-9_.-]{0,31}$/.test(u) ? u : ""; 669 } 670 671 function listMapsPayload() { 672 const t = api.now(); 673 const all = [...BUILTIN_MAPS, ...customMaps]; 674 return all.map((m) => { 675 const room = rooms.get(m.id); 676 const count = room ? Array.from(room.users.values()).filter((u) => !u?.invisible).length : 0; 677 const lastActiveAt = Number(room?.lastActiveAt || 0) || 0; 678 const live = count > 0 && t - lastActiveAt <= 60_000; 679 return { 680 id: m.id, 681 title: m.title, 682 owner: m.owner || "", 683 thumbUrl: m.thumbUrl, 684 backgroundUrl: m.backgroundUrl, 685 world: m.world, 686 avatarSize: clampInt(m.avatarSize || 36, 18, 96), 687 cameraZoom: clampFloat(m.cameraZoom, 0.8, 5.0, 2.35), 688 walkiesEnabled: Boolean(m.walkiesEnabled), 689 ttrpgEnabled: Boolean(m.ttrpgEnabled), 690 spritesCount: Array.isArray(m.sprites) ? m.sprites.length : 0, 691 propsCount: Array.isArray(m.props) ? m.props.length : 0, 692 collisionsCount: Array.isArray(m.collisions) ? m.collisions.length : 0, 693 masksCount: Array.isArray(m.masks) ? m.masks.length : 0, 694 exitsCount: Array.isArray(m.exits) ? m.exits.length : 0, 695 userCount: count, 696 live, 697 lastActiveAt 698 }; 699 }); 700 } 701 702 function broadcastMapsListThrottled() { 703 // Avoid spamming when users move around maps frequently. 704 const t = api.now(); 705 let should = false; 706 for (const m of [...BUILTIN_MAPS, ...customMaps]) { 707 const r = roomFor(m.id); 708 if (!r) continue; 709 if (t - (r.lastListAt || 0) > 750) { 710 r.lastListAt = t; 711 should = true; 712 } 713 } 714 if (!should) return; 715 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 716 } 717 718 function usersInRoom(mapId) { 719 const room = rooms.get(normId(mapId)); 720 if (!room) return []; 721 return Array.from(room.users.keys()); 722 } 723 724 function broadcastRoomState(mapId) { 725 const mid = normId(mapId); 726 const room = rooms.get(mid); 727 if (!room) return; 728 const all = Array.from(room.users.entries()); 729 const recipients = usersInRoom(mid); 730 for (const recipient of recipients) { 731 const includeAvatarSnapshot = avatarSnapshotNeededByUser.has(recipient); 732 const users = all 733 .filter(([username, u]) => username === recipient || !u?.invisible) 734 .map(([username, u]) => { 735 const base = { 736 username, 737 x: u.x, 738 y: u.y, 739 color: u.color || "", 740 image: u.image || "" 741 }; 742 if (includeAvatarSnapshot) { 743 base.avatar = normalizeAvatarPref(u?.avatar || getAvatarPref(username)); 744 } 745 return base; 746 }); 747 const now = api.now(); 748 const typingUsers = Array.from(room.typing?.entries() || []) 749 .filter(([name, until]) => name !== recipient && Number(until || 0) > now) 750 .map(([name]) => name); 751 const visibleCount = all.filter(([, u]) => !u?.invisible).length; 752 const presence = { userCount: visibleCount, live: visibleCount > 0 && now - Number(room.lastActiveAt || 0) <= 60_000, lastActiveAt: Number(room.lastActiveAt || 0) || 0 }; 753 api.sendToUsers([recipient], { type: "plugin:maps:roomState", mapId: mid, users, typingUsers, presence }); 754 if (includeAvatarSnapshot) avatarSnapshotNeededByUser.delete(recipient); 755 } 756 broadcastMapsListThrottled(); 757 } 758 759 function leaveAnyRoom(ws) { 760 const username = userIdentity(ws); 761 if (!username) return; 762 const current = normId(ws.__mapsRoomId || ""); 763 if (!current) return; 764 const room = rooms.get(current); 765 if (!room) { 766 ws.__mapsRoomId = ""; 767 ws.__mapsInvisible = 0; 768 ws.__mapsSpeakAsPropId = ""; 769 return; 770 } 771 dropWalkiePendingForUser(room, username, "leave"); 772 if (room.users.has(username)) room.users.delete(username); 773 if (room.typing && room.typing.has(username)) room.typing.delete(username); 774 ws.__mapsRoomId = ""; 775 ws.__mapsInvisible = 0; 776 ws.__mapsSpeakAsPropId = ""; 777 if (room.users.size === 0) { 778 clearRoomWalkies(room, "room-empty"); 779 rooms.delete(current); 780 } 781 broadcastRoomState(current); 782 } 783 784 api.onWsClose((ws) => { 785 leaveAnyRoom(ws); 786 }); 787 788 function loadCustomMapsFromDisk() { 789 try { 790 fs.mkdirSync(DATA_DIR, { recursive: true }); 791 if (!fs.existsSync(MAPS_FILE)) { 792 customMaps = []; 793 return; 794 } 795 const raw = fs.readFileSync(MAPS_FILE, "utf8"); 796 const json = JSON.parse(raw); 797 const list = Array.isArray(json?.maps) ? json.maps : []; 798 const presetList = Array.isArray(json?.avatarPresets) ? json.avatarPresets : []; 799 const next = []; 800 for (const m of list) { 801 const id = normId(m?.id || ""); 802 if (!id) continue; 803 if (BUILTIN_MAPS.some((b) => b.id === id)) continue; 804 const title = typeof m?.title === "string" ? m.title.trim().slice(0, 60) : id; 805 const owner = typeof m?.owner === "string" ? normId(m.owner) : ""; 806 const backgroundUrl = typeof m?.backgroundUrl === "string" ? m.backgroundUrl.trim() : ""; 807 const thumbUrl = typeof m?.thumbUrl === "string" ? m.thumbUrl.trim() : backgroundUrl; 808 if (!isSafeImageUrl(backgroundUrl) || !isSafeImageUrl(thumbUrl)) continue; 809 const avatarSize = clampInt(m?.avatarSize || 36, 18, 96); 810 const cameraZoom = clampFloat(m?.cameraZoom, 0.8, 5.0, 2.35); 811 const walkiesEnabled = Boolean(m?.walkiesEnabled); 812 const world = 813 m?.world && typeof m.world === "object" 814 ? { w: clampInt(m.world.w, 200, 10000), h: clampInt(m.world.h, 200, 10000) } 815 : null; 816 const collisions = normalizePolyList(m?.collisions); 817 const masks = normalizePolyList(m?.masks); 818 const exits = normalizeExitList(m?.exits); 819 const hiddenMasks = normalizeFogList(m?.hiddenMasks); 820 const occluders = normalizePolyList(m?.occluders); 821 const fallThroughs = normalizeFallList(m?.fallThroughs); 822 const ttrpgEnabled = Boolean(m?.ttrpgEnabled); 823 const sprites = normalizeSpriteList(m?.sprites); 824 const spriteIds = new Set(sprites.map((s) => s.id)); 825 const props = normalizePropList(m?.props, spriteIds); 826 next.push({ 827 id, 828 title, 829 owner, 830 backgroundUrl, 831 thumbUrl, 832 world, 833 avatarSize, 834 cameraZoom, 835 collisions, 836 masks, 837 exits, 838 hiddenMasks, 839 occluders, 840 fallThroughs, 841 ttrpgEnabled, 842 sprites, 843 props, 844 walkiesEnabled 845 }); 846 } 847 customMaps = next; 848 const nextPresets = []; 849 const seenPresetIds = new Set(); 850 for (const rawPreset of presetList) { 851 const preset = normalizeAvatarPreset(rawPreset || {}, rawPreset?.updatedBy || rawPreset?.createdBy || ""); 852 if (!preset) continue; 853 if (seenPresetIds.has(preset.id)) continue; 854 seenPresetIds.add(preset.id); 855 nextPresets.push(preset); 856 } 857 avatarPresets = nextPresets; 858 } catch (e) { 859 console.warn("Maps plugin: failed to load custom maps:", e?.message || e); 860 customMaps = []; 861 avatarPresets = []; 862 } 863 } 864 865 function saveCustomMapsToDisk() { 866 fs.mkdirSync(DATA_DIR, { recursive: true }); 867 fs.writeFileSync(MAPS_FILE, JSON.stringify({ maps: customMaps, avatarPresets }, null, 2)); 868 } 869 870 loadCustomMapsFromDisk(); 871 loadAvatarPrefsFromDisk(); 872 873 api.registerWs("list", (ws) => { 874 ws.send(JSON.stringify({ type: "plugin:maps:mapsList", maps: listMapsPayload() })); 875 ws.send(JSON.stringify(mapsCapabilities(ws))); 876 sendAvatarPresets(ws); 877 }); 878 879 api.registerWs("createMap", (ws, msg) => { 880 const username = userIdentity(ws); 881 if (!username) { 882 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Sign in required." })); 883 return; 884 } 885 const role = String(ws?.user?.role || "").toLowerCase(); 886 if (role !== "owner" && role !== "admin" && role !== "moderator") { 887 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Owner/admin/mod access required to create maps." })); 888 return; 889 } 890 891 const id = normId(msg?.id || ""); 892 if (!id) { 893 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Invalid map id." })); 894 return; 895 } 896 if (mapById(id)) { 897 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map id already exists." })); 898 return; 899 } 900 901 const title = typeof msg?.title === "string" ? msg.title.trim().slice(0, 60) : ""; 902 if (!title) { 903 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Missing map title." })); 904 return; 905 } 906 const backgroundUrl = typeof msg?.backgroundUrl === "string" ? msg.backgroundUrl.trim() : ""; 907 const thumbUrl = typeof msg?.thumbUrl === "string" ? msg.thumbUrl.trim() : backgroundUrl; 908 if (!isSafeImageUrl(backgroundUrl) || !isSafeImageUrl(thumbUrl)) { 909 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Invalid map image URL." })); 910 return; 911 } 912 const avatarSize = clampInt(msg?.avatarSize || 36, 18, 96); 913 914 customMaps.push({ 915 id, 916 title, 917 owner: username, 918 backgroundUrl, 919 thumbUrl, 920 world: null, 921 avatarSize, 922 cameraZoom: 2.35, 923 collisions: [], 924 masks: [], 925 exits: [], 926 hiddenMasks: [], 927 occluders: [], 928 fallThroughs: [], 929 ttrpgEnabled: false, 930 sprites: [], 931 props: [], 932 walkiesEnabled: false 933 }); 934 try { 935 saveCustomMapsToDisk(); 936 } catch (e) { 937 customMaps = customMaps.filter((m) => m.id !== id); 938 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Failed to save map." })); 939 return; 940 } 941 942 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 943 }); 944 945 api.registerWs("updateMap", (ws, msg) => { 946 const mapId = normId(msg?.id || ""); 947 const map = mapById(mapId); 948 if (!map || BUILTIN_MAPS.some((m) => m.id === mapId)) { 949 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 950 return; 951 } 952 if (!canManageMaps(ws, map)) { 953 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 954 return; 955 } 956 const idx = customMaps.findIndex((m) => m.id === mapId); 957 if (idx < 0) return; 958 959 const next = { ...customMaps[idx] }; 960 const patch = {}; 961 if (msg && Object.prototype.hasOwnProperty.call(msg, "avatarSize")) { 962 next.avatarSize = clampInt(msg.avatarSize, 18, 96); 963 patch.avatarSize = next.avatarSize; 964 } 965 if (msg && Object.prototype.hasOwnProperty.call(msg, "cameraZoom")) { 966 next.cameraZoom = clampFloat(msg.cameraZoom, 0.8, 5.0, 2.35); 967 patch.cameraZoom = next.cameraZoom; 968 } 969 if (msg && Object.prototype.hasOwnProperty.call(msg, "collisions")) { 970 next.collisions = normalizePolyList(msg.collisions); 971 patch.collisions = next.collisions; 972 } 973 if (msg && Object.prototype.hasOwnProperty.call(msg, "masks")) { 974 next.masks = normalizePolyList(msg.masks); 975 patch.masks = next.masks; 976 } 977 if (msg && Object.prototype.hasOwnProperty.call(msg, "exits")) { 978 next.exits = normalizeExitList(msg.exits); 979 patch.exits = next.exits; 980 } 981 if (msg && Object.prototype.hasOwnProperty.call(msg, "hiddenMasks")) { 982 next.hiddenMasks = normalizeFogList(msg.hiddenMasks); 983 patch.hiddenMasks = next.hiddenMasks; 984 } 985 if (msg && Object.prototype.hasOwnProperty.call(msg, "occluders")) { 986 next.occluders = normalizePolyList(msg.occluders); 987 patch.occluders = next.occluders; 988 } 989 if (msg && Object.prototype.hasOwnProperty.call(msg, "fallThroughs")) { 990 next.fallThroughs = normalizeFallList(msg.fallThroughs); 991 patch.fallThroughs = next.fallThroughs; 992 } 993 if (msg && Object.prototype.hasOwnProperty.call(msg, "ttrpgEnabled")) { 994 next.ttrpgEnabled = Boolean(msg.ttrpgEnabled); 995 } 996 if (msg && Object.prototype.hasOwnProperty.call(msg, "sprites")) { 997 next.sprites = normalizeSpriteList(msg.sprites); 998 } 999 if (msg && Object.prototype.hasOwnProperty.call(msg, "props")) { 1000 const spriteIds = new Set((Array.isArray(next.sprites) ? next.sprites : []).map((s) => s?.id).filter(Boolean)); 1001 next.props = normalizePropList(msg.props, spriteIds); 1002 } 1003 if (msg && Object.prototype.hasOwnProperty.call(msg, "walkiesEnabled")) { 1004 next.walkiesEnabled = Boolean(msg.walkiesEnabled); 1005 patch.walkiesEnabled = next.walkiesEnabled; 1006 } 1007 customMaps[idx] = next; 1008 scheduleSaveSoon(mapId); 1009 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 1010 if (Object.keys(patch).length) { 1011 sendToRoom(mapId, { type: "plugin:maps:mapPatched", mapId, patch }); 1012 } 1013 }); 1014 1015 function customMapIndex(mapId) { 1016 const mid = normId(mapId); 1017 if (!mid) return -1; 1018 return customMaps.findIndex((m) => m.id === mid); 1019 } 1020 1021 function sendToRoom(mapId, msg) { 1022 const mid = normId(mapId); 1023 if (!mid) return 0; 1024 return api.sendToUsers(usersInRoom(mid), msg); 1025 } 1026 1027 api.registerWs("ttrpgSetEnabled", (ws, msg) => { 1028 const mapId = normId(msg?.mapId || msg?.id || ""); 1029 const idx = customMapIndex(mapId); 1030 if (idx < 0) { 1031 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 1032 return; 1033 } 1034 const map = customMaps[idx]; 1035 if (!canManageMaps(ws, map)) { 1036 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 1037 return; 1038 } 1039 map.ttrpgEnabled = Boolean(msg?.enabled); 1040 scheduleSaveSoon(mapId); 1041 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 1042 sendToRoom(mapId, { type: "plugin:maps:ttrpgEnabled", mapId, enabled: Boolean(map.ttrpgEnabled) }); 1043 }); 1044 1045 api.registerWs("ttrpgSpriteAdd", (ws, msg) => { 1046 const mapId = normId(msg?.mapId || ""); 1047 const idx = customMapIndex(mapId); 1048 if (idx < 0) { 1049 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 1050 return; 1051 } 1052 const map = customMaps[idx]; 1053 if (!canManageMaps(ws, map)) { 1054 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 1055 return; 1056 } 1057 const url = typeof msg?.url === "string" ? msg.url.trim() : ""; 1058 if (!url.startsWith("/uploads/") || !isSafeImageUrl(url)) { 1059 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Invalid sprite image URL." })); 1060 return; 1061 } 1062 const kind = msg?.kind === "token" ? "token" : "prop"; 1063 const name = typeof msg?.name === "string" ? msg.name.trim().slice(0, 40) : ""; 1064 const scale = clampFloat(msg?.scale, 0.1, 4.0, 1.0); 1065 const id = typeof msg?.id === "string" && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,64}$/.test(msg.id) ? msg.id : randId("spr"); 1066 const sprite = { id, kind, name, url, scale }; 1067 if (!Array.isArray(map.sprites)) map.sprites = []; 1068 map.sprites = normalizeSpriteList([...map.sprites, sprite]); 1069 scheduleSaveSoon(mapId); 1070 sendToRoom(mapId, { type: "plugin:maps:spriteAdded", mapId, sprite }); 1071 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 1072 }); 1073 1074 api.registerWs("ttrpgSpriteRemove", (ws, msg) => { 1075 const mapId = normId(msg?.mapId || ""); 1076 const idx = customMapIndex(mapId); 1077 if (idx < 0) return; 1078 const map = customMaps[idx]; 1079 if (!canManageMaps(ws, map)) { 1080 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 1081 return; 1082 } 1083 const spriteId = typeof msg?.spriteId === "string" ? msg.spriteId.trim() : ""; 1084 if (!spriteId) return; 1085 map.sprites = (Array.isArray(map.sprites) ? map.sprites : []).filter((s) => String(s?.id || "") !== spriteId); 1086 map.props = (Array.isArray(map.props) ? map.props : []).filter((p) => String(p?.spriteId || "") !== spriteId); 1087 scheduleSaveSoon(mapId); 1088 sendToRoom(mapId, { type: "plugin:maps:spriteRemoved", mapId, spriteId }); 1089 sendToRoom(mapId, { type: "plugin:maps:propsReset", mapId, props: Array.isArray(map.props) ? map.props : [] }); 1090 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 1091 }); 1092 1093 api.registerWs("ttrpgPropAdd", (ws, msg) => { 1094 const username = userIdentity(ws); 1095 if (!username) return; 1096 const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); 1097 const idx = customMapIndex(mapId); 1098 if (idx < 0) return; 1099 const map = customMaps[idx]; 1100 if (!map.ttrpgEnabled) return; 1101 if (!canManageMaps(ws, map)) { 1102 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 1103 return; 1104 } 1105 const spriteId = typeof msg?.spriteId === "string" ? msg.spriteId.trim() : ""; 1106 const spriteOk = (Array.isArray(map.sprites) ? map.sprites : []).some((s) => String(s?.id || "") === spriteId); 1107 if (!spriteId || !spriteOk) return; 1108 const x = clamp01(msg?.x); 1109 const y = clamp01(msg?.y); 1110 const z = clampInt(msg?.z || 0, -10_000, 10_000); 1111 const rot = clampFloat(msg?.rot, -180, 180, 0); 1112 const scale = clampFloat(msg?.scale, 0.1, 4.0, 1.0); 1113 const sprite = spriteById(map, spriteId); 1114 const nickname = typeof msg?.nickname === "string" ? msg.nickname.trim().slice(0, 40) : ""; 1115 const hpMax = clampInt(msg?.hpMax || 10, 0, 9999); 1116 const hpCurrent = clampInt(msg?.hpCurrent || hpMax, 0, hpMax > 0 ? hpMax : 9999); 1117 const id = typeof msg?.id === "string" && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,64}$/.test(msg.id) ? msg.id : randId("prop"); 1118 const prop = { 1119 id, 1120 spriteId, 1121 x, 1122 y, 1123 z, 1124 rot, 1125 scale, 1126 nickname: sprite?.kind === "token" ? nickname : "", 1127 hpCurrent: sprite?.kind === "token" ? hpCurrent : 0, 1128 hpMax: sprite?.kind === "token" ? hpMax : 0, 1129 controlledBy: "" 1130 }; 1131 if (!Array.isArray(map.props)) map.props = []; 1132 map.props = normalizePropList([...map.props, prop], new Set(map.sprites.map((s) => s.id))); 1133 scheduleSaveSoon(mapId); 1134 sendToRoom(mapId, { type: "plugin:maps:propAdded", mapId, prop }); 1135 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 1136 }); 1137 1138 api.registerWs("ttrpgPropMove", (ws, msg) => { 1139 const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); 1140 const idx = customMapIndex(mapId); 1141 if (idx < 0) { 1142 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 1143 return; 1144 } 1145 const map = customMaps[idx]; 1146 if (!map.ttrpgEnabled) { 1147 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "TTRPG mode is disabled for this map." })); 1148 return; 1149 } 1150 if (!canManageMaps(ws, map)) { 1151 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 1152 return; 1153 } 1154 const propId = typeof msg?.propId === "string" ? msg.propId.trim() : ""; 1155 if (!propId) return; 1156 const list = Array.isArray(map.props) ? map.props : []; 1157 const pidx = list.findIndex((p) => String(p?.id || "") === propId); 1158 if (pidx < 0) return; 1159 const prev = list[pidx] || {}; 1160 const x = clamp01(msg?.x); 1161 const y = clamp01(msg?.y); 1162 const z = Object.prototype.hasOwnProperty.call(msg || {}, "z") ? clampInt(msg?.z || 0, -10_000, 10_000) : prev.z || 0; 1163 const rot = Object.prototype.hasOwnProperty.call(msg || {}, "rot") ? clampFloat(msg?.rot, -180, 180, 0) : prev.rot || 0; 1164 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); 1165 list[pidx] = { ...prev, x, y, z, rot, scale }; 1166 map.props = list; 1167 scheduleSaveSoon(mapId); 1168 sendToRoom(mapId, { type: "plugin:maps:propMoved", mapId, propId, x, y, z, rot, scale }); 1169 }); 1170 1171 api.registerWs("ttrpgPropPatch", (ws, msg) => { 1172 const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); 1173 const idx = customMapIndex(mapId); 1174 if (idx < 0) { 1175 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 1176 return; 1177 } 1178 const map = customMaps[idx]; 1179 if (!map.ttrpgEnabled) { 1180 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "TTRPG mode is disabled for this map." })); 1181 return; 1182 } 1183 if (!canManageMaps(ws, map)) { 1184 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 1185 return; 1186 } 1187 const propId = typeof msg?.propId === "string" ? msg.propId.trim() : ""; 1188 const { prop: prev, index: pidx } = propById(map, propId); 1189 if (!prev || pidx < 0) return; 1190 const sprite = spriteById(map, String(prev.spriteId || "")); 1191 const isToken = sprite?.kind === "token"; 1192 const patch = {}; 1193 if (Object.prototype.hasOwnProperty.call(msg || {}, "nickname")) { 1194 patch.nickname = isToken ? String(msg?.nickname || "").trim().slice(0, 40) : ""; 1195 } 1196 if (Object.prototype.hasOwnProperty.call(msg || {}, "hpMax")) { 1197 patch.hpMax = isToken ? clampInt(msg?.hpMax || 0, 0, 9999) : 0; 1198 } 1199 if (Object.prototype.hasOwnProperty.call(msg || {}, "hpCurrent")) { 1200 const currentCap = Object.prototype.hasOwnProperty.call(patch, "hpMax") ? patch.hpMax : clampInt(prev.hpMax || 0, 0, 9999); 1201 patch.hpCurrent = isToken ? clampInt(msg?.hpCurrent || 0, 0, currentCap > 0 ? currentCap : 9999) : 0; 1202 } 1203 if (!Object.keys(patch).length) return; 1204 const next = { ...prev, ...patch }; 1205 map.props[pidx] = next; 1206 scheduleSaveSoon(mapId); 1207 sendToRoom(mapId, { type: "plugin:maps:propPatched", mapId, prop: next }); 1208 }); 1209 1210 api.registerWs("ttrpgTokenPossess", (ws, msg) => { 1211 const username = userIdentity(ws); 1212 if (!username) return; 1213 const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); 1214 const idx = customMapIndex(mapId); 1215 if (idx < 0) { 1216 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 1217 return; 1218 } 1219 const map = customMaps[idx]; 1220 if (!map.ttrpgEnabled) { 1221 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "TTRPG mode is disabled for this map." })); 1222 return; 1223 } 1224 if (!canManageMaps(ws, map)) { 1225 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 1226 return; 1227 } 1228 const action = msg?.action === "release" ? "release" : "possess"; 1229 const props = Array.isArray(map.props) ? map.props : []; 1230 1231 // Release always clears *all* tokens controlled by this user (prevents "stuck" control). 1232 if (action === "release") { 1233 let changed = false; 1234 for (let i = 0; i < props.length; i++) { 1235 const p = props[i]; 1236 if (!p) continue; 1237 if (String(p.controlledBy || "") !== username) continue; 1238 const spr = spriteById(map, String(p.spriteId || "")); 1239 if (!spr || spr.kind !== "token") continue; 1240 props[i] = { ...p, controlledBy: "" }; 1241 changed = true; 1242 sendToRoom(mapId, { type: "plugin:maps:propPatched", mapId, prop: props[i] }); 1243 } 1244 ws.__mapsSpeakAsPropId = ""; 1245 if (changed) { 1246 map.props = props; 1247 scheduleSaveSoon(mapId); 1248 } 1249 return; 1250 } 1251 1252 const propId = typeof msg?.propId === "string" ? msg.propId.trim() : ""; 1253 const { prop: prev, index: pidx } = propById(map, propId); 1254 if (!prev || pidx < 0) return; 1255 const sprite = spriteById(map, String(prev.spriteId || "")); 1256 if (!sprite || sprite.kind !== "token") return; 1257 if (prev.controlledBy && prev.controlledBy !== username) return; 1258 1259 // Possession is exclusive per user: release any other controlled tokens first. 1260 for (let i = 0; i < props.length; i++) { 1261 const p = props[i]; 1262 if (!p) continue; 1263 if (String(p.id || "") === propId) continue; 1264 if (String(p.controlledBy || "") !== username) continue; 1265 const spr = spriteById(map, String(p.spriteId || "")); 1266 if (!spr || spr.kind !== "token") continue; 1267 props[i] = { ...p, controlledBy: "" }; 1268 sendToRoom(mapId, { type: "plugin:maps:propPatched", mapId, prop: props[i] }); 1269 } 1270 1271 const next = { ...prev, controlledBy: username }; 1272 props[pidx] = next; 1273 map.props = props; 1274 ws.__mapsSpeakAsPropId = propId; 1275 scheduleSaveSoon(mapId); 1276 sendToRoom(mapId, { type: "plugin:maps:propPatched", mapId, prop: next }); 1277 }); 1278 1279 api.registerWs("ttrpgPropRemove", (ws, msg) => { 1280 const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); 1281 const idx = customMapIndex(mapId); 1282 if (idx < 0) { 1283 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 1284 return; 1285 } 1286 const map = customMaps[idx]; 1287 if (!map.ttrpgEnabled) { 1288 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "TTRPG mode is disabled for this map." })); 1289 return; 1290 } 1291 if (!canManageMaps(ws, map)) { 1292 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 1293 return; 1294 } 1295 const propId = typeof msg?.propId === "string" ? msg.propId.trim() : ""; 1296 if (!propId) return; 1297 map.props = (Array.isArray(map.props) ? map.props : []).filter((p) => String(p?.id || "") !== propId); 1298 scheduleSaveSoon(mapId); 1299 sendToRoom(mapId, { type: "plugin:maps:propRemoved", mapId, propId }); 1300 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 1301 }); 1302 1303 api.registerWs("deleteMap", (ws, msg) => { 1304 const mapId = normId(msg?.id || ""); 1305 const map = mapById(mapId); 1306 if (!map || BUILTIN_MAPS.some((m) => m.id === mapId)) { 1307 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 1308 return; 1309 } 1310 if (!canManageMaps(ws, map)) { 1311 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 1312 return; 1313 } 1314 customMaps = customMaps.filter((m) => m.id !== mapId); 1315 try { 1316 saveCustomMapsToDisk(); 1317 } catch (e) { 1318 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Failed to delete map." })); 1319 return; 1320 } 1321 api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); 1322 }); 1323 1324 api.registerWs("join", (ws, msg) => { 1325 const username = userIdentity(ws); 1326 if (!username) { 1327 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Sign in required." })); 1328 return; 1329 } 1330 const mapId = normId(msg?.mapId || ""); 1331 const map = mapById(mapId); 1332 if (!map) { 1333 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 1334 return; 1335 } 1336 1337 leaveAnyRoom(ws); 1338 1339 const room = roomFor(mapId); 1340 if (!room) return; 1341 1342 const prof = api.getProfile(username) || {}; 1343 const color = typeof prof.color === "string" ? prof.color : ""; 1344 const image = typeof prof.image === "string" ? prof.image : ""; 1345 room.users.set(username, { x: Math.random(), y: Math.random(), color, image, avatar: getAvatarPref(username), invisible: false, seq: 0 }); 1346 room.lastActiveAt = api.now(); 1347 avatarSnapshotNeededByUser.add(username); 1348 ws.__mapsRoomId = mapId; 1349 ws.__mapsInvisible = 0; 1350 1351 ws.send( 1352 JSON.stringify({ 1353 type: "plugin:maps:joinOk", 1354 map: { 1355 id: map.id, 1356 title: map.title, 1357 owner: map.owner || "", 1358 backgroundUrl: map.backgroundUrl, 1359 world: map.world || null, 1360 avatarSize: clampInt(map.avatarSize || 36, 18, 96), 1361 cameraZoom: clampFloat(map.cameraZoom, 0.8, 5.0, 2.35), 1362 collisions: Array.isArray(map.collisions) ? map.collisions : [], 1363 masks: Array.isArray(map.masks) ? map.masks : [], 1364 exits: Array.isArray(map.exits) ? map.exits : [], 1365 hiddenMasks: Array.isArray(map.hiddenMasks) ? map.hiddenMasks : [], 1366 occluders: Array.isArray(map.occluders) ? map.occluders : [], 1367 fallThroughs: Array.isArray(map.fallThroughs) ? map.fallThroughs : [], 1368 ttrpgEnabled: Boolean(map.ttrpgEnabled), 1369 sprites: Array.isArray(map.sprites) ? map.sprites : [], 1370 props: Array.isArray(map.props) ? map.props : [], 1371 walkiesEnabled: Boolean(map.walkiesEnabled) 1372 }, 1373 selfInvisible: false 1374 }) 1375 ); 1376 ws.send(JSON.stringify(mapsCapabilities(ws))); 1377 sendAvatarPresets(ws); 1378 broadcastRoomState(mapId); 1379 }); 1380 1381 api.registerWs("getCapabilities", (ws) => { 1382 ws.send(JSON.stringify(mapsCapabilities(ws))); 1383 }); 1384 1385 api.registerWs("setAvatar", (ws, msg) => { 1386 const username = userIdentity(ws); 1387 if (!username) return; 1388 const avatar = normalizeAvatarPref(msg || {}); 1389 avatarPrefsByUser.set(username, avatar); 1390 try { 1391 saveAvatarPrefsToDisk(); 1392 } catch (e) { 1393 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Failed to save avatar settings." })); 1394 return; 1395 } 1396 const mapId = normId(ws.__mapsRoomId || ""); 1397 const room = mapId ? rooms.get(mapId) : null; 1398 if (room && room.users.has(username)) { 1399 const current = room.users.get(username) || {}; 1400 room.users.set(username, { ...current, avatar }); 1401 api.sendToUsers(usersInRoom(mapId), { type: "plugin:maps:avatarChanged", mapId, username, avatar }); 1402 broadcastRoomState(mapId); 1403 } 1404 ws.send(JSON.stringify({ type: "plugin:maps:avatarSet", avatar })); 1405 }); 1406 1407 api.registerWs("listAvatarPresets", (ws) => { 1408 sendAvatarPresets(ws); 1409 }); 1410 1411 api.registerWs("upsertAvatarPreset", (ws, msg) => { 1412 const username = userIdentity(ws); 1413 if (!username) return; 1414 if (!canManageAvatarPresets(ws)) { 1415 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Owner/admin/mod access required." })); 1416 return; 1417 } 1418 const normalized = normalizeAvatarPreset(msg || {}, username); 1419 if (!normalized) { 1420 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Invalid avatar preset." })); 1421 return; 1422 } 1423 const idx = findAvatarPresetIndexById(normalized.id); 1424 if (idx >= 0) { 1425 const prior = avatarPresets[idx]; 1426 avatarPresets[idx] = { 1427 ...prior, 1428 ...normalized, 1429 id: prior.id, 1430 createdAt: Number(prior.createdAt || api.now()), 1431 createdBy: prior.createdBy || username, 1432 updatedAt: api.now(), 1433 updatedBy: username 1434 }; 1435 } else { 1436 avatarPresets.push({ 1437 ...normalized, 1438 createdAt: api.now(), 1439 updatedAt: api.now(), 1440 createdBy: username, 1441 updatedBy: username 1442 }); 1443 } 1444 try { 1445 saveCustomMapsToDisk(); 1446 } catch { 1447 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Failed to save avatar presets." })); 1448 return; 1449 } 1450 sendAvatarPresets(ws); 1451 api.broadcast({ type: "plugin:maps:avatarPresetsUpdated" }); 1452 }); 1453 1454 api.registerWs("deleteAvatarPreset", (ws, msg) => { 1455 if (!canManageAvatarPresets(ws)) { 1456 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Owner/admin/mod access required." })); 1457 return; 1458 } 1459 const id = normId(msg?.id || ""); 1460 if (!id) return; 1461 const idx = findAvatarPresetIndexById(id); 1462 if (idx < 0) { 1463 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Preset not found." })); 1464 return; 1465 } 1466 avatarPresets.splice(idx, 1); 1467 try { 1468 saveCustomMapsToDisk(); 1469 } catch { 1470 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Failed to save avatar presets." })); 1471 return; 1472 } 1473 sendAvatarPresets(ws); 1474 api.broadcast({ type: "plugin:maps:avatarPresetsUpdated" }); 1475 }); 1476 1477 api.registerWs("applyAvatarPreset", (ws, msg) => { 1478 const username = userIdentity(ws); 1479 if (!username) return; 1480 const id = normId(msg?.id || ""); 1481 if (!id) return; 1482 const idx = findAvatarPresetIndexById(id); 1483 if (idx < 0) { 1484 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Preset not found." })); 1485 return; 1486 } 1487 const preset = avatarPresets[idx]; 1488 if (!preset.published && !canManageAvatarPresets(ws)) { 1489 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Preset unavailable." })); 1490 return; 1491 } 1492 const current = getAvatarPref(username); 1493 const avatar = normalizeAvatarPref({ 1494 ...preset.avatar, 1495 displayName: current.displayName || "", 1496 showUsername: current.showUsername !== false 1497 }); 1498 avatarPrefsByUser.set(username, avatar); 1499 try { 1500 saveAvatarPrefsToDisk(); 1501 } catch { 1502 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Failed to apply avatar preset." })); 1503 return; 1504 } 1505 const mapId = normId(ws.__mapsRoomId || ""); 1506 const room = mapId ? rooms.get(mapId) : null; 1507 if (room && room.users.has(username)) { 1508 const currentUser = room.users.get(username) || {}; 1509 room.users.set(username, { ...currentUser, avatar }); 1510 api.sendToUsers(usersInRoom(mapId), { type: "plugin:maps:avatarChanged", mapId, username, avatar }); 1511 broadcastRoomState(mapId); 1512 } 1513 ws.send(JSON.stringify({ type: "plugin:maps:avatarSet", avatar })); 1514 }); 1515 1516 api.registerWs("avatarEmote", (ws, msg) => { 1517 const username = userIdentity(ws); 1518 if (!username) return; 1519 const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); 1520 if (!mapId) return; 1521 const room = rooms.get(mapId); 1522 if (!room || !room.users.has(username)) return; 1523 const pref = getAvatarPref(username); 1524 const resolved = resolveAvatarEmote(pref, msg); 1525 if (!resolved) return; 1526 const until = api.now() + resolved.durationMs; 1527 room.lastActiveAt = api.now(); 1528 api.sendToUsers(usersInRoom(mapId), { 1529 type: "plugin:maps:avatarEmote", 1530 mapId, 1531 username, 1532 state: resolved.state, 1533 name: resolved.name, 1534 loop: resolved.loop, 1535 until 1536 }); 1537 broadcastMapsListThrottled(); 1538 }); 1539 1540 api.registerWs("leave", (ws) => { 1541 leaveAnyRoom(ws); 1542 ws.send(JSON.stringify({ type: "plugin:maps:left" })); 1543 }); 1544 1545 api.registerWs("move", (ws, msg) => { 1546 const username = userIdentity(ws); 1547 if (!username) return; 1548 const mapId = normId(ws.__mapsRoomId || ""); 1549 if (!mapId) return; 1550 const room = rooms.get(mapId); 1551 if (!room) return; 1552 const u = room.users.get(username); 1553 if (!u) return; 1554 1555 const t = api.now(); 1556 const last = Number(ws.__mapsLastMoveAt || 0) || 0; 1557 if (t - last < 50) return; // ~20Hz 1558 ws.__mapsLastMoveAt = t; 1559 1560 const x = clamp01(msg?.x); 1561 const y = clamp01(msg?.y); 1562 const seq = clampSeq(msg?.seq); 1563 const prevSeq = clampSeq(u?.seq || 0); 1564 if (seq && seq < prevSeq) return; 1565 const next = { ...u, x, y, seq: seq || prevSeq }; 1566 room.users.set(username, next); 1567 room.lastActiveAt = api.now(); 1568 1569 const payload = { type: "plugin:maps:userMoved", mapId, username, x, y, seq: seq || prevSeq }; 1570 if (next.invisible) { 1571 api.sendToUsers([username], payload); 1572 } else { 1573 api.sendToUsers(usersInRoom(mapId), payload); 1574 } 1575 }); 1576 1577 api.registerWs("chatHistoryReq", (ws, msg) => { 1578 const username = userIdentity(ws); 1579 if (!username) return; 1580 const mapId = normId(msg?.mapId || ws.__mapsRoomId || ""); 1581 if (!mapId) return; 1582 const room = rooms.get(mapId); 1583 if (!room) return; 1584 if (!room.users.has(username)) return; 1585 const list = Array.isArray(room.chatGlobal) ? room.chatGlobal : []; 1586 ws.send(JSON.stringify({ type: "plugin:maps:chatHistory", mapId, scope: "global", messages: list.slice(-MAP_CHAT_GLOBAL_MAX) })); 1587 }); 1588 1589 api.registerWs("chatSend", (ws, msg) => { 1590 const username = userIdentity(ws); 1591 if (!username) return; 1592 const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); 1593 if (!mapId) return; 1594 const room = rooms.get(mapId); 1595 if (!room) return; 1596 const u = room.users.get(username); 1597 if (!u) return; 1598 1599 const scopeRaw = typeof msg?.scope === "string" ? msg.scope.trim().toLowerCase() : "local"; 1600 const scope = scopeRaw === "global" ? "global" : "local"; 1601 const text = sanitizeMapChatText(msg?.text); 1602 if (!text) return; 1603 1604 const createdAt = api.now(); 1605 room.lastActiveAt = createdAt; 1606 const id = `${createdAt}_${Math.random().toString(16).slice(2)}`; 1607 const message = { id, fromUser: username, text, createdAt }; 1608 const payload = { type: "plugin:maps:chatMessage", mapId, scope, message }; 1609 1610 // If invisible, only send to self (consistent with bubbles/movement). 1611 if (u.invisible) { 1612 api.sendToUsers([username], payload); 1613 return; 1614 } 1615 1616 if (scope === "global") { 1617 if (!Array.isArray(room.chatGlobal)) room.chatGlobal = []; 1618 room.chatGlobal.push(message); 1619 if (room.chatGlobal.length > MAP_CHAT_GLOBAL_MAX * 2) room.chatGlobal = room.chatGlobal.slice(-MAP_CHAT_GLOBAL_MAX); 1620 api.sendToUsers(usersInRoom(mapId), payload); 1621 return; 1622 } 1623 1624 // Local: deliver only to users within radius at send-time ("witnessing it"). 1625 const recipients = []; 1626 const all = Array.from(room.users.entries()); 1627 for (const [otherName, other] of all) { 1628 if (!other) continue; 1629 const d = distance01(u.x, u.y, other.x, other.y); 1630 if (d <= MAP_CHAT_LOCAL_RADIUS) recipients.push(otherName); 1631 } 1632 if (!recipients.includes(username)) recipients.push(username); 1633 api.sendToUsers(recipients, payload); 1634 }); 1635 1636 api.registerWs("say", (ws, msg) => { 1637 const username = userIdentity(ws); 1638 if (!username) return; 1639 const mapId = normId(ws.__mapsRoomId || ""); 1640 if (!mapId) return; 1641 const map = mapById(mapId); 1642 if (!map) return; 1643 const room = rooms.get(mapId); 1644 if (!room) return; 1645 const u = room.users.get(username); 1646 if (!u) return; 1647 1648 const text = typeof msg?.text === "string" ? msg.text.replace(/\s+/g, " ").trim().slice(0, 120) : ""; 1649 if (!text) return; 1650 let actorType = "user"; 1651 let actorPropId = ""; 1652 let displayName = `@${username}`; 1653 const color = typeof u.color === "string" ? u.color : ""; 1654 const requestedPropId = typeof msg?.actorPropId === "string" ? msg.actorPropId.trim() : ""; 1655 if (requestedPropId && map.ttrpgEnabled && canManageMaps(ws, map)) { 1656 const { prop } = propById(map, requestedPropId); 1657 const sprite = prop ? spriteById(map, String(prop.spriteId || "")) : null; 1658 if (prop && sprite && sprite.kind === "token") { 1659 if (!prop.controlledBy || prop.controlledBy === username) { 1660 actorType = "token"; 1661 actorPropId = requestedPropId; 1662 ws.__mapsSpeakAsPropId = requestedPropId; 1663 displayName = String(prop.nickname || sprite.name || sprite.id || "token").slice(0, 40); 1664 } 1665 } 1666 } 1667 const createdAt = api.now(); 1668 room.lastActiveAt = createdAt; 1669 const scopeRaw = typeof msg?.scope === "string" ? msg.scope.trim().toLowerCase() : "local"; 1670 const scope = scopeRaw === "global" ? "global" : "local"; 1671 const payload = { type: "plugin:maps:bubble", mapId, username, actorType, actorPropId, displayName, color, text, createdAt, scope }; 1672 if (u.invisible) { 1673 api.sendToUsers([username], payload); 1674 } else if (scope === "global") { 1675 api.sendToUsers(usersInRoom(mapId), payload); 1676 } else { 1677 const recipients = []; 1678 const all = Array.from(room.users.entries()); 1679 for (const [otherName, other] of all) { 1680 if (!other) continue; 1681 const d = distance01(u.x, u.y, other.x, other.y); 1682 if (d <= MAP_CHAT_LOCAL_RADIUS) recipients.push(otherName); 1683 } 1684 if (!recipients.includes(username)) recipients.push(username); 1685 api.sendToUsers(recipients, payload); 1686 } 1687 }); 1688 1689 api.registerWs("typing", (ws, msg) => { 1690 const username = userIdentity(ws); 1691 if (!username) return; 1692 const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); 1693 if (!mapId) return; 1694 const room = rooms.get(mapId); 1695 if (!room || !room.users.has(username)) return; 1696 const isTyping = Boolean(msg?.isTyping); 1697 if (!room.typing) room.typing = new Map(); 1698 if (isTyping) { 1699 room.typing.set(username, api.now() + 4500); 1700 room.lastActiveAt = api.now(); 1701 } else { 1702 room.typing.delete(username); 1703 } 1704 api.sendToUsers(usersInRoom(mapId), { type: "plugin:maps:typing", mapId, username, isTyping, expiresAt: Number(room.typing.get(username) || 0) || 0 }); 1705 broadcastMapsListThrottled(); 1706 }); 1707 1708 api.registerWs("setInvisible", (ws, msg) => { 1709 const username = userIdentity(ws); 1710 if (!username) return; 1711 const mapId = normId(msg?.mapId || ws.__mapsRoomId || ""); 1712 if (!mapId) return; 1713 const map = mapById(mapId); 1714 if (!map) { 1715 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); 1716 return; 1717 } 1718 if (!canManageMaps(ws, map)) { 1719 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); 1720 return; 1721 } 1722 const room = rooms.get(mapId); 1723 if (!room) return; 1724 const u = room.users.get(username); 1725 if (!u) return; 1726 const invisible = Boolean(msg?.invisible); 1727 room.users.set(username, { ...u, invisible }); 1728 ws.__mapsInvisible = invisible ? 1 : 0; 1729 ws.send(JSON.stringify({ type: "plugin:maps:selfInvisible", mapId, invisible })); 1730 broadcastRoomState(mapId); 1731 }); 1732 1733 api.registerWs("walkieSend", (ws, msg) => { 1734 const username = userIdentity(ws); 1735 if (!username) return; 1736 const mapId = normId(ws.__mapsRoomId || ""); 1737 if (!mapId) return; 1738 const map = mapById(mapId); 1739 if (!map) return; 1740 if (!map.walkiesEnabled) { 1741 noteWalkie("send-denied-disabled", mapId, { username }); 1742 ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Walkies are disabled for this map." })); 1743 return; 1744 } 1745 const room = roomFor(mapId); 1746 if (!room) return; 1747 if (!room.users.has(username)) return; 1748 1749 const idRaw = typeof msg?.id === "string" ? msg.id.trim() : ""; 1750 let id = idRaw && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,80}$/.test(idRaw) ? idRaw : `${api.now()}_${Math.random().toString(16).slice(2)}`; 1751 while (room.walkies?.has(id)) id = `${id}_${Math.random().toString(16).slice(2, 6)}`; 1752 const url = typeof msg?.url === "string" ? msg.url.trim() : ""; 1753 if (!isSafeUploadUrl(url)) { 1754 noteWalkie("send-denied-bad-url", mapId, { username }); 1755 return; 1756 } 1757 const x = clamp01(msg?.x); 1758 const y = clamp01(msg?.y); 1759 1760 const createdAt = api.now(); 1761 room.lastActiveAt = createdAt; 1762 const pending = new Set(usersInRoom(mapId)); 1763 if (!room.walkies) room.walkies = new Map(); 1764 const timeout = setTimeout(() => { 1765 const r = rooms.get(mapId); 1766 if (!r?.walkies?.has(id)) return; 1767 cleanupWalkieEntry(r, id, "cleanup-timeout", {}); 1768 }, 2 * 60 * 1000); 1769 room.walkies.set(id, { url, pending, createdAt, mapId, timeout }); 1770 noteWalkie("send", mapId, { id, pendingCount: pending.size }); 1771 1772 api.sendToUsers(usersInRoom(mapId), { type: "plugin:maps:walkie", mapId, id, username, url, x, y, createdAt }); 1773 1774 }); 1775 1776 api.registerWs("walkiePlayed", (ws, msg) => { 1777 const username = userIdentity(ws); 1778 if (!username) return; 1779 const mapId = normId(ws.__mapsRoomId || ""); 1780 if (!mapId) return; 1781 const room = rooms.get(mapId); 1782 if (!room || !room.walkies) return; 1783 const id = typeof msg?.id === "string" ? msg.id.trim() : ""; 1784 if (!id) return; 1785 const entry = room.walkies.get(id); 1786 if (!entry) return; 1787 if (!entry.pending.has(username)) { 1788 noteWalkie("ack-duplicate", mapId, { id, username, reason: String(msg?.reason || "") }); 1789 return; 1790 } 1791 entry.pending.delete(username); 1792 noteWalkie("ack", mapId, { id, username, pendingCount: entry.pending.size, reason: String(msg?.reason || "") }); 1793 if (entry.pending.size === 0) { 1794 cleanupWalkieEntry(room, id, "cleanup-all-acked", {}); 1795 } 1796 }); 1797 1798 api.registerWs("walkieState", (ws, msg) => { 1799 const username = userIdentity(ws); 1800 if (!username) return; 1801 const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); 1802 if (!mapId) return; 1803 const phaseRaw = typeof msg?.phase === "string" ? msg.phase.trim().toLowerCase() : ""; 1804 const phase = /^[a-z_]{2,24}$/.test(phaseRaw) ? phaseRaw : "unknown"; 1805 const id = typeof msg?.id === "string" ? msg.id.trim().slice(0, 120) : ""; 1806 const attempt = clampInt(msg?.attempt, 0, 10); 1807 const error = typeof msg?.error === "string" ? msg.error.trim().slice(0, 120) : ""; 1808 noteWalkie(`client-${phase}`, mapId, { username, id, attempt, error }); 1809 }); 1810 };