bzl

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

server.js (39619B)


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