bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

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 };