bzl

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

commit e7e738f65a31f64a48833d9e1fc7a76de533fc82
parent d7da1e814ad00534fa0949103e0e6ba72599eef8
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date:   Thu, 19 Feb 2026 12:49:18 -0700

Merge branch 'main' of https://github.com/bzlapp/Bzl

Diffstat:
Ddata.bak.20260219-051337/.gitkeep | 1-
Ddata.bak.20260219-051337/.plugins.json.tmp_13208_1771096919022 | 9---------
Ddata.bak.20260219-051337/.plugins.json.tmp_13208_1771096919026 | 9---------
Ddata.bak.20260219-051337/.plugins.json.tmp_13208_1771096919029 | 9---------
Ddata.bak.20260219-051337/.plugins.json.tmp_13440_1771229475977 | 13-------------
Ddata.bak.20260219-051337/.plugins.json.tmp_13440_1771229497028 | 0
Ddata.bak.20260219-051337/.plugins.json.tmp_13688_1771052071734 | 9---------
Ddata.bak.20260219-051337/.plugins.json.tmp_14560_1771359310654 | 13-------------
Ddata.bak.20260219-051337/.plugins.json.tmp_2860_1771230387937 | 13-------------
Ddata.bak.20260219-051337/.plugins.json.tmp_29004_1771197707341 | 13-------------
Ddata.bak.20260219-051337/.plugins.json.tmp_30564_1771229515809 | 13-------------
Ddata.bak.20260219-051337/.plugins.json.tmp_32372_1771027932160 | 0
Ddata.bak.20260219-051337/.plugins.json.tmp_34128_1771026368608 | 0
Ddata.bak.20260219-051337/.plugins.json.tmp_34980_1771060344882 | 0
Ddata.bak.20260219-051337/.plugins.json.tmp_37940_1771107122604 | 9---------
Ddata.bak.20260219-051337/.plugins.json.tmp_37940_1771107131963 | 9---------
Ddata.bak.20260219-051337/.plugins.json.tmp_37940_1771107133440 | 9---------
Ddata.bak.20260219-051337/.plugins.json.tmp_37940_1771107134339 | 9---------
Ddata.bak.20260219-051337/.plugins.json.tmp_37940_1771107135733 | 9---------
Ddata.bak.20260219-051337/.plugins.json.tmp_37940_1771107135964 | 9---------
Ddata.bak.20260219-051337/.plugins.json.tmp_37940_1771107137489 | 9---------
Ddata.bak.20260219-051337/.plugins.json.tmp_37940_1771107144959 | 9---------
Ddata.bak.20260219-051337/.plugins.json.tmp_39860_1771195693625 | 0
Ddata.bak.20260219-051337/.plugins.json.tmp_5332_1771115855223 | 0
Ddata.bak.20260219-051337/.plugins.json.tmp_7588_1771035362091 | 9---------
Ddata.bak.20260219-051337/collections.json | 67-------------------------------------------------------------------
Ddata.bak.20260219-051337/dm-key.txt | 1-
Ddata.bak.20260219-051337/dms.json | 4----
Ddata.bak.20260219-051337/instance.json | 20--------------------
Ddata.bak.20260219-051337/mod-log.json | 862-------------------------------------------------------------------------------
Ddata.bak.20260219-051337/plugin-data/maps.json | 1019-------------------------------------------------------------------------------
Ddata.bak.20260219-051337/plugins.json | 13-------------
Ddata.bak.20260219-051337/plugins/library/client.js | 927-------------------------------------------------------------------------------
Ddata.bak.20260219-051337/plugins/library/library.json | 39---------------------------------------
Ddata.bak.20260219-051337/plugins/library/plugin.json | 9---------
Ddata.bak.20260219-051337/plugins/library/server.js | 514-------------------------------------------------------------------------------
Ddata.bak.20260219-051337/plugins/maps/client.js | 3995-------------------------------------------------------------------------------
Ddata.bak.20260219-051337/plugins/maps/plugin.json | 9---------
Ddata.bak.20260219-051337/plugins/maps/server.js | 1177-------------------------------------------------------------------------------
Ddata.bak.20260219-051337/posts.json | 7-------
Ddata.bak.20260219-051337/reports.json | 4----
Ddata.bak.20260219-051337/roles.json | 32--------------------------------
Ddata.bak.20260219-051337/sessions.json | 141-------------------------------------------------------------------------------
Ddata.bak.20260219-051337/uploads/1770950130363-8834c8d2dcdd0d4dba86.gif | 0
Ddata.bak.20260219-051337/uploads/1771103212934-a391f54ca9f718623b24.png | 0
Ddata.bak.20260219-051337/uploads/1771112006330-2be059809a3daf517e00.png | 0
Ddata.bak.20260219-051337/uploads/1771132138431-69d9972f45f58bf88df6.png | 0
Ddata.bak.20260219-051337/uploads/1771207189932-1298f4c42c2fadfca656.png | 0
Ddata.bak.20260219-051337/uploads/1771361684484-4f732dc6356adcf10c1c.mp3 | 0
Ddata.bak.20260219-051337/uploads/1771361698390-33aa47d698c92d911a6f.mp3 | 0
Ddata.bak.20260219-051337/uploads/library/shadow-ocean.pdf-2026-02-15T23-20-21-366Z-a0cc9320.pdf | 0
Ddata.bak.20260219-051337/uploads/library/tawkys-pitch-deck-email-only.pdf-2026-02-16T00-37-25-191Z-79c29696.pdf | 0
Ddata.bak.20260219-051337/uploads/library/tmp/40070a72fefd1bad1b1a455b.part | 0
Ddata.bak.20260219-051337/uploads/library/tmp/5777b8905dab1affe9e683a5.part | 0
Ddata.bak.20260219-051337/uploads/library/tmp/8c769db5adab5550e68e539f.part | 0
Ddata.bak.20260219-051337/uploads/library/tmp/ae86e22cc1d744fb9098972b.part | 0
Ddata.bak.20260219-051337/uploads/library/tmp/cf72aef73464126acd0dda93.part | 0
Ddata.bak.20260219-051337/uploads/library/tmp/d4f2fa55f999b9636017b7c5.part | 0
Ddata.bak.20260219-051337/uploads/library/tmp/f52f36f263be11824ffd6019.part | 0
Ddata.bak.20260219-051337/uploads/library/tri-axis-of-sense.pdf-2026-02-16T00-42-32-306Z-9dbf5045.pdf | 0
Ddata.bak.20260219-051337/users.json | 183-------------------------------------------------------------------------------
61 files changed, 0 insertions(+), 9206 deletions(-)

diff --git a/data.bak.20260219-051337/.gitkeep b/data.bak.20260219-051337/.gitkeep @@ -1 +0,0 @@ - diff --git a/data.bak.20260219-051337/.plugins.json.tmp_13208_1771096919022 b/data.bak.20260219-051337/.plugins.json.tmp_13208_1771096919022 @@ -1,9 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "maps", - "enabled": true - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_13208_1771096919026 b/data.bak.20260219-051337/.plugins.json.tmp_13208_1771096919026 @@ -1,9 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "maps", - "enabled": true - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_13208_1771096919029 b/data.bak.20260219-051337/.plugins.json.tmp_13208_1771096919029 @@ -1,9 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "maps", - "enabled": true - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_13440_1771229475977 b/data.bak.20260219-051337/.plugins.json.tmp_13440_1771229475977 @@ -1,13 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "library", - "enabled": false - }, - { - "id": "maps", - "enabled": false - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_13440_1771229497028 b/data.bak.20260219-051337/.plugins.json.tmp_13440_1771229497028 diff --git a/data.bak.20260219-051337/.plugins.json.tmp_13688_1771052071734 b/data.bak.20260219-051337/.plugins.json.tmp_13688_1771052071734 @@ -1,9 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "maps", - "enabled": true - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_14560_1771359310654 b/data.bak.20260219-051337/.plugins.json.tmp_14560_1771359310654 @@ -1,13 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "library", - "enabled": false - }, - { - "id": "maps", - "enabled": true - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_2860_1771230387937 b/data.bak.20260219-051337/.plugins.json.tmp_2860_1771230387937 @@ -1,13 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "library", - "enabled": false - }, - { - "id": "maps", - "enabled": false - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_29004_1771197707341 b/data.bak.20260219-051337/.plugins.json.tmp_29004_1771197707341 @@ -1,13 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "maps", - "enabled": true - }, - { - "id": "library", - "enabled": true - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_30564_1771229515809 b/data.bak.20260219-051337/.plugins.json.tmp_30564_1771229515809 @@ -1,13 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "library", - "enabled": false - }, - { - "id": "maps", - "enabled": false - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_32372_1771027932160 b/data.bak.20260219-051337/.plugins.json.tmp_32372_1771027932160 diff --git a/data.bak.20260219-051337/.plugins.json.tmp_34128_1771026368608 b/data.bak.20260219-051337/.plugins.json.tmp_34128_1771026368608 diff --git a/data.bak.20260219-051337/.plugins.json.tmp_34980_1771060344882 b/data.bak.20260219-051337/.plugins.json.tmp_34980_1771060344882 diff --git a/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107122604 b/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107122604 @@ -1,9 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "maps", - "enabled": false - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107131963 b/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107131963 @@ -1,9 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "maps", - "enabled": false - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107133440 b/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107133440 @@ -1,9 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "maps", - "enabled": false - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107134339 b/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107134339 @@ -1,9 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "maps", - "enabled": false - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107135733 b/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107135733 @@ -1,9 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "maps", - "enabled": false - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107135964 b/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107135964 @@ -1,9 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "maps", - "enabled": false - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107137489 b/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107137489 @@ -1,9 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "maps", - "enabled": false - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107144959 b/data.bak.20260219-051337/.plugins.json.tmp_37940_1771107144959 @@ -1,9 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "maps", - "enabled": false - } - ] -} diff --git a/data.bak.20260219-051337/.plugins.json.tmp_39860_1771195693625 b/data.bak.20260219-051337/.plugins.json.tmp_39860_1771195693625 diff --git a/data.bak.20260219-051337/.plugins.json.tmp_5332_1771115855223 b/data.bak.20260219-051337/.plugins.json.tmp_5332_1771115855223 diff --git a/data.bak.20260219-051337/.plugins.json.tmp_7588_1771035362091 b/data.bak.20260219-051337/.plugins.json.tmp_7588_1771035362091 @@ -1,9 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "maps", - "enabled": true - } - ] -} diff --git a/data.bak.20260219-051337/collections.json b/data.bak.20260219-051337/collections.json @@ -1,67 +0,0 @@ -{ - "version": 1, - "collections": [ - { - "id": "general", - "name": "General", - "slug": "general", - "description": "", - "createdBy": "system", - "createdAt": 1770962715037, - "order": 0, - "visibility": "public", - "allowedRoles": [], - "archived": false - }, - { - "id": "8425a482-6efe-47c5-9afc-c6ec7a1e4bda", - "name": "music", - "slug": "music", - "description": "", - "createdBy": "azakaela", - "createdAt": 1770944001241, - "order": 1, - "visibility": "public", - "allowedRoles": [], - "archived": true - }, - { - "id": "ea98ea71-1ad5-4280-a531-d3563f4579a3", - "name": "zomboid", - "slug": "zomboid", - "description": "", - "createdBy": "azakaela", - "createdAt": 1770963222513, - "order": 2, - "visibility": "gated", - "allowedRoles": [ - "moderator" - ], - "archived": true - }, - { - "id": "f6f40eb9-deb2-4e8f-a724-ac06e015347b", - "name": "RULES", - "slug": "rules", - "description": "", - "createdBy": "azakaela", - "createdAt": 1771107891479, - "order": 3, - "visibility": "public", - "allowedRoles": [], - "archived": true - }, - { - "id": "a946af97-c5be-4ab4-927a-799da9796a04", - "name": "Books", - "slug": "books", - "description": "", - "createdBy": "brookemmmz", - "createdAt": 1771113548304, - "order": 4, - "visibility": "public", - "allowedRoles": [], - "archived": true - } - ] -} diff --git a/data.bak.20260219-051337/dm-key.txt b/data.bak.20260219-051337/dm-key.txt @@ -1 +0,0 @@ -O1p9KPcZdwr7Cmo3OIz/yVNpcE5MQWOq5xyiG6tpEBQ= diff --git a/data.bak.20260219-051337/dms.json b/data.bak.20260219-051337/dms.json @@ -1,4 +0,0 @@ -{ - "version": 1, - "threads": [] -} diff --git a/data.bak.20260219-051337/instance.json b/data.bak.20260219-051337/instance.json @@ -1,20 +0,0 @@ -{ - "version": 1, - "title": "Temple of the Unfolding", - "subtitle": "Iihn Aash Zulok", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#060611", - "panel": "#0c0c18", - "text": "#f6f0ff", - "accent": "#ff3ea5", - "accent2": "#b84bff", - "good": "#3ddc97", - "bad": "#ff4d8a", - "fontBody": "system", - "fontMono": "mono", - "mutedPct": 65, - "linePct": 10, - "panel2Pct": 2 - } -} diff --git a/data.bak.20260219-051337/mod-log.json b/data.bak.20260219-051337/mod-log.json @@ -1,862 +0,0 @@ -{ - "version": 1, - "entries": [ - { - "id": "34925ec1-e101-45d1-8158-3b256c2ff207", - "actionType": "post_erase", - "actor": "azakaela", - "targetType": "post", - "targetId": "a4359e7f-4c6c-402d-8f16-1cc5830a6379", - "reason": "because i can goddamnit", - "metadata": { - "beforePreview": "Post was deleted", - "deletedUploads": 0, - "erasedAt": 1771383895387 - }, - "createdAt": 1771383895387 - }, - { - "id": "5ee30c0f-c8b2-41a5-99d1-037f0029175d", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "Iihn Aash Zulok", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#060611", - "panel": "#0c0c18", - "text": "#f6f0ff", - "accent": "#ff3ea5", - "accent2": "#b84bff", - "good": "#3ddc97", - "bad": "#ff4d8a", - "fontBody": "system", - "fontMono": "mono", - "mutedPct": 65, - "linePct": 10, - "panel2Pct": 2 - } - }, - "createdAt": 1771383801645 - }, - { - "id": "f4cba8bd-5ac4-4788-8696-092308d7adf0", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "Iihn Aash Zulok", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#000000", - "panel": "#0a0a0a", - "text": "#ffffff", - "accent": "#ffd300", - "accent2": "#00d3ff", - "good": "#00ff85", - "bad": "#ff2d55", - "fontBody": "system", - "fontMono": "mono", - "mutedPct": 70, - "linePct": 16, - "panel2Pct": 3 - } - }, - "createdAt": 1771383744236 - }, - { - "id": "63acdf14-9606-4b4d-8ecd-668d08bb6ff1", - "actionType": "self_post_delete", - "actor": "azakaela", - "targetType": "post", - "targetId": "a4359e7f-4c6c-402d-8f16-1cc5830a6379", - "reason": "Author deleted their post.", - "metadata": { - "beforePreview": "This is a post about nothing", - "deletedAt": 1771383714779 - }, - "createdAt": 1771383714779 - }, - { - "id": "24f57e16-4741-4021-b9ce-8acae79a0f7c", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "Iihn Aash Zulok", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#060a12", - "panel": "#0a1220", - "text": "#eaf4ff", - "accent": "#2bf5d6", - "accent2": "#4aa0ff", - "good": "#2bf5d6", - "bad": "#ff4d8a", - "fontBody": "system", - "fontMono": "mono", - "mutedPct": 64, - "linePct": 10, - "panel2Pct": 2 - } - }, - "createdAt": 1771379779153 - }, - { - "id": "9ca15ea8-ee2a-4272-82ce-6fa14301dcd5", - "actionType": "post_erase", - "actor": "azakaela", - "targetType": "post", - "targetId": "c97ab995-7add-4075-b96f-e9f70da16219", - "reason": "test erase", - "metadata": { - "beforePreview": "Hello hello!", - "deletedUploads": 0, - "erasedAt": 1771379567684 - }, - "createdAt": 1771379567684 - }, - { - "id": "8d060113-5435-4e0c-882b-af8da2c28764", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "Iihn Aash Zulok", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#141414", - "panel": "#262626", - "text": "#fff2ea", - "accent": "#545454", - "accent2": "#dedede", - "good": "#3da7db", - "bad": "#ff4d4d", - "fontBody": "mono", - "fontMono": "mono", - "mutedPct": 66, - "linePct": 100, - "panel2Pct": 33 - } - }, - "createdAt": 1771379528954 - }, - { - "id": "a7f0fd6a-e256-4fc2-b872-b5241a27eb28", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#141414", - "panel": "#262626", - "text": "#fff2ea", - "accent": "#545454", - "accent2": "#dedede", - "good": "#3da7db", - "bad": "#ff4d4d", - "fontBody": "mono", - "fontMono": "mono", - "mutedPct": 66, - "linePct": 100, - "panel2Pct": 33 - } - }, - "createdAt": 1771379492821 - }, - { - "id": "fd6872a5-3668-446d-8820-6d71267823f4", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#141414", - "panel": "#262626", - "text": "#fff2ea", - "accent": "#545454", - "accent2": "#dedede", - "good": "#3da7db", - "bad": "#ff4d4d", - "fontBody": "serif", - "fontMono": "system", - "mutedPct": 66, - "linePct": 100, - "panel2Pct": 33 - } - }, - "createdAt": 1771379485272 - }, - { - "id": "be84ded2-26fd-4ca2-8633-4dc815bf9343", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#141414", - "panel": "#262626", - "text": "#fff2ea", - "accent": "#545454", - "accent2": "#dedede", - "good": "#3da7db", - "bad": "#ff4d4d", - "fontBody": "serif", - "fontMono": "mono", - "mutedPct": 66, - "linePct": 100, - "panel2Pct": 33 - } - }, - "createdAt": 1771379469028 - }, - { - "id": "cc38bd0b-87dc-4d5f-aeda-834dd54dcc30", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#141414", - "panel": "#262626", - "text": "#fff2ea", - "accent": "#545454", - "accent2": "#dedede", - "good": "#3da7db", - "bad": "#ff4d4d", - "fontBody": "serif", - "fontMono": "mono", - "mutedPct": 66, - "linePct": 11, - "panel2Pct": 3 - } - }, - "createdAt": 1771379432494 - }, - { - "id": "c38b6b28-7d5d-416d-98c5-3c229498ca93", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#141414", - "panel": "#262626", - "text": "#fff2ea", - "accent": "#545454", - "accent2": "#dedede", - "good": "#3ddc97", - "bad": "#ff4d8a", - "fontBody": "serif", - "fontMono": "mono", - "mutedPct": 66, - "linePct": 11, - "panel2Pct": 3 - } - }, - "createdAt": 1771379415055 - }, - { - "id": "b10036cd-cabf-457e-a8d7-6386f8c39945", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#141414", - "panel": "#262626", - "text": "#fff2ea", - "accent": "#ffb020", - "accent2": "#ff3ea5", - "good": "#3ddc97", - "bad": "#ff4d8a", - "fontBody": "serif", - "fontMono": "mono", - "mutedPct": 66, - "linePct": 11, - "panel2Pct": 3 - } - }, - "createdAt": 1771379403522 - }, - { - "id": "602e1b80-fc44-4c5a-b468-c2e2370489d4", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#141414", - "panel": "#787878", - "text": "#fff2ea", - "accent": "#ffb020", - "accent2": "#ff3ea5", - "good": "#3ddc97", - "bad": "#ff4d8a", - "fontBody": "serif", - "fontMono": "mono", - "mutedPct": 66, - "linePct": 11, - "panel2Pct": 3 - } - }, - "createdAt": 1771379398435 - }, - { - "id": "928f981e-9bac-4849-b084-f5489d7ed4cd", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#141414", - "panel": "#2e0022", - "text": "#fff2ea", - "accent": "#ffb020", - "accent2": "#ff3ea5", - "good": "#3ddc97", - "bad": "#ff4d8a", - "fontBody": "serif", - "fontMono": "mono", - "mutedPct": 66, - "linePct": 11, - "panel2Pct": 3 - } - }, - "createdAt": 1771379390892 - }, - { - "id": "447c5fd5-f839-4496-a42d-9d8fba9ed0ad", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#141414", - "panel": "#160e14", - "text": "#fff2ea", - "accent": "#ffb020", - "accent2": "#ff3ea5", - "good": "#3ddc97", - "bad": "#ff4d8a", - "fontBody": "serif", - "fontMono": "mono", - "mutedPct": 66, - "linePct": 11, - "panel2Pct": 3 - } - }, - "createdAt": 1771379386877 - }, - { - "id": "23d0a5be-acd7-405e-bdf1-651e557e1954", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#383838", - "panel": "#160e14", - "text": "#fff2ea", - "accent": "#ffb020", - "accent2": "#ff3ea5", - "good": "#3ddc97", - "bad": "#ff4d8a", - "fontBody": "serif", - "fontMono": "mono", - "mutedPct": 66, - "linePct": 11, - "panel2Pct": 3 - } - }, - "createdAt": 1771379379132 - }, - { - "id": "001510b2-c109-464f-a82a-a5d61609fe6b", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "Temple of the Unfolding", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#0d0707", - "panel": "#160e14", - "text": "#fff2ea", - "accent": "#ffb020", - "accent2": "#ff3ea5", - "good": "#3ddc97", - "bad": "#ff4d8a", - "fontBody": "serif", - "fontMono": "mono", - "mutedPct": 66, - "linePct": 11, - "panel2Pct": 3 - } - }, - "createdAt": 1771379372975 - }, - { - "id": "56e2104a-585d-4393-b0bc-df4040445144", - "actionType": "collection_archive", - "actor": "azakaela", - "targetType": "system", - "targetId": "f6f40eb9-deb2-4e8f-a724-ac06e015347b", - "reason": "Archived collection", - "metadata": { - "collectionId": "f6f40eb9-deb2-4e8f-a724-ac06e015347b" - }, - "createdAt": 1771185394928 - }, - { - "id": "7fd2825e-95b4-49c1-bbb4-2cfe22369b10", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "AZA'S STUDIO", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#060a12", - "panel": "#0a1220", - "text": "#eaf4ff", - "accent": "#2bf5d6", - "accent2": "#4aa0ff", - "good": "#2bf5d6", - "bad": "#ff4d8a", - "fontBody": "system", - "fontMono": "mono", - "mutedPct": 64, - "linePct": 10, - "panel2Pct": 2 - } - }, - "createdAt": 1771185348005 - }, - { - "id": "e312a61b-cf95-4294-9cd6-20bc6c3861bf", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "AZA'S STUDIO", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#5b7661", - "panel": "#454545", - "text": "#d7ffe6", - "accent": "#ffffff", - "accent2": "#c2ffd4", - "good": "#2bff88", - "bad": "#ff4d8a", - "fontBody": "mono", - "fontMono": "mono", - "mutedPct": 58, - "linePct": 12, - "panel2Pct": 2 - } - }, - "createdAt": 1771185339023 - }, - { - "id": "50b83632-7be0-4b74-ae56-36f9a1b4d735", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "AZA'S STUDIO", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#5b7661", - "panel": "#454545", - "text": "#d7ffe6", - "accent": "#ffffff", - "accent2": "#1fff62", - "good": "#2bff88", - "bad": "#ff4d8a", - "fontBody": "mono", - "fontMono": "mono", - "mutedPct": 58, - "linePct": 12, - "panel2Pct": 2 - } - }, - "createdAt": 1771185335325 - }, - { - "id": "eca1ccc8-dabb-4162-ad18-b9ad149eebd7", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "AZA'S STUDIO", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#5b7661", - "panel": "#454545", - "text": "#d7ffe6", - "accent": "#fff829", - "accent2": "#1fff62", - "good": "#2bff88", - "bad": "#ff4d8a", - "fontBody": "mono", - "fontMono": "mono", - "mutedPct": 58, - "linePct": 12, - "panel2Pct": 2 - } - }, - "createdAt": 1771185330509 - }, - { - "id": "42a24c03-f06e-4d4c-9779-772afc5faa7a", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "AZA'S STUDIO", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#5b7661", - "panel": "#454545", - "text": "#d7ffe6", - "accent": "#fff829", - "accent2": "#20d3ff", - "good": "#2bff88", - "bad": "#ff4d8a", - "fontBody": "mono", - "fontMono": "mono", - "mutedPct": 58, - "linePct": 12, - "panel2Pct": 2 - } - }, - "createdAt": 1771185324864 - }, - { - "id": "be0d9b5c-8162-4015-bfc9-2051e03d986f", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "AZA'S STUDIO", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#5b7661", - "panel": "#454545", - "text": "#d7ffe6", - "accent": "#2bff88", - "accent2": "#20d3ff", - "good": "#2bff88", - "bad": "#ff4d8a", - "fontBody": "mono", - "fontMono": "mono", - "mutedPct": 58, - "linePct": 12, - "panel2Pct": 2 - } - }, - "createdAt": 1771185304500 - }, - { - "id": "c373f7e6-f838-43d2-bf22-9f85e2f976bc", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "AZA'S STUDIO", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#c5f7d1", - "panel": "#454545", - "text": "#d7ffe6", - "accent": "#2bff88", - "accent2": "#20d3ff", - "good": "#2bff88", - "bad": "#ff4d8a", - "fontBody": "mono", - "fontMono": "mono", - "mutedPct": 58, - "linePct": 12, - "panel2Pct": 2 - } - }, - "createdAt": 1771185298951 - }, - { - "id": "c414ce88-d77b-47d2-897b-a9c08b666645", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "AZA'S STUDIO", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#c5f7d1", - "panel": "#777977", - "text": "#d7ffe6", - "accent": "#2bff88", - "accent2": "#20d3ff", - "good": "#2bff88", - "bad": "#ff4d8a", - "fontBody": "mono", - "fontMono": "mono", - "mutedPct": 58, - "linePct": 12, - "panel2Pct": 2 - } - }, - "createdAt": 1771185294569 - }, - { - "id": "656d776a-7355-42aa-a015-0fadb31c8bdf", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "AZA'S STUDIO", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#c5f7d1", - "panel": "#909891", - "text": "#d7ffe6", - "accent": "#2bff88", - "accent2": "#20d3ff", - "good": "#2bff88", - "bad": "#ff4d8a", - "fontBody": "mono", - "fontMono": "mono", - "mutedPct": 58, - "linePct": 12, - "panel2Pct": 2 - } - }, - "createdAt": 1771185288409 - }, - { - "id": "1e6e7574-2997-4446-81ae-69e7d13d027f", - "actionType": "instance_branding_set", - "actor": "azakaela", - "targetType": "system", - "targetId": "instance", - "reason": "Updated instance branding", - "metadata": { - "title": "AZA'S STUDIO", - "subtitle": "uwu?", - "allowMemberPermanentPosts": true, - "appearance": { - "bg": "#c5f7d1", - "panel": "#070f08", - "text": "#d7ffe6", - "accent": "#2bff88", - "accent2": "#20d3ff", - "good": "#2bff88", - "bad": "#ff4d8a", - "fontBody": "mono", - "fontMono": "mono", - "mutedPct": 58, - "linePct": 12, - "panel2Pct": 2 - } - }, - "createdAt": 1771185280330 - }, - { - "id": "677fa9df-97f4-4ec3-8a96-750f09700031", - "actionType": "custom_role_archive", - "actor": "azakaela", - "targetType": "system", - "targetId": "readers", - "reason": "Archived custom role", - "metadata": { - "key": "readers" - }, - "createdAt": 1771185217553 - }, - { - "id": "1a4bceb9-2f19-4d91-a436-53043d3fdadf", - "actionType": "user_unban", - "actor": "azakaela", - "targetType": "user", - "targetId": "brookemmmz", - "reason": "test unban", - "metadata": {}, - "createdAt": 1771185206754 - }, - { - "id": "ffa44e7c-585e-44e4-a312-8658d422731f", - "actionType": "user_ban", - "actor": "azakaela", - "targetType": "user", - "targetId": "brookemmmz", - "reason": "test ban", - "metadata": {}, - "createdAt": 1771185201064 - }, - { - "id": "0f8627d5-4e8e-4d01-accc-8eef9d138cda", - "actionType": "user_unsuspend", - "actor": "azakaela", - "targetType": "user", - "targetId": "brookemmmz", - "reason": "test unsuspend", - "metadata": {}, - "createdAt": 1771185190599 - }, - { - "id": "cd8f10b0-b01e-4311-a5c7-5f225b0a2e5d", - "actionType": "user_suspend", - "actor": "azakaela", - "targetType": "user", - "targetId": "brookemmmz", - "reason": "test suspend", - "metadata": { - "minutes": 120 - }, - "createdAt": 1771185181545 - }, - { - "id": "cc1951f4-d7d8-4241-ac70-0f92bb6d775e", - "actionType": "user_role_set", - "actor": "azakaela", - "targetType": "user", - "targetId": "dokkaecat", - "reason": "testing mod removal", - "metadata": { - "role": "member" - }, - "createdAt": 1771185111556 - }, - { - "id": "9ca00c34-316d-484d-af23-88922f258f39", - "actionType": "user_role_set", - "actor": "azakaela", - "targetType": "user", - "targetId": "dokkaecat", - "reason": "testing mod granting", - "metadata": { - "role": "moderator" - }, - "createdAt": 1771185103364 - }, - { - "id": "be715560-4a9b-441a-b986-332749106e4f", - "actionType": "post_erase", - "actor": "azakaela", - "targetType": "post", - "targetId": "d9333af4-40f0-499b-b1d8-8037d23c767d", - "reason": "clearing things", - "metadata": { - "beforePreview": "BOO!", - "deletedUploads": 0, - "erasedAt": 1771185057993 - }, - "createdAt": 1771185057993 - }, - { - "id": "bcfebcc1-e688-4d46-958b-1460cc1d11de", - "actionType": "collection_archive", - "actor": "azakaela", - "targetType": "system", - "targetId": "a946af97-c5be-4ab4-927a-799da9796a04", - "reason": "Archived collection", - "metadata": { - "collectionId": "a946af97-c5be-4ab4-927a-799da9796a04" - }, - "createdAt": 1771114198902 - } - ] -} diff --git a/data.bak.20260219-051337/plugin-data/maps.json b/data.bak.20260219-051337/plugin-data/maps.json @@ -1,1018 +0,0 @@ -{ - "maps": [ - { - "id": "fountain", - "title": "Fountain", - "owner": "azakaela", - "backgroundUrl": "/uploads/1771103212934-a391f54ca9f718623b24.png", - "thumbUrl": "/uploads/1771103212934-a391f54ca9f718623b24.png", - "world": null, - "avatarSize": 40, - "cameraZoom": 1, - "collisions": [ - { - "points": [ - { - "x": 0.0006613756613756613, - "y": 0.4498789621386524 - }, - { - "x": 0.20006613756613756, - "y": 0.4492175864772768 - }, - { - "x": 0.19907407407407404, - "y": 0.40225991451960474 - }, - { - "x": 0.18253968253968253, - "y": 0.4002757875354778 - }, - { - "x": 0.1832010582010582, - "y": 0.38440277166246195 - }, - { - "x": 0.2156084656084656, - "y": 0.38440277166246195 - }, - { - "x": 0.2156084656084656, - "y": 0.3648921896518799 - }, - { - "x": 0.24900793650793648, - "y": 0.36588425314394346 - }, - { - "x": 0.24867724867724866, - "y": 0.34901917377886404 - }, - { - "x": 0.28273809523809523, - "y": 0.35001123727092753 - }, - { - "x": 0.30257936507936506, - "y": 0.3503419251016154 - }, - { - "x": 0.2986111111111111, - "y": 0.29908531134500166 - }, - { - "x": 0.28439153439153436, - "y": 0.29908531134500166 - }, - { - "x": 0.28273809523809523, - "y": 0.3152890150487053 - }, - { - "x": 0.25165343915343913, - "y": 0.31859589335558364 - }, - { - "x": 0.24834656084656082, - "y": 0.33215409441378474 - }, - { - "x": 0.2175925925925926, - "y": 0.3328154700751603 - }, - { - "x": 0.21626984126984125, - "y": 0.351003300762991 - }, - { - "x": 0.0013227513227513227, - "y": 0.35001123727092753 - } - ] - }, - { - "points": [ - { - "x": 0.0006613756613756613, - "y": 0.4998128245725149 - }, - { - "x": 0.09953703703703702, - "y": 0.5008048880645783 - }, - { - "x": 0.10019841269841269, - "y": 0.4660826658423561 - }, - { - "x": 0.21428571428571427, - "y": 0.4680667928264831 - }, - { - "x": 0.21626984126984125, - "y": 0.4009371631968535 - }, - { - "x": 0.24702380952380953, - "y": 0.4012678510275413 - }, - { - "x": 0.24768518518518515, - "y": 0.3840720838317741 - }, - { - "x": 0.28075396825396826, - "y": 0.38274933250902277 - }, - { - "x": 0.28406084656084657, - "y": 0.5851302848899752 - }, - { - "x": 0.3806216931216931, - "y": 0.5841382213979116 - }, - { - "x": 0.3826058201058201, - "y": 0.5497466870063773 - }, - { - "x": 0.36706349206349204, - "y": 0.548423935683626 - }, - { - "x": 0.3673941798941799, - "y": 0.5328816076412979 - }, - { - "x": 0.3498677248677249, - "y": 0.5328816076412979 - }, - { - "x": 0.3498677248677249, - "y": 0.5163472161069064 - }, - { - "x": 0.3323412698412698, - "y": 0.5163472161069064 - }, - { - "x": 0.3316798941798942, - "y": 0.5004742002338906 - }, - { - "x": 0.31613756613756616, - "y": 0.5004742002338906 - }, - { - "x": 0.31679894179894175, - "y": 0.3834107081703984 - }, - { - "x": 0.3657407407407407, - "y": 0.3863868986465889 - }, - { - "x": 0.3657407407407407, - "y": 0.39994509970479 - }, - { - "x": 0.38293650793650796, - "y": 0.39994509970479 - }, - { - "x": 0.38326719576719576, - "y": 0.38374139600108625 - }, - { - "x": 0.4183201058201058, - "y": 0.3850641473238376 - }, - { - "x": 0.4169973544973545, - "y": 0.5173392795989699 - }, - { - "x": 0.4318783068783069, - "y": 0.5160165282762186 - }, - { - "x": 0.4328703703703703, - "y": 0.5335429833026736 - }, - { - "x": 0.4497354497354497, - "y": 0.5368498616095518 - }, - { - "x": 0.45171957671957674, - "y": 0.5514001261598165 - }, - { - "x": 0.466931216931217, - "y": 0.5514001261598165 - }, - { - "x": 0.46494708994708994, - "y": 0.6314265811862714 - }, - { - "x": 0.4474206349206349, - "y": 0.634072083831774 - }, - { - "x": 0.3859126984126984, - "y": 0.632418644678335 - }, - { - "x": 0.3842592592592592, - "y": 0.5976964224561128 - }, - { - "x": 0.2824074074074074, - "y": 0.5993498616095518 - }, - { - "x": 0.0023148148148148147, - "y": 0.6016646764243666 - } - ] - }, - { - "points": [ - { - "x": 0.3177910052910053, - "y": 0.6320879568476471 - }, - { - "x": 0.36607142857142855, - "y": 0.6330800203397107 - }, - { - "x": 0.3657407407407407, - "y": 0.6506064753661657 - }, - { - "x": 0.29927248677248675, - "y": 0.6486223483820387 - }, - { - "x": 0.29927248677248675, - "y": 0.6320879568476471 - }, - { - "x": 0.27810846560846564, - "y": 0.6301038298635201 - }, - { - "x": 0.2787698412698412, - "y": 0.651598538858229 - }, - { - "x": 0.38326719576719576, - "y": 0.6512678510275414 - }, - { - "x": 0.38095238095238093, - "y": 0.6000112372709275 - }, - { - "x": 0.2810846560846561, - "y": 0.6023260520857423 - }, - { - "x": 0.27744708994708994, - "y": 0.6261355758952662 - }, - { - "x": 0.29927248677248675, - "y": 0.6297731420328323 - }, - { - "x": 0.2996031746031746, - "y": 0.6152228774825678 - }, - { - "x": 0.3177910052910053, - "y": 0.6155535653132556 - } - ] - }, - { - "points": [ - { - "x": 0.3501984126984127, - "y": 0.3318234065830969 - }, - { - "x": 0.3498677248677249, - "y": 0.29941599917568945 - }, - { - "x": 0.46494708994708994, - "y": 0.3023921896518799 - }, - { - "x": 0.46494708994708994, - "y": 0.2858577981174884 - }, - { - "x": 0.5142195767195767, - "y": 0.2858577981174884 - }, - { - "x": 0.5162037037037036, - "y": 0.2997466870063773 - }, - { - "x": 0.533068783068783, - "y": 0.3023921896518799 - }, - { - "x": 0.5340608465608465, - "y": 0.3328154700751603 - } - ] - }, - { - "points": [ - { - "x": 0.4662698412698413, - "y": 0.41681017906986934 - }, - { - "x": 0.4646164021164021, - "y": 0.33050065526034555 - }, - { - "x": 0.5158730158730158, - "y": 0.3308313430910334 - }, - { - "x": 0.5185185185185185, - "y": 0.41614880340849364 - } - ] - }, - { - "points": [ - { - "x": 0.43331339014984516, - "y": 0.41571852232696005 - }, - { - "x": 0.43331339014984516, - "y": 0.38430317841161615 - }, - { - "x": 0.4187631255995806, - "y": 0.3839724905809283 - }, - { - "x": 0.41578693512339016, - "y": 0.4153878344962722 - } - ] - }, - { - "points": [ - { - "x": 0.551038257874713, - "y": 0.4147264588348965 - }, - { - "x": 0.551038257874713, - "y": 0.38132698793542563 - }, - { - "x": 0.5986573054937605, - "y": 0.38198836359680133 - }, - { - "x": 0.599649368985824, - "y": 0.41406508317352086 - }, - { - "x": 0.5983266176630727, - "y": 0.5833772524856902 - }, - { - "x": 0.5675726494091043, - "y": 0.5833772524856902 - }, - { - "x": 0.5679033372397922, - "y": 0.41604921015764784 - } - ] - }, - { - "points": [ - { - "x": 0.5176387869752419, - "y": 0.6336418027502404 - }, - { - "x": 0.513670533006988, - "y": 0.5506391572475949 - }, - { - "x": 0.5328504271868821, - "y": 0.5496470937555314 - }, - { - "x": 0.5335118028482578, - "y": 0.5347661413745791 - }, - { - "x": 0.5467393160757711, - "y": 0.5344354535438912 - }, - { - "x": 0.550707570044025, - "y": 0.5175703741788118 - }, - { - "x": 0.5642657711022261, - "y": 0.5172396863481241 - }, - { - "x": 0.570218152054607, - "y": 0.632649739258177 - } - ] - }, - { - "points": [ - { - "x": 0.535165242001697, - "y": 0.3327158768243145 - }, - { - "x": 0.6300726494091043, - "y": 0.3313931255015632 - }, - { - "x": 0.6310647129011678, - "y": 0.34693545354389127 - }, - { - "x": 0.6353636547001097, - "y": 0.34693545354389127 - }, - { - "x": 0.6360250303614853, - "y": 0.30063915724759493 - }, - { - "x": 0.5298742367106917, - "y": 0.2999777815862193 - } - ] - }, - { - "points": [ - { - "x": 0.6823213266577817, - "y": 0.33172381333225104 - }, - { - "x": 0.6806678875043425, - "y": 0.2999777815862193 - }, - { - "x": 0.6988557181921732, - "y": 0.2999777815862193 - }, - { - "x": 0.7018319086683636, - "y": 0.31585079745923517 - }, - { - "x": 0.7315938134302684, - "y": 0.31816561227405 - }, - { - "x": 0.7329165647530197, - "y": 0.33172381333225104 - }, - { - "x": 0.7597022790387341, - "y": 0.3330465646550024 - }, - { - "x": 0.7587102155466706, - "y": 0.36545397206240976 - }, - { - "x": 0.7339086282450833, - "y": 0.3651232842317219 - }, - { - "x": 0.7315938134302684, - "y": 0.3492502683587061 - }, - { - "x": 0.7008398451763002, - "y": 0.34891958052801825 - }, - { - "x": 0.6988557181921732, - "y": 0.333707940316378 - } - ] - }, - { - "points": [ - { - "x": 0.5989879933244483, - "y": 0.40116825777669546 - }, - { - "x": 0.6181678875043425, - "y": 0.40182963343807115 - }, - { - "x": 0.6175065118429668, - "y": 0.38364180275024046 - }, - { - "x": 0.6667789986154536, - "y": 0.3829804270888648 - }, - { - "x": 0.6674403742768292, - "y": 0.499713231321669 - }, - { - "x": 0.6495832314196864, - "y": 0.49905185566029336 - }, - { - "x": 0.650244607081062, - "y": 0.5159169350253727 - }, - { - "x": 0.632718152054607, - "y": 0.5155862471946848 - }, - { - "x": 0.6330488398852949, - "y": 0.5317899508983887 - }, - { - "x": 0.6165144483509033, - "y": 0.5321206387290764 - }, - { - "x": 0.6168451361815912, - "y": 0.5496470937555314 - }, - { - "x": 0.5979959298323848, - "y": 0.5483243424327802 - } - ] - }, - { - "points": [ - { - "x": 0.6995170938535489, - "y": 0.5833772524856902 - }, - { - "x": 0.7005091573456124, - "y": 0.3816576757661135 - }, - { - "x": 0.7315938134302684, - "y": 0.38364180275024046 - }, - { - "x": 0.7345700039064588, - "y": 0.39984550645394423 - }, - { - "x": 0.7590409033773584, - "y": 0.4014989456073833 - }, - { - "x": 0.7590409033773584, - "y": 0.5866841307925685 - } - ] - } - ], - "masks": [ - { - "points": [ - { - "x": 0.41909381343026847, - "y": 0.5069883635968013 - }, - { - "x": 0.4306678875043425, - "y": 0.5089724905809283 - }, - { - "x": 0.42934513618159115, - "y": 0.49309947470791243 - }, - { - "x": 0.4306678875043425, - "y": 0.4854936546020923 - }, - { - "x": 0.41909381343026847, - "y": 0.4848322789407166 - }, - { - "x": 0.41942450126095626, - "y": 0.49309947470791243 - }, - { - "x": 0.42207000390645893, - "y": 0.49442222603066377 - } - ] - }, - { - "points": [ - { - "x": 0.43512555946201453, - "y": 0.5243428609512987 - }, - { - "x": 0.44669963353608855, - "y": 0.5263269879354256 - }, - { - "x": 0.4453768822133372, - "y": 0.5104539720624097 - }, - { - "x": 0.44669963353608855, - "y": 0.5028481519565896 - }, - { - "x": 0.43512555946201453, - "y": 0.5021867762952139 - }, - { - "x": 0.4354562472927023, - "y": 0.5104539720624097 - }, - { - "x": 0.438101749938205, - "y": 0.5117767233851611 - } - ] - }, - { - "points": [ - { - "x": 0.4518186811551362, - "y": 0.5390518556602935 - }, - { - "x": 0.4633927552292102, - "y": 0.5410359826444204 - }, - { - "x": 0.46207000390645886, - "y": 0.5251629667714045 - }, - { - "x": 0.4633927552292102, - "y": 0.5175571466655844 - }, - { - "x": 0.4518186811551362, - "y": 0.5168957710042087 - }, - { - "x": 0.452149368985824, - "y": 0.5251629667714045 - }, - { - "x": 0.45479487163132665, - "y": 0.5264857180941559 - } - ] - }, - { - "points": [ - { - "x": 0.5176255594620145, - "y": 0.539713231321669 - }, - { - "x": 0.5291996335360886, - "y": 0.541697358305796 - }, - { - "x": 0.5278768822133373, - "y": 0.5258243424327801 - }, - { - "x": 0.5291996335360886, - "y": 0.51821852232696 - }, - { - "x": 0.5176255594620145, - "y": 0.5175571466655843 - }, - { - "x": 0.5179562472927024, - "y": 0.5258243424327801 - }, - { - "x": 0.520601749938205, - "y": 0.5271470937555315 - } - ] - }, - { - "points": [ - { - "x": 0.5341599509964061, - "y": 0.5228481519565897 - }, - { - "x": 0.5457340250704801, - "y": 0.5248322789407167 - }, - { - "x": 0.5444112737477287, - "y": 0.5089592630677008 - }, - { - "x": 0.5457340250704801, - "y": 0.5013534429618807 - }, - { - "x": 0.5341599509964061, - "y": 0.500692067300505 - }, - { - "x": 0.5344906388270938, - "y": 0.5089592630677008 - }, - { - "x": 0.5371361414725966, - "y": 0.5102820143904522 - } - ] - }, - { - "points": [ - { - "x": 0.5513557181921732, - "y": 0.5063137604221981 - }, - { - "x": 0.5629297922662473, - "y": 0.5082978874063251 - }, - { - "x": 0.5616070409434959, - "y": 0.49242487153330916 - }, - { - "x": 0.5629297922662473, - "y": 0.48481905142748905 - }, - { - "x": 0.5513557181921732, - "y": 0.48415767576611335 - }, - { - "x": 0.551686406022861, - "y": 0.49242487153330916 - }, - { - "x": 0.5543319086683637, - "y": 0.49374762285606055 - } - ] - }, - { - "points": [ - { - "x": 0.5526784695149247, - "y": 0.4239724905809284 - }, - { - "x": 0.5642525435889987, - "y": 0.4259566175650554 - }, - { - "x": 0.5629297922662474, - "y": 0.41008360169203945 - }, - { - "x": 0.5642525435889987, - "y": 0.40247778158621933 - }, - { - "x": 0.5526784695149247, - "y": 0.40181640592484363 - }, - { - "x": 0.5530091573456125, - "y": 0.41008360169203945 - }, - { - "x": 0.5556546599911152, - "y": 0.41140635301479084 - } - ] - }, - { - "points": [ - { - "x": 0.4184192102556653, - "y": 0.42364180275024055 - }, - { - "x": 0.4299932843297393, - "y": 0.4256259297343675 - }, - { - "x": 0.428670533006988, - "y": 0.4097529138613516 - }, - { - "x": 0.4299932843297393, - "y": 0.4021470937555315 - }, - { - "x": 0.4184192102556653, - "y": 0.4014857180941558 - }, - { - "x": 0.4187498980863531, - "y": 0.4097529138613516 - }, - { - "x": 0.4213954007318558, - "y": 0.411075665184103 - } - ] - }, - { - "points": [ - { - "x": 0.3688160356524908, - "y": 0.3892502683587062 - }, - { - "x": 0.38039010972656484, - "y": 0.39123439534283316 - }, - { - "x": 0.3790673584038135, - "y": 0.37536137946981724 - }, - { - "x": 0.38039010972656484, - "y": 0.3677555593639971 - }, - { - "x": 0.3688160356524908, - "y": 0.3670941837026214 - }, - { - "x": 0.3691467234831786, - "y": 0.37536137946981724 - }, - { - "x": 0.3717922261286813, - "y": 0.3766841307925686 - } - ] - } - ], - "exits": [ - { - "points": [ - { - "x": 0.3164750809160108, - "y": 0.21695510025497125 - }, - { - "x": 0.31665088963612337, - "y": 0.20034117620433836 - }, - { - "x": 0.33475918780771263, - "y": 0.2010444110847884 - }, - { - "x": 0.33440757036748764, - "y": 0.21669138717480246 - } - ], - "name": "a", - "action": "toMap", - "toMapId": "lounge", - "targetExit": "a" - } - ], - "hiddenMasks": [], - "occluders": [], - "fallThroughs": [], - "ttrpgEnabled": false, - "sprites": [ - { - "id": "spr_1771132138732_bf217083a664f8", - "kind": "token", - "name": "dragon", - "url": "/uploads/1771132138431-69d9972f45f58bf88df6.png", - "scale": 1 - } - ], - "props": [ - { - "id": "prop_1771132143222_6295b1ff2354b", - "spriteId": "spr_1771132138732_bf217083a664f8", - "x": 0.4905478720781012, - "y": 0.05955362416011983, - "z": 0, - "rot": 0, - "scale": 0.5000000000000001, - "nickname": "1", - "hpCurrent": 10, - "hpMax": 10, - "controlledBy": "azakaela" - } - ], - "walkiesEnabled": false - }, - { - "id": "lounge", - "title": "Lounge", - "owner": "azakaela", - "backgroundUrl": "/uploads/1771112006330-2be059809a3daf517e00.png", - "thumbUrl": "/uploads/1771112006330-2be059809a3daf517e00.png", - "world": null, - "avatarSize": 83, - "cameraZoom": 2.15, - "collisions": [], - "masks": [], - "exits": [ - { - "points": [ - { - "x": 0.4479827937787088, - "y": 0.9962660845588236 - }, - { - "x": 0.44970613936694415, - "y": 0.9549057904411765 - }, - { - "x": 0.5705318133865519, - "y": 0.9546185661764707 - }, - { - "x": 0.5680425364257676, - "y": 0.997702205882353 - } - ], - "name": "a", - "action": "toMap", - "toMapId": "fountain", - "targetExit": "a" - } - ], - "hiddenMasks": [], - "occluders": [], - "fallThroughs": [], - "ttrpgEnabled": false, - "sprites": [], - "props": [], - "walkiesEnabled": false - }, - { - "id": "kakariko", - "title": "kakariko", - "owner": "azakaela", - "backgroundUrl": "/uploads/1771207189932-1298f4c42c2fadfca656.png", - "thumbUrl": "/uploads/1771207189932-1298f4c42c2fadfca656.png", - "world": null, - "avatarSize": 53, - "cameraZoom": 3.1, - "collisions": [], - "masks": [], - "exits": [], - "hiddenMasks": [], - "occluders": [], - "fallThroughs": [], - "ttrpgEnabled": false, - "sprites": [], - "props": [], - "walkiesEnabled": false - } - ] -} -\ No newline at end of file diff --git a/data.bak.20260219-051337/plugins.json b/data.bak.20260219-051337/plugins.json @@ -1,13 +0,0 @@ -{ - "version": 1, - "plugins": [ - { - "id": "library", - "enabled": false - }, - { - "id": "maps", - "enabled": true - } - ] -} diff --git a/data.bak.20260219-051337/plugins/library/client.js b/data.bak.20260219-051337/plugins/library/client.js @@ -1,927 +0,0 @@ -(function () { - const PLUGIN_ID = "library"; - - const PDF_CHUNK_BYTES = 256 * 1024; - const TEXT_FILE_MAX_BYTES = 512 * 1024; // mirrors server default - - function escapeHtml(text) { - return String(text || "") - .replace(/&/g, "&amp;") - .replace(/</g, "&lt;") - .replace(/>/g, "&gt;") - .replace(/\"/g, "&quot;") - .replace(/'/g, "&#039;"); - } - - function formatBytes(n) { - const v = Number(n || 0); - if (!Number.isFinite(v) || v <= 0) return "0 B"; - const units = ["B", "KB", "MB", "GB"]; - let idx = 0; - let x = v; - while (x >= 1024 && idx < units.length - 1) { - x /= 1024; - idx += 1; - } - return `${x.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`; - } - - function bytesToBase64(bytes) { - let bin = ""; - const chunk = 0x8000; - for (let i = 0; i < bytes.length; i += chunk) { - // eslint-disable-next-line prefer-spread - bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); - } - return btoa(bin); - } - - function sleep(ms) { - return new Promise((r) => setTimeout(r, ms)); - } - - function sanitizeFilenameBase(name) { - return String(name || "book") - .trim() - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/-+/g, "-") - .replace(/^[-.]+|[-.]+$/g, "") - .slice(0, 80); - } - - function paginateText(text, opts = {}) { - const maxLines = Number(opts.maxLines || 42); - const maxChars = Number(opts.maxChars || 2200); - const raw = String(text || "").replace(/\r\n/g, "\n"); - const lines = raw.split("\n"); - - const pages = []; - let buf = ""; - let lineCount = 0; - - const flush = () => { - pages.push(buf); - buf = ""; - lineCount = 0; - }; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const lineWithNl = i === lines.length - 1 ? line : `${line}\n`; - - // If a single line is huge, split it to fit. - if (lineWithNl.length > maxChars) { - let remaining = lineWithNl; - while (remaining.length) { - const take = remaining.slice(0, maxChars); - remaining = remaining.slice(maxChars); - if (buf && (buf.length + take.length > maxChars || lineCount >= maxLines)) flush(); - buf += take; - lineCount += 1; - if (buf.length >= maxChars || lineCount >= maxLines) flush(); - } - continue; - } - - if (buf && (buf.length + lineWithNl.length > maxChars || lineCount + 1 > maxLines)) flush(); - buf += lineWithNl; - lineCount += 1; - } - - if (buf || !pages.length) pages.push(buf); - return pages; - } - - function ensureStyles() { - if (document.getElementById("bzlLibraryStyle")) return; - const el = document.createElement("style"); - el.id = "bzlLibraryStyle"; - el.textContent = ` - .bzlLibraryToggle { - position: fixed; right: 18px; bottom: 18px; z-index: 9998; - padding: 10px 14px; border-radius: 999px; - background: linear-gradient(180deg, rgba(255,140,0,0.95), rgba(255,80,160,0.95)); - color: #1b0a12; border: 0; cursor: pointer; font-weight: 700; - box-shadow: 0 10px 30px rgba(0,0,0,0.35); - } - .bzlLibraryPanel { - position: fixed; z-index: 9999; - left: 18px; top: 18px; - width: min(560px, calc(100vw - 36px)); - height: min(74vh, 760px); - max-width: calc(100vw - 36px); - max-height: calc(100vh - 36px); - overflow: hidden; - border-radius: 16px; - background: rgba(20, 12, 18, 0.92); - border: 1px solid rgba(255,255,255,0.12); - box-shadow: 0 22px 70px rgba(0,0,0,0.55); - backdrop-filter: blur(10px); - display: flex; - flex-direction: column; - } - .bzlLibraryHeader { - display: flex; align-items: center; justify-content: space-between; - gap: 10px; padding: 12px 12px 10px 12px; - border-bottom: 1px solid rgba(255,255,255,0.08); - } - .bzlLibraryTitle { - font-weight: 800; - cursor: move; - user-select: none; - -webkit-user-select: none; - touch-action: none; - } - .bzlLibraryBody { padding: 12px; overflow: auto; flex: 1 1 auto; min-height: 0; } - .bzlLibraryRow { display:flex; gap: 10px; align-items:center; flex-wrap: wrap; margin-bottom: 10px; } - .bzlLibraryRow input[type="text"], .bzlLibraryRow input[type="number"], .bzlLibraryRow textarea { - background: rgba(255,255,255,0.08); color: #f6e8f0; - border: 1px solid rgba(255,255,255,0.12); - border-radius: 10px; padding: 8px 10px; - } - .bzlLibraryBtn { - border-radius: 999px; padding: 8px 12px; border: 1px solid rgba(255,255,255,0.12); - background: rgba(255,255,255,0.06); color: #f6e8f0; cursor: pointer; - } - .bzlLibraryBtn.primary { - background: linear-gradient(180deg, rgba(255,140,0,0.95), rgba(255,80,160,0.95)); - color: #1b0a12; border: 0; font-weight: 800; - } - .bzlLibraryTabs { display:flex; gap: 8px; align-items:center; } - .bzlLibraryList { display:flex; flex-direction: column; gap: 10px; } - .bzlLibraryItem { - border: 1px solid rgba(255,255,255,0.10); - background: rgba(255,255,255,0.04); - border-radius: 12px; padding: 10px; - display:flex; align-items:flex-start; justify-content: space-between; gap: 10px; - } - .bzlLibraryMeta { opacity: 0.8; font-size: 12px; margin-top: 4px; } - .bzlLibraryViewer { display:flex; flex-direction: column; gap: 10px; height: 100%; } - .bzlLibraryDocWrap { flex: 1 1 auto; min-height: 240px; min-width: 0; } - .bzlLibraryFrame { - width: 100%; - height: 100%; - border: 1px solid rgba(255,255,255,0.12); - border-radius: 12px; - background: rgba(0,0,0,0.25); - } - .bzlLibraryTextPage { - width: 100%; - height: 100%; - overflow: auto; - border: 1px solid rgba(255,255,255,0.12); - border-radius: 12px; - background: rgba(0,0,0,0.18); - padding: 10px; - color: #f6e8f0; - white-space: pre-wrap; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace; - font-size: 13px; - line-height: 1.35; - } - .bzlLibraryResize { - position: absolute; - right: 10px; - bottom: 10px; - width: 16px; - height: 16px; - border-radius: 5px; - border: 1px solid rgba(255,255,255,0.18); - background: rgba(255,255,255,0.10); - cursor: se-resize; - opacity: 0.85; - } - .bzlLibraryResize:hover { opacity: 1; } - .bzlLibraryHint { opacity: 0.8; font-size: 12px; margin-top: 6px; } - .hidden { display: none !important; } - `; - document.head.appendChild(el); - } - - function whenBodyReady(fn) { - if (document.body) return fn(); - const run = () => { - try { - document.removeEventListener("DOMContentLoaded", run); - } catch { - // ignore - } - fn(); - }; - document.addEventListener("DOMContentLoaded", run, { once: true }); - } - - window.BzlPluginHost?.register(PLUGIN_ID, (ctx) => { - ensureStyles(); - - const PANEL_RECT_KEY = "bzlLibraryPanelRect"; - const PANEL_MIN_W = 420; - const PANEL_MIN_H = 320; - - let panelOpen = false; - let viewerOpen = false; - let items = []; - let filterKind = "all"; // all | pdf | text - - let activeItem = null; // {id, kind, ...} - let activePage = 1; - let totalPages = 1; - let textPages = [""]; - let activeText = ""; - let editorOpen = false; - let editorText = ""; - let editorTitle = ""; - - let uploadingPdf = false; - let wsAttachedTo = null; - - function readPanelRect() { - try { - const raw = localStorage.getItem(PANEL_RECT_KEY); - if (!raw) return null; - const json = JSON.parse(raw); - const left = Number(json?.left); - const top = Number(json?.top); - const width = Number(json?.width); - const height = Number(json?.height); - if (![left, top, width, height].every((n) => Number.isFinite(n))) return null; - return { left, top, width, height }; - } catch { - return null; - } - } - - function defaultPanelRect() { - const width = Math.min(560, Math.max(PANEL_MIN_W, window.innerWidth - 36)); - const height = Math.min(Math.floor(window.innerHeight * 0.74), 760); - const left = Math.max(18, Math.floor(window.innerWidth - width - 18)); - const top = Math.max(18, Math.floor(window.innerHeight - height - 70)); - return { left, top, width, height }; - } - - function clampPanelRect(rect) { - const maxW = Math.max(PANEL_MIN_W, window.innerWidth - 36); - const maxH = Math.max(PANEL_MIN_H, window.innerHeight - 36); - const width = Math.min(maxW, Math.max(PANEL_MIN_W, Math.floor(rect.width))); - const height = Math.min(maxH, Math.max(PANEL_MIN_H, Math.floor(rect.height))); - const left = Math.min(window.innerWidth - 18 - width, Math.max(18, Math.floor(rect.left))); - const top = Math.min(window.innerHeight - 18 - height, Math.max(18, Math.floor(rect.top))); - return { left, top, width, height }; - } - - function applyPanelRect(panel, rect) { - const r = clampPanelRect(rect); - panel.style.left = `${r.left}px`; - panel.style.top = `${r.top}px`; - panel.style.right = ""; - panel.style.bottom = ""; - panel.style.width = `${r.width}px`; - panel.style.height = `${r.height}px`; - } - - function savePanelRect(rect) { - try { - localStorage.setItem(PANEL_RECT_KEY, JSON.stringify(rect)); - } catch { - // ignore - } - } - - function savePanelRectFromEl(panel) { - const left = Number.parseFloat(panel.style.left || "0"); - const top = Number.parseFloat(panel.style.top || "0"); - const width = Number.parseFloat(panel.style.width || "0"); - const height = Number.parseFloat(panel.style.height || "0"); - if (![left, top, width, height].every((n) => Number.isFinite(n) && n > 0)) return; - savePanelRect(clampPanelRect({ left, top, width, height })); - } - - function setStatus(msg) { - const el = document.getElementById("bzlLibraryStatus"); - if (el) el.textContent = String(msg || ""); - } - - function attachWsListener() { - const ws = window.__bzlWs; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - if (wsAttachedTo === ws) return; - try { - if (wsAttachedTo) wsAttachedTo.removeEventListener("message", onWsMsg); - } catch { - // ignore - } - wsAttachedTo = ws; - ws.addEventListener("message", onWsMsg); - } - - function requestList() { - ctx.send("list", {}); - } - - function requestText(id) { - ctx.send("textGet", { id }); - } - - function isAuthor(it) { - const me = String(ctx.getUser() || ""); - return Boolean(me) && String(it?.createdBy || "") === me; - } - - function canDelete(it) { - const role = String(ctx.getRole() || ""); - return role === "owner" || isAuthor(it); - } - - function downloadTextFile(filename, text) { - try { - const blob = new Blob([String(text || "")], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 2000); - } catch { - // ignore - } - } - - function openItem(it) { - activeItem = it; - activePage = 1; - viewerOpen = true; - editorOpen = false; - editorText = ""; - editorTitle = ""; - activeText = ""; - textPages = [""]; - totalPages = 1; - render(); - if (String(it.kind || "pdf") === "text") { - requestText(it.id); - } else { - setPage(1); - } - } - - function closeViewer() { - viewerOpen = false; - activeItem = null; - activePage = 1; - totalPages = 1; - textPages = [""]; - activeText = ""; - editorOpen = false; - editorText = ""; - editorTitle = ""; - render(); - } - - function setPage(n) { - if (!viewerOpen || !activeItem) return; - const kind = String(activeItem.kind || "pdf"); - if (kind === "text") { - const next = Math.max(1, Math.min(totalPages, Number(n || 1))); - activePage = next; - const pageLabel = document.getElementById("bzlLibraryPageLabel"); - if (pageLabel) pageLabel.textContent = `Page ${activePage} / ${totalPages}`; - const inp = document.getElementById("bzlLibraryPage"); - if (inp) inp.value = String(activePage); - const textEl = document.getElementById("bzlLibraryTextPage"); - if (textEl) textEl.textContent = textPages[activePage - 1] || ""; - return; - } - const next = Math.max(1, Math.min(9999, Number(n || 1))); - activePage = next; - const iframe = document.getElementById("bzlLibraryFrame"); - if (iframe) iframe.src = `${activeItem.url}#page=${activePage}`; - const inp = document.getElementById("bzlLibraryPage"); - if (inp) inp.value = String(activePage); - const pageLabel = document.getElementById("bzlLibraryPageLabel"); - if (pageLabel) pageLabel.textContent = `Page ${activePage}`; - } - - async function uploadSelectedPdf() { - if (uploadingPdf) return; - attachWsListener(); - const fileEl = document.getElementById("bzlLibraryPdfFile"); - const titleEl = document.getElementById("bzlLibraryPdfTitle"); - const file = fileEl?.files?.[0]; - if (!file) return setStatus("Choose a PDF first."); - const mime = String(file.type || "").trim().toLowerCase(); - const isPdf = /\.pdf$/i.test(file.name || "") || mime === "application/pdf"; - if (!isPdf) return setStatus("Only PDF files are supported."); - - uploadingPdf = true; - setStatus("Starting PDF upload..."); - window.__bzlLibraryUploadId = ""; - ctx.send("uploadStart", { filename: file.name, mime, size: file.size, title: String(titleEl?.value || "").trim() }); - - const t0 = Date.now(); - while (!window.__bzlLibraryUploadId && Date.now() - t0 < 3000) { - // eslint-disable-next-line no-await-in-loop - await sleep(30); - } - const uploadId = String(window.__bzlLibraryUploadId || ""); - if (!uploadId) { - uploadingPdf = false; - return setStatus("Upload failed to start."); - } - ctx.devLog("info", "library:uploadId", { uploadId, size: file.size }); - - let sent = 0; - for (let off = 0; off < file.size; off += PDF_CHUNK_BYTES) { - const slice = file.slice(off, Math.min(file.size, off + PDF_CHUNK_BYTES)); - // eslint-disable-next-line no-await-in-loop - const buf = await slice.arrayBuffer(); - const bytes = new Uint8Array(buf); - const b64 = bytesToBase64(bytes); - ctx.send("uploadChunk", { uploadId, data: b64 }); - sent += bytes.length; - setStatus(`Uploading PDF... ${formatBytes(sent)} / ${formatBytes(file.size)}`); - // eslint-disable-next-line no-await-in-loop - await sleep(0); - } - - ctx.send("uploadFinish", { uploadId }); - setStatus("Finalizing PDF..."); - } - - async function importTextFromFile() { - const fileEl = document.getElementById("bzlLibraryTextFile"); - const titleEl = document.getElementById("bzlLibraryTextTitle"); - const file = fileEl?.files?.[0]; - if (!file) return setStatus("Choose a text file first."); - - const name = String(file.name || "").toLowerCase(); - const okExt = name.endsWith(".txt") || name.endsWith(".md"); - if (!okExt && String(file.type || "").toLowerCase() !== "text/plain") { - return setStatus("Supported: .txt, .md, or text/plain."); - } - if (file.size > TEXT_FILE_MAX_BYTES) { - return setStatus(`Text file too large. Max is ${formatBytes(TEXT_FILE_MAX_BYTES)}.`); - } - - let text = ""; - try { - text = await file.text(); - } catch { - return setStatus("Failed to read file."); - } - const title = String(titleEl?.value || "").trim() || String(file.name || "").replace(/\.(txt|md)$/i, ""); - ctx.send("textCreate", { title, text }); - setStatus("Importing text..."); - } - - function createBlankTextBook() { - const titleEl = document.getElementById("bzlLibraryTextNewTitle"); - const title = String(titleEl?.value || "").trim() || "Untitled text"; - ctx.send("textCreate", { title, text: "" }); - setStatus("Creating blank text..."); - } - - function openEditor() { - if (!activeItem || String(activeItem.kind || "") !== "text") return; - if (!isAuthor(activeItem)) { - ctx.toast("Library", "Only the author can edit/export this text."); - return; - } - editorOpen = true; - editorText = String(activeText || ""); - editorTitle = String(activeItem.title || ""); - render(); - } - - function closeEditor() { - editorOpen = false; - editorText = ""; - editorTitle = ""; - render(); - } - - function saveEditor() { - if (!activeItem || String(activeItem.kind || "") !== "text") return; - if (!isAuthor(activeItem)) return; - ctx.send("textUpdate", { id: activeItem.id, title: editorTitle, text: editorText }); - setStatus("Saving..."); - } - - function exportActiveText() { - if (!activeItem || String(activeItem.kind || "") !== "text") return; - if (!isAuthor(activeItem)) return; - const base = sanitizeFilenameBase(activeItem.title || "book") || "book"; - downloadTextFile(`${base}.txt`, activeText); - } - - function render() { - ensureDom(); - const panel = document.getElementById("bzlLibraryPanel"); - if (!panel) return; - panel.classList.toggle("hidden", !panelOpen); - if (!panelOpen) return; - - const viewList = !viewerOpen; - const list = items - .filter((it) => { - const k = String(it?.kind || "pdf"); - if (filterKind === "all") return true; - return k === filterKind; - }) - .map((it) => { - const kind = String(it.kind || "pdf"); - const title = escapeHtml(it.title || it.filename || (kind === "text" ? "Text" : "PDF")); - const when = new Date(Number(it.createdAt || 0) || 0).toLocaleString(); - const who = escapeHtml(String(it.createdBy || "")); - const meta = `${kind.toUpperCase()} | ${who} | ${when} | ${formatBytes(it.bytes)}`; - const delBtn = canDelete(it) ? `<button type="button" class="bzlLibraryBtn" data-libdel="${escapeHtml(it.id)}">Delete</button>` : ""; - const openBtn = `<button type="button" class="bzlLibraryBtn primary" data-libopen="${escapeHtml(it.id)}">Open</button>`; - const newTab = - kind === "pdf" && it.url - ? `<a class="bzlLibraryBtn" href="${escapeHtml(it.url)}" target="_blank" rel="noreferrer">New tab</a>` - : ""; - return ` - <div class="bzlLibraryItem"> - <div> - <div><b>${title}</b></div> - <div class="bzlLibraryMeta">${escapeHtml(meta)}</div> - </div> - <div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap; justify-content:flex-end;"> - ${openBtn} - ${newTab} - ${delBtn} - </div> - </div> - `; - }) - .join(""); - - const kindTabs = ` - <div class="bzlLibraryTabs"> - <button type="button" class="bzlLibraryBtn ${filterKind === "all" ? "primary" : ""}" data-libkind="all">All</button> - <button type="button" class="bzlLibraryBtn ${filterKind === "pdf" ? "primary" : ""}" data-libkind="pdf">PDFs</button> - <button type="button" class="bzlLibraryBtn ${filterKind === "text" ? "primary" : ""}" data-libkind="text">Texts</button> - </div> - `; - - const isText = viewerOpen && activeItem && String(activeItem.kind || "pdf") === "text"; - const canEdit = isText && isAuthor(activeItem); - const pageLabelText = isText ? `Page ${activePage} / ${totalPages}` : `Page ${activePage}`; - - panel.innerHTML = ` - <div class="bzlLibraryHeader"> - <div class="bzlLibraryTitle" id="bzlLibraryDrag" title="Drag to move">Library</div> - <div style="display:flex; gap:8px; align-items:center;"> - ${kindTabs} - <button type="button" class="bzlLibraryBtn" id="bzlLibraryRefresh">Refresh</button> - <button type="button" class="bzlLibraryBtn" id="bzlLibraryReset" title="Reset panel size/position">Reset</button> - <button type="button" class="bzlLibraryBtn" id="bzlLibraryClose">Close</button> - </div> - </div> - - <div class="bzlLibraryBody"> - <div class="${viewList ? "" : "hidden"}" id="bzlLibraryListView"> - <div class="bzlLibraryRow" style="align-items:flex-end;"> - <div style="flex: 1 1 260px;"> - <div class="bzlLibraryHint"><b>Upload PDF</b></div> - <div class="bzlLibraryRow" style="margin: 6px 0 0 0;"> - <input id="bzlLibraryPdfTitle" type="text" placeholder="PDF title (optional)" style="flex: 1 1 200px;" /> - <input id="bzlLibraryPdfFile" type="file" accept="application/pdf,.pdf" /> - <button type="button" class="bzlLibraryBtn primary" id="bzlLibraryPdfUpload">Upload PDF</button> - </div> - </div> - </div> - - <div class="bzlLibraryRow" style="align-items:flex-end;"> - <div style="flex: 1 1 260px;"> - <div class="bzlLibraryHint"><b>Text books</b> (for lore, notes, character bios, poetry, etc)</div> - <div class="bzlLibraryRow" style="margin: 6px 0 0 0;"> - <input id="bzlLibraryTextNewTitle" type="text" placeholder="New text title" style="flex: 1 1 200px;" /> - <button type="button" class="bzlLibraryBtn primary" id="bzlLibraryTextNew">New blank</button> - </div> - <div class="bzlLibraryRow" style="margin: 6px 0 0 0;"> - <input id="bzlLibraryTextTitle" type="text" placeholder="Import title (optional)" style="flex: 1 1 200px;" /> - <input id="bzlLibraryTextFile" type="file" accept="text/plain,.txt,.md" /> - <button type="button" class="bzlLibraryBtn primary" id="bzlLibraryTextImport">Import</button> - </div> - </div> - </div> - - <div id="bzlLibraryStatus" class="bzlLibraryHint"></div> - <div class="bzlLibraryHint">Open to read here. Left/Right arrows change pages. Text books can be edited and exported by the author.</div> - <div style="height: 10px;"></div> - <div class="bzlLibraryList"> - ${list || `<div class="bzlLibraryHint">No library items yet.</div>`} - </div> - </div> - - <div class="${viewList ? "hidden" : ""} bzlLibraryViewer" id="bzlLibraryViewer"> - <div class="bzlLibraryRow" style="justify-content: space-between; align-items:center;"> - <div style="display:flex; gap:8px; align-items:center;"> - <button type="button" class="bzlLibraryBtn" id="bzlLibraryBack">Back</button> - <div id="bzlLibraryPageLabel" class="bzlLibraryHint">${escapeHtml(pageLabelText)}</div> - </div> - <div style="display:flex; gap:8px; align-items:center; justify-content:flex-end; flex-wrap:wrap;"> - ${canEdit ? `<button type="button" class="bzlLibraryBtn" id="bzlLibraryEdit">Edit</button>` : ""} - ${canEdit ? `<button type="button" class="bzlLibraryBtn" id="bzlLibraryExport">Export .txt</button>` : ""} - <button type="button" class="bzlLibraryBtn" id="bzlLibraryPrev">&lt;</button> - <input id="bzlLibraryPage" type="number" min="1" step="1" value="${activePage}" style="width: 92px;" /> - <button type="button" class="bzlLibraryBtn" id="bzlLibraryGo">Go</button> - <button type="button" class="bzlLibraryBtn" id="bzlLibraryNext">&gt;</button> - </div> - </div> - - <div class="bzlLibraryDocWrap"> - <div class="${isText ? "hidden" : ""}" style="height:100%;"> - <iframe id="bzlLibraryFrame" class="bzlLibraryFrame" title="PDF viewer"></iframe> - </div> - <div class="${isText ? "" : "hidden"}" style="height:100%;"> - <div id="bzlLibraryTextPage" class="bzlLibraryTextPage"></div> - </div> - </div> - - <div class="bzlLibraryHint">${activeItem ? escapeHtml(activeItem.title || activeItem.filename || "") : ""}</div> - - <div class="${editorOpen ? "" : "hidden"}" id="bzlLibraryEditorWrap"> - <div style="height:10px;"></div> - <div class="bzlLibraryHint"><b>Edit text book</b> (author only)</div> - <div class="bzlLibraryRow" style="margin-top:6px;"> - <input id="bzlLibraryEditorTitle" type="text" placeholder="Title" value="${escapeHtml(editorTitle)}" style="flex: 1 1 240px;" /> - </div> - <div class="bzlLibraryRow" style="margin-top:6px;"> - <textarea id="bzlLibraryEditorText" rows="12" style="width:100%; box-sizing:border-box;">${escapeHtml( - editorText - )}</textarea> - </div> - <div class="bzlLibraryRow" style="justify-content:flex-end;"> - <button type="button" class="bzlLibraryBtn" id="bzlLibraryEditorCancel">Cancel</button> - <button type="button" class="bzlLibraryBtn primary" id="bzlLibraryEditorSave">Save</button> - </div> - <div class="bzlLibraryHint">Note: pages are auto-generated from the text.</div> - </div> - </div> - </div> - - <div class="bzlLibraryResize" id="bzlLibraryResize" title="Resize"></div> - `; - - // header buttons - document.getElementById("bzlLibraryClose")?.addEventListener("click", () => { - panelOpen = false; - closeViewer(); - render(); - }); - document.getElementById("bzlLibraryRefresh")?.addEventListener("click", () => requestList()); - document.getElementById("bzlLibraryReset")?.addEventListener("click", () => { - try { - localStorage.removeItem(PANEL_RECT_KEY); - } catch { - // ignore - } - applyPanelRect(panel, defaultPanelRect()); - savePanelRectFromEl(panel); - }); - panel.querySelectorAll("[data-libkind]").forEach((b) => { - b.addEventListener("click", () => { - filterKind = String(b.getAttribute("data-libkind") || "all"); - render(); - }); - }); - - const drag = document.getElementById("bzlLibraryDrag"); - const resize = document.getElementById("bzlLibraryResize"); - - if (drag) { - drag.addEventListener("pointerdown", (e) => { - if (e.button !== 0) return; - e.preventDefault(); - const start = { x: e.clientX, y: e.clientY }; - const rect = panel.getBoundingClientRect(); - const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; - - const onMove = (ev) => { - const dx = ev.clientX - start.x; - const dy = ev.clientY - start.y; - applyPanelRect(panel, { ...startRect, left: startRect.left + dx, top: startRect.top + dy }); - }; - const onUp = () => { - window.removeEventListener("pointermove", onMove, true); - window.removeEventListener("pointerup", onUp, true); - window.removeEventListener("pointercancel", onUp, true); - savePanelRectFromEl(panel); - }; - - window.addEventListener("pointermove", onMove, true); - window.addEventListener("pointerup", onUp, true); - window.addEventListener("pointercancel", onUp, true); - }); - } - - if (resize) { - resize.addEventListener("pointerdown", (e) => { - if (e.button !== 0) return; - e.preventDefault(); - const start = { x: e.clientX, y: e.clientY }; - const rect = panel.getBoundingClientRect(); - const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; - - const onMove = (ev) => { - const dx = ev.clientX - start.x; - const dy = ev.clientY - start.y; - applyPanelRect(panel, { ...startRect, width: startRect.width + dx, height: startRect.height + dy }); - }; - const onUp = () => { - window.removeEventListener("pointermove", onMove, true); - window.removeEventListener("pointerup", onUp, true); - window.removeEventListener("pointercancel", onUp, true); - savePanelRectFromEl(panel); - }; - - window.addEventListener("pointermove", onMove, true); - window.addEventListener("pointerup", onUp, true); - window.addEventListener("pointercancel", onUp, true); - }); - } - - // list actions - document.getElementById("bzlLibraryPdfUpload")?.addEventListener("click", () => uploadSelectedPdf()); - document.getElementById("bzlLibraryTextImport")?.addEventListener("click", () => importTextFromFile()); - document.getElementById("bzlLibraryTextNew")?.addEventListener("click", () => createBlankTextBook()); - - panel.querySelectorAll("[data-libopen]").forEach((b) => { - b.addEventListener("click", () => { - const id = String(b.getAttribute("data-libopen") || ""); - const it = items.find((x) => String(x.id || "") === id); - if (it) openItem(it); - }); - }); - panel.querySelectorAll("[data-libdel]").forEach((b) => { - b.addEventListener("click", () => { - const id = String(b.getAttribute("data-libdel") || ""); - if (!id) return; - if (!confirm("Delete this item from the library?")) return; - ctx.send("delete", { id }); - }); - }); - - // viewer actions - document.getElementById("bzlLibraryBack")?.addEventListener("click", () => closeViewer()); - document.getElementById("bzlLibraryPrev")?.addEventListener("click", () => setPage(activePage - 1)); - document.getElementById("bzlLibraryNext")?.addEventListener("click", () => setPage(activePage + 1)); - document.getElementById("bzlLibraryGo")?.addEventListener("click", () => { - const inp = document.getElementById("bzlLibraryPage"); - setPage(Number(inp?.value || 1)); - }); - document.getElementById("bzlLibraryEdit")?.addEventListener("click", () => openEditor()); - document.getElementById("bzlLibraryExport")?.addEventListener("click", () => exportActiveText()); - document.getElementById("bzlLibraryEditorCancel")?.addEventListener("click", () => closeEditor()); - document.getElementById("bzlLibraryEditorSave")?.addEventListener("click", () => { - const titleEl = document.getElementById("bzlLibraryEditorTitle"); - const textEl = document.getElementById("bzlLibraryEditorText"); - editorTitle = String(titleEl?.value || "").trim(); - editorText = String(textEl?.value || ""); - saveEditor(); - closeEditor(); - }); - - // post-render sync for viewer - if (viewerOpen && activeItem) { - if (String(activeItem.kind || "pdf") === "pdf") { - const iframe = document.getElementById("bzlLibraryFrame"); - if (iframe && !iframe.src) iframe.src = `${activeItem.url}#page=${activePage}`; - } else { - setPage(activePage); - } - } - } - - function onWsMsg(ev) { - try { - const msg = JSON.parse(String(ev?.data || "")); - const type = String(msg?.type || ""); - if (!type.startsWith("plugin:library:")) return; - - if (type === "plugin:library:list") { - items = Array.isArray(msg.items) ? msg.items : []; - render(); - return; - } - if (type === "plugin:library:changed") { - if (panelOpen) requestList(); - return; - } - if (type === "plugin:library:uploadStarted") { - const uploadId = String(msg.uploadId || ""); - if (uploadId) window.__bzlLibraryUploadId = uploadId; - return; - } - if (type === "plugin:library:uploadProgress") { - setStatus(`Uploading PDF... ${formatBytes(msg.received)} / ${formatBytes(msg.expected)}`); - return; - } - if (type === "plugin:library:uploadFinished") { - uploadingPdf = false; - window.__bzlLibraryUploadId = ""; - setStatus("Upload complete."); - requestList(); - render(); - return; - } - if (type === "plugin:library:text") { - const it = msg.item; - if (!it || !activeItem || String(activeItem.id || "") !== String(it.id || "")) return; - activeText = String(it.text || ""); - textPages = paginateText(activeText); - totalPages = Math.max(1, textPages.length); - // update title in activeItem - activeItem = { ...activeItem, title: it.title, createdBy: it.createdBy, updatedAt: it.updatedAt, bytes: it.bytes }; - editorTitle = String(activeItem.title || ""); - setStatus(""); - render(); - setPage(1); - return; - } - if (type === "plugin:library:textCreated") { - requestList(); - setStatus("Created."); - return; - } - if (type === "plugin:library:textUpdated") { - requestList(); - setStatus("Saved."); - if (activeItem && String(activeItem.kind || "") === "text" && msg.id && String(msg.id) === String(activeItem.id)) { - requestText(activeItem.id); - } - return; - } - if (type === "plugin:library:deleted") { - requestList(); - if (activeItem && msg.id && String(msg.id) === String(activeItem.id)) closeViewer(); - return; - } - if (type === "plugin:library:error") { - uploadingPdf = false; - setStatus(String(msg.message || "Error.")); - ctx.toast("Library", String(msg.message || "Error.")); - } - } catch { - // ignore - } - } - - function ensureDom() { - if (document.getElementById("bzlLibraryToggle")) return; - if (!document.body) { - ctx.devLog("warn", "library:bodyMissing", {}); - return; - } - const btn = document.createElement("button"); - btn.id = "bzlLibraryToggle"; - btn.className = "bzlLibraryToggle"; - btn.type = "button"; - btn.textContent = "Library"; - btn.addEventListener("click", () => { - panelOpen = !panelOpen; - if (panelOpen) { - const panel = document.getElementById("bzlLibraryPanel"); - if (panel) applyPanelRect(panel, readPanelRect() || defaultPanelRect()); - requestList(); - } - render(); - }); - document.body.appendChild(btn); - ctx.devLog("info", "library:toggleMounted", {}); - - const panel = document.createElement("div"); - panel.id = "bzlLibraryPanel"; - panel.className = "bzlLibraryPanel hidden"; - applyPanelRect(panel, readPanelRect() || defaultPanelRect()); - document.body.appendChild(panel); - - window.addEventListener("resize", () => { - const p = document.getElementById("bzlLibraryPanel"); - if (!p) return; - applyPanelRect(p, readPanelRect() || defaultPanelRect()); - }); - - window.addEventListener("keydown", (e) => { - if (!panelOpen || !viewerOpen) return; - if (e.key === "ArrowLeft") { - e.preventDefault(); - setPage(activePage - 1); - } else if (e.key === "ArrowRight") { - e.preventDefault(); - setPage(activePage + 1); - } - }); - } - - // kick off - setInterval(attachWsListener, 1000); - attachWsListener(); - whenBodyReady(ensureDom); - ctx.devLog("info", "library:init", { ok: true }); - }); -})(); diff --git a/data.bak.20260219-051337/plugins/library/library.json b/data.bak.20260219-051337/plugins/library/library.json @@ -1,39 +0,0 @@ -{ - "version": 1, - "items": [ - { - "id": "6a0098722636fe575bdb", - "kind": "pdf", - "title": "Tawkys Pitch Deck email only.pdf", - "url": "/uploads/library/tawkys-pitch-deck-email-only.pdf-2026-02-16T00-37-25-191Z-79c29696.pdf", - "filename": "tawkys-pitch-deck-email-only.pdf-2026-02-16T00-37-25-191Z-79c29696.pdf", - "bytes": 13195983, - "createdAt": 1771202245191, - "createdBy": "azakaela", - "updatedAt": 0, - "text": "" - }, - { - "id": "93083cfd21715e32729b", - "kind": "pdf", - "title": "Tri Axis Of Sense.pdf", - "url": "/uploads/library/tri-axis-of-sense.pdf-2026-02-16T00-42-32-306Z-9dbf5045.pdf", - "filename": "tri-axis-of-sense.pdf-2026-02-16T00-42-32-306Z-9dbf5045.pdf", - "bytes": 31383, - "createdAt": 1771202552306, - "createdBy": "azakaela", - "updatedAt": 0, - "text": "" - }, - { - "id": "41eb2f9f45490c63763d", - "kind": "text", - "title": "(Survivor Radio 4 _ GUS, THE JANITOR)", - "text": "(Survivor Radio 4 : GUS, THE JANITOR)\n\n\nDAY 1 — Gus Broadcast #1\nGus:\nUh. Hello?\nIs this thing on?\nYeah. Okay.\nThis is Gus. Facilities management. Or… I was.\nNow see, I don’t know where everybody went, but I gotta say, the east wing looks better without teenagers draggin’ mud through it.\nYou ever notice how folks’ll complain about the smell of bleach, but nobody complains when the place smells like feet?\nStandards, people.\nAnyway, if anyone’s still around — somebody left a cart of canned peaches in the cafeteria.\nI took two. Not greedy. Just practical.\nAlso, whoever’s been moanin’ outside the gymnasium at night?\nYou gotta hydrate. That’s not healthy.\nAlright. I’m gonna go fix the flickerin’ light in the stairwell.\nCan’t have chaos and poor lighting.\nThat’s how accidents happen.\n________________\n\n\n\n\nDAY 2 — Gus Broadcast #2\nGus:\nOkay.\nNow I don’t mean to alarm anybody.\nBut I was mopin’ the hallway by science lab three — because SOMEONE spilled somethin’ suspiciously chunky —\nand there’s folks wanderin’ around real slow today.\nVery unmotivated.\nNobody respondin’ to “Excuse me.”\nThat’s rude.\nNow, I tried tappin’ one on the shoulder with my mop handle.\nDidn’t react much.\nSo I assume they’re union.\nAnyway.\nIf you’re out there, please stop leavin’ doors open.\nSecurity is important.\nAnd if you’re feelin’ under the weather, maybe lie down somewhere comfortable instead of in the middle of the corridor.\nYou’re creatin’ a trippin’ hazard.\nThank you.\n________________\n\n\n\n\nDAY 3 — Gus Broadcast #3\nGus:\nAlright.\nI think we need to talk about the smell.\nIt’s gettin’ persistent.\nNow I’ve dealt with gym locker rooms in August. I’ve dealt with cafeteria milk incidents. I have SEEN things.\nBut this?\nThis is ambitious.\nAlso — small note — if you’re gonna bang on the supply closet door for six hours straight?\nYou better be willin’ to help reorganize the shelving when I let you out.\nBecause I just alphabetized the solvents.\nAnd I will not be doin’ that twice.\nI dunno what’s goin’ on exactly.\nBut I will say this.\nIf this is some kind of drill?\nYou’re all failing.\nOn the bright side though—\nParking’s never been easier.\nAlright. Gus out.\nI’m takin’ my break.\nAnd if anyone touches my thermos?\nThere will be consequences.\n________________\n\n\nDAY 4 — Gus Realizes\nGus:\n…Alright.\nOkay.\nSo.\nI may have misjudged the situation.\nNow, I’ve been in facilities long enough to recognize patterns.\nAnd when multiple individuals ignore direct verbal instruction, exhibit poor hygiene, and attempt to bite you—\nThat’s not union.\nThat’s somethin’ else.\nI was in the cafeteria this morning.\nOne of ‘em slipped on a patch I’d just mopped.\nDidn’t even try to catch himself.\nThat’s when it hit me.\nThey’re not slow.\nThey’re gone.\n…\nWell.\nThat’s unfortunate.\nAnyway.\nBlood stains come out easier if you hit ‘em before they set.\nCold water first. Always.\nHot water cooks it in.\nIf anybody’s still alive and listenin’—\nclean as you go.\nWorld might be endin’.\nBut grime is a choice.\nGus out.\nI got the west hallway lookin’ respectable.\n________________\n\n\nDAY 5 — The Fall of Linda\nGus:\nI don’t have much to say today.\nLinda snapped.\nRight at the handle.\n1992 composite shaft.\nThey stopped makin’ ‘em like that after the budget cuts.\nI warned ‘em.\nNobody listens.\nI was clearin’ the front office.\nHad three of those biters comin’ down the stairs.\nLinda held.\nLong as she could.\nGood reach. Good balance.\nSaved my knees more times than I can count.\nThen—\ncrack.\nSplit clean.\n…\nI had to finish the job with a broom.\nA broom.\nYou ever try defendin’ yourself with somethin’ meant for dust?\nHumiliatin’.\nAnyway.\nI buried her out back.\nNear the flagpole.\nSeemed appropriate.\nIf anyone finds a decent hardwood handle out there—\nlet me know.\nStandards still matter.\nEven now.\nGus out.\n________________\n\n\nDAY 6 — The Burnisher Incident\nGus:\nAlright.\nSo I’d like to address the situation in the main lobby.\nFirst of all—\nwho unplugged the floor burnisher and left it crooked?\nYou can’t just abandon a machine like that.\nThat’s how cords get tangled.\nAnyway.\nI was runnin’ it this morning. Figured if we’re gonna have the end of civilization, we can at least have a respectable shine on the tile.\nMidway through the second pass, I notice a crowd gatherin’.\nThought maybe folks were finally admirin’ the gloss.\nNope.\nJust more of those shamblers.\nNow here’s the thing about a commercial burnisher—\nHigh torque.\nHeavy chassis.\nGood forward momentum.\nYou lean into it, it does the rest.\nAnd let me tell you—\nit did the rest.\n…\nLobby’s clear.\nVery clear.\nHowever—\nthere are… fragments.\nEverywhere.\nAnd someone bled directly onto the grout lines I sealed last week.\nUnacceptable.\nIf you’re operatin’ heavy machinery during a crisis, please be mindful of splash radius.\nAlso, small note—\nif you see a man pushin’ 90 pounds of industrial equipment toward you at speed?\nStep aside.\nThat’s common courtesy.\nAnyway.\nI’ll be in the lobby for the next few hours.\nTryin’ to get this back to presentable.\nGloss is gone, obviously.\nBut we’ll recover.\nWe always do.\nGus out.\nAnd if anyone touches the extension cord—\nI will know.\n________________\n\n\nDAY 7 — Survivor Frequency Crossover\n[static crackle — overlapping frequencies]\nGus:\n Uh—\nThere’s… another signal on this channel.\nYou’re crossin’ lines, ma’am.\nThis is a public frequency.\nYou can’t just—\n…well. I suppose you can.\nErin (calm, amused):\n I’ve heard about you.\nLobby burnisher incident.\nGrout preservation specialist.\nYou sound busy.\nGus:\n Standards don’t collapse just because society does.\nAnd for the record, that burnisher’s torque did most of the work.\nI was merely guiding.\nErin:\n That’s usually how it works.\nYou guide.\nThe machine follows.\nGus (grunts approvingly):\n Finally, someone who understands equipment.\nYou mop?\nErin:\n When I have to.\nCold water first for blood.\nGus:\n …Correct.\nHot water sets it.\nMost people don’t know that.\nErin:\n Most people don’t pay attention.\nYou do.\nGus (pause):\n Used to have a mop.\nNamed her Linda.\nComposite handle. ‘92 model.\nGood balance.\nSnapped last week.\nI buried her.\nFlagpole.\nSeemed right.\n[short silence]\nErin (gentler):\n You didn’t lose her.\nYou learned her.\nEvery mop you hold now—\nthat balance, that grip, that instinct?\nThat’s Linda.\nShe lives in your hands.\nAnd in the shine you leave behind.\n[longer pause — Gus exhales]\nGus:\n …That’s a ridiculous thing to say.\nErin:\n I know.\nGus:\n …\nBut it’s… structurally sound.\nErin (smiles in voice):\n Standards matter.\nEven now.\nEspecially now.\nGus:\n You keep your corners clean, ma’am.\nErin:\n You keep the floors shining, Gus.\nMusic persists.\nAnd so does good work.\nGus (quietly):\n …Copy that.\nGus out.\nErin:\n Survivor Frequency signing off.\nTake care of your tools.\nAnd each other.\n________________\n\n\nDAY 8 — Linda Returns\nGus:\nAlright.\nI’ve got an announcement.\nFound a replacement handle in the maintenance shed behind the gym.\nSolid hardwood. Good grain. No warping.\nTook some adjusting.\nBut she’s responsive.\nI’ve named her Linda.\nTradition matters.\nNow before anyone gets sentimental—\nthis is not the same Linda.\nThis one’s… sturdier.\nLess flex in the wrist.\nGood reach under cafeteria tables.\nVery important.\n…\nSchool’s secure.\nEast wing barricaded.\nGym doors chained.\nScience lab cleared.\nAnd for the first time in a week—\nit smells like lemon solvent again.\nAs it should.\nNow here’s the situation.\nI’ve been hearin’ chatter on the Survivor Frequency.\nFolks lookin’ for shelter.\nTalkin’ about regrouping here.\nAt the school.\nWhich is fine.\nCommunity’s important.\nBut let me be very clear about something.\nIf you come into this building—\nyou wipe your boots.\nYou don’t leave wrappers in the stairwell.\nYou do not—\nunder any circumstances—\ntrack mud across a freshly sealed hallway.\nI did not wrestle a lobby full of biters with industrial equipment\njust to watch someone drop sunflower seed shells on my tile.\nWe will survive.\nBut we will survive clean.\nLinda and I are prepared.\nAre you?\nGus out.\nAnd bring your own trash bags.\n________________\n\n\nDAY 9 — New Tenants\nGus:\nAlright.\nWe’ve got occupants.\nThree, countin’ me.\nJordan. Senior. Used to roam these halls before all this went sideways.\nGood kid.\nPolite.\nCarries his weight.\nBit too into this… “grunge” business, though.\nAll that flannel and sighin’.\nI told him, “Son, if you’re gonna be melancholic, at least make it melodic.”\nWe’ll get him listenin’ to Tom Petty yet.\nThat’s structured sadness.\nBuilds character.\nNow.\nHis uncle Derrick.\nDerrick’s… enthusiastic.\nWalked in yesterday with what I can only describe as a “liberal application of blood.”\nDrippin’.\nOnto tile I personally burnished.\nI pointed it out.\nRespectfully.\nSaid, “Sir, you appear to be leakin’.”\nHe told me to shove it.\nWhich I found unnecessary.\nBut I don’t take offense.\nPeople cope different.\nStill handed him a rag.\nHe used it.\nPoorly.\nWe’re workin’ on it.\nSchool’s still secure.\nCafeteria’s operational.\nGym’s off-limits unless supervised.\nJordan’s sweepin’ without complaint.\nDerrick leaves cans in places cans do not belong.\nWe’ll adapt.\nThat’s what we do.\nOne hallway at a time.\nGus out.\nAnd if you’re gonna bleed—\nat least aim for linoleum.\n________________\n\n\nDAY 10 — “Fighting Mop”\nGus:\nJordan’s got that racket on again.\n“Nirvana.”\nSays it’s the future of music.\nI told him the future sounds like feedback and unfinished drywall.\nHe says that’s the point.\nI don’t see how that’s a point.\n“Smells Like Teen Spirit,” he says.\nWell, I’ve worked in locker rooms.\nTeen spirit smells very specific and it ain’t this.\n…\nStill.\nSecond chorus hit.\nIt’s got… momentum.\nCan’t deny momentum.\nStructured chaos.\nReminds me of a floor burnisher on uneven tile.\nTakes a minute to appreciate.\nAnyway.\nImportant announcement.\nI’ve modified Linda.\nReinforced the collar.\nWeighted the head slightly.\nWrapped the grip.\nShe’s no longer strictly custodial.\nShe is now—\na fighting mop.\nMulti-purpose design.\nJordan thinks that’s “metal.”\nI told him metal is a material, not a personality.\n…\nHold on.\nDerrick?\nYou’re walkin’ funny.\nYou good?\n…\nDerrick.\nStop.\nYou’re drippin’ again.\nWe talked about—\n…\nJordan.\nRadio down.\nNow.\n[muffled scuffle sounds — impact, breath, scraping tile]\nGus (strained but steady):\n Push him toward the lockers.\nNot the hallway.\nI just waxed that—\nNow!\n[thud — heavy metallic locker slam]\nGood.\nJordan— sweep the leg.\nI’ll handle upper structure.\n[solid impact — wet crack]\n…\n…\nWell.\nThat’s… unfortunate.\nJordan, you alright?\n…\nYeah.\nMe too.\nLinda held.\nGood torque.\nMinimal splatter radius.\nWe’ll need solvent.\nStrong one.\n…\nJordan.\nTurn that song back on.\n________________\n\n\nDAY 11 — “Left the Tape”\nGus:\nSchool’s quiet this mornin’.\nQuieter than usual.\nJordan left.\nMiddle of the night.\nDidn’t wake me.\nLeft a note on the faculty desk.\nSaid he didn’t want to slow me down.\nWhich is ridiculous.\nI move at a very reasonable pace.\n…\nHe had the decency to say goodbye.\nLeft his Nirvana tape on the counter.\nLabeled it in marker.\nUnderlined twice.\nI appreciate proper labeling.\nSaves confusion.\nHe took one of the flashlights.\nThe good one.\nSmart choice.\nMarked the map before he left.\nAlso smart.\nI don’t blame him.\nSchool’s secure.\nBut it ain’t the world.\nAnd he’s not a boy anymore.\nHe cleared lockers.\nHeld the line.\nDidn’t freeze.\nThat makes him a man.\nMakes me proud.\n…\nI put the tape in earlier.\nPlayed it through.\nStill sounds like unfinished drywall.\nBut I let it run.\nMomentum’s momentum.\nLinda and I’ll hold the fort.\nIf you’re out there, Jordan—\nkeep your corners clean.\nGus out.\n________________\n\n\nDAY 12 — “Reinforcements”\nGus:\nGot company again.\nFella named Johnson.\nHeard me on the frequency.\nSaid if a man cares that much about grout lines, the building’s probably worth stayin’ in.\nFair assessment.\nJohnson brought tools.\nActual welding rig.\nProper mask.\nDidn’t even burn the paint.\nWe reinforced the east-facing windows.\nMetal plates, clean seams.\nGood work.\nVery tidy.\nHe’s got a family with him.\nTwo kids.\nQuiet ones.\nWhich I respect.\nThey stay outta the hallways unless necessary.\nJohnson asked where to set up.\nI told him:\nAnywhere that doesn’t block access to emergency exits.\nHe laughed.\nGood sense of humor.\nHe’s stayin’ the week.\nHelpin’ secure the roof access tomorrow.\nHe even brought provisions.\nCanned goods.\nWater jugs.\nHanded me a stack and said it was “for hospitality.”\nNow I didn’t charge admission.\nBut I won’t argue with generosity.\nGood man.\nOddly generous, actually.\nBut I don’t question kindness.\nWorld’s short on it.\nSchool’s lookin’ stronger today.\nWindows plated.\nDoors reinforced.\nSmells like lemon again.\nFeels… operational.\nIf you’re thinkin’ about comin’ through—\nbring somethin’ useful.\nOr at least bring your manners.\nGus out.\nAnd if you weld near tile—\nlay down cardboard first.\nDAY 13 — “Company”\nGus:\nSchool’s louder now.\nNot in a bad way.\nJohnson’s kids run light-footed.\nI taught ’em that.\nNo heel-striking on tile.\nRespect the surface.\nJohnson’s wife—\nmakes a decent stew.\nUses too much salt.\nBut it’s warm.\nAnd warm matters.\nWe ate in the cafeteria tonight.\nProper tables.\nNo loiterin’.\nJohnson said it felt almost normal.\nI don’t think that’s the word.\nBut it felt… steady.\nI watched the kids draw on scrap paper.\nHeard laughter echo in the hallway.\nFor a minute there—\nI thought about my own.\nHad a wife once.\nTwo boys.\nThey wanted me home more.\nHard to explain pride in polish to people who don’t see the shine.\nJanitorial work ain’t glamorous.\nBut it’s honest.\nI chose it.\nAnd I don’t regret that.\nStill.\nHavin’ voices in the hallways again…\nIt’s good.\nWorld’s a mess.\nBut this corner of it?\nPresentable.\nJohnson says we’re buildin’ somethin’.\nMaybe we are.\nIf we are—\nit’ll be level.\nGus out.\nAnd if you’re settin’ the table—\nline it up straight.\n________________\n\n\nDAY 14 — “Command Structure”\nGus:\nWe’ve got numbers now.\nSix new faces since yesterday.\nOne fella brought a toolbox.\nOne brought chickens.\nOne brought opinions.\nJohnson’s been talkin’ more.\nGatherin’ folks in the cafeteria.\nPointin’ at maps.\nAssignin’ shifts.\nSecurity, watch, rationing.\nGood ideas.\nOrganized.\nI don’t mind.\nSomeone’s gotta coordinate.\nAnd I’ve got floors to burnish.\nTruth is—\nI’m better with surfaces than speeches.\nJohnson’s voice carries.\nPeople listen.\nKids stick close to him.\nHe’s got that… projector energy.\nI’ve got extension cords.\nDifferent roles.\nHe asked if we should block off the auditorium.\nSaid it’s a liability.\nI said it’s hardwood.\nNeeds maintenance.\nWe compromised.\nHe runs the perimeter.\nI run the polish.\nFair trade.\nSchool’s busier now.\nBoot prints everywhere.\nBut they wipe ’em.\nMostly.\nI’ll give him this—\nhe enforces “no mud.”\nThat earns respect.\nCommunity’s formmin’.\nHas a rhythm to it.\nNot perfect.\nBut steady.\nAnd steady’s enough.\nGus out.\nAnd if you’re holdin’ a meeting—\npush the chairs back in after.\nDAY 15 — “Unsung”\nGus:\nHeard some whisperin’ yesterday.\nCouple of the new arrivals.\nDidn’t think I could hear.\nSaid I was “just the janitor.”\nSaid it like it meant “optional.”\nNow I don’t take offense.\nPeople don’t understand infrastructure until it fails.\nJohnson’s been restructurin’ space.\nMoved some cots into the library.\nStacked supplies in the teacher’s lounge.\nEfficient enough.\nBut he didn’t account for ventilation.\nOr weight distribution on those old shelves.\nI mentioned it.\nOne of the whisperers rolled his eyes.\nSo I let him.\nThen the rain came.\nHard.\nWater found the weak seam in the east wall.\nRan straight toward the supply stack.\nYou know what keeps food dry?\nWorking drains.\nYou know who clears drains?\nNot the loudest man in the room.\nWe redirected it.\nProper slope.\nMinimal loss.\nAfterward—\nsame fella came up to me.\nDidn’t apologize.\nJust said, “Didn’t think about that.”\nI told him most people don’t.\nThat’s why floors matter.\nThat’s why walls matter.\nThat’s why someone’s gotta notice the little things.\nHe’s been callin’ me “Mr. Gus” since.\nFunny how quick a joke becomes a title.\nI don’t need praise.\nBut I won’t be dismissed either.\nWorld’s still endin’.\nBut it’s standin’ on clean tile.\nGus out.\nAnd check your drains before the storm does.\n________________\n\n\nFINAL TRANSMISSION — “Level”\nGus:\nAlright.\nWe’re packin’ up the radio.\nJohnson says we’ll need it mobile.\nSpread the signal.\nMakes sense.\nSchool’s secure enough now.\nDoesn’t need broadcastin’ as much as it needs maintainin’.\nAnd I’ve got that covered.\nKids are louder these days.\nIn a good way.\nChairs get moved.\nBoots get scuffed.\nLife’s messy.\nI’ve made peace with that.\n…\nThere’s somethin’ I should probably say.\nBeen callin’ my mop Linda.\nSome of you might’ve guessed.\nThat was my wife’s name.\nShe hated when I brought work home.\nSaid I talked more about floor wax than feelings.\nShe wasn’t wrong.\nBut she understood pride.\nUnderstood doin’ a job right.\nUnderstood that order’s a kind of love.\nSo I named the mop after her.\nFigured if I was gonna keep somethin’ upright in this world,\nit might as well carry her name.\n…\nAnd if my boys are out there—\nYou don’t gotta follow in my footsteps.\nYou don’t gotta polish anything.\nJust stand level.\nThat’s enough.\nI love you.\n…\nSchool’s lookin’ good.\nCommunity’s steady.\nJohnson’s got structure.\nErin’s got signal.\nAnd I’ve got tile.\nThat’s a solid foundation.\nThis is Gus.\nSigning off.\nKeep your corners clean.", - "bytes": 18030, - "createdAt": 1771206687678, - "updatedAt": 1771206687678, - "createdBy": "azakaela" - } - ] -} diff --git a/data.bak.20260219-051337/plugins/library/plugin.json b/data.bak.20260219-051337/plugins/library/plugin.json @@ -1,9 +0,0 @@ -{ - "id": "library", - "name": "Library", - "version": "0.2.5", - "description": "Upload PDFs as library posts and read them in-app.", - "entryClient": "client.js", - "entryServer": "server.js", - "permissions": ["ui", "ws"] -} diff --git a/data.bak.20260219-051337/plugins/library/server.js b/data.bak.20260219-051337/plugins/library/server.js @@ -1,514 +0,0 @@ -const crypto = require("crypto"); -const fs = require("fs"); -const path = require("path"); - -const MAX_PDF_BYTES = Number(process.env.LIBRARY_PDF_MAX_BYTES || 50 * 1024 * 1024); // 50MB -const CHUNK_MAX_B64 = Number(process.env.LIBRARY_CHUNK_MAX_B64 || 1024 * 1024); // base64 chars -const MAX_TEXT_BYTES = Number(process.env.LIBRARY_TEXT_MAX_BYTES || 512 * 1024); // 512KB - -function safeJsonParse(str) { - try { - return JSON.parse(str); - } catch { - return null; - } -} - -function readJsonOrNull(filePath) { - try { - const raw = fs.readFileSync(filePath, "utf8"); - return safeJsonParse(raw); - } catch { - return null; - } -} - -function writeFileAtomic(filePath, content) { - const dir = path.dirname(filePath); - fs.mkdirSync(dir, { recursive: true }); - const tmp = `${filePath}.tmp.${crypto.randomBytes(6).toString("hex")}`; - fs.writeFileSync(tmp, content, "utf8"); - fs.renameSync(tmp, filePath); -} - -function nowMs() { - return Date.now(); -} - -function normalizeId(id) { - return String(id || "").trim().toLowerCase(); -} - -function normalizeTitle(title) { - const t = String(title || "").trim().slice(0, 120); - return t || "Untitled PDF"; -} - -function normalizeTextTitle(title) { - const t = String(title || "").trim().slice(0, 120); - return t || "Untitled text"; -} - -function normalizeTextBody(text) { - let t = typeof text === "string" ? text : ""; - t = t.replace(/\r\n/g, "\n"); - if (Buffer.byteLength(t, "utf8") > MAX_TEXT_BYTES) { - // Trim to max bytes (best-effort by characters). - while (t && Buffer.byteLength(t, "utf8") > MAX_TEXT_BYTES) t = t.slice(0, Math.max(0, t.length - 4096)); - while (t && Buffer.byteLength(t, "utf8") > MAX_TEXT_BYTES) t = t.slice(0, Math.max(0, t.length - 256)); - while (t && Buffer.byteLength(t, "utf8") > MAX_TEXT_BYTES) t = t.slice(0, Math.max(0, t.length - 16)); - while (t && Buffer.byteLength(t, "utf8") > MAX_TEXT_BYTES) t = t.slice(0, Math.max(0, t.length - 1)); - } - return t; -} - -function sanitizeFilename(name) { - const base = String(name || "") - .trim() - .toLowerCase() - .replace(/\\.pdf$/i, "") - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/-+/g, "-") - .replace(/^[-.]+|[-.]+$/g, "") - .slice(0, 80); - return base || "pdf"; -} - -function userRole(ws) { - return String(ws?.user?.role || "").trim().toLowerCase(); -} - -function username(ws) { - return String(ws?.user?.username || "").trim().toLowerCase(); -} - -module.exports = function init(api) { - const dataFile = path.join(__dirname, "library.json"); - const uploadsRoot = path.resolve(process.env.UPLOADS_DIR || path.join(process.cwd(), "data", "uploads")); - const uploadsDir = path.join(uploadsRoot, "library"); - const tmpDir = path.join(uploadsDir, "tmp"); - - const inFlight = new Map(); // uploadId -> { fd, tmpPath, metaPath, expected, received, createdAt, createdBy, title, lastSeenAt } - const INFLIGHT_TTL_MS = Number(process.env.LIBRARY_INFLIGHT_TTL_MS || 10 * 60_000); // 10m - - function metaPathFor(uploadId) { - return path.join(tmpDir, `${uploadId}.json`); - } - - function readMeta(metaPath) { - return readJsonOrNull(metaPath); - } - - function writeMeta(metaPath, meta) { - writeFileAtomic(metaPath, JSON.stringify(meta, null, 2) + "\n"); - } - - function tryResumeInflight(uploadId, user) { - const metaPath = metaPathFor(uploadId); - const meta = readMeta(metaPath); - if (!meta || String(meta.uploadId || "") !== uploadId) return null; - if (String(meta.createdBy || "") !== String(user || "")) return null; - const tmpPath = String(meta.tmpPath || ""); - if (!tmpPath) return null; - if (!fs.existsSync(tmpPath)) return null; - const expected = Number(meta.expected || 0); - if (!Number.isFinite(expected) || expected <= 0 || expected > MAX_PDF_BYTES) return null; - - let received = 0; - try { - received = Number(fs.statSync(tmpPath).size || 0); - } catch { - received = 0; - } - if (!Number.isFinite(received) || received < 0 || received > expected) return null; - - let fd = null; - try { - fd = fs.openSync(tmpPath, "r+"); - } catch { - return null; - } - const t = nowMs(); - const rec = { - fd, - tmpPath, - metaPath, - expected, - received, - createdAt: Number(meta.createdAt || t), - createdBy: String(meta.createdBy || ""), - title: String(meta.title || ""), - lastSeenAt: t - }; - inFlight.set(uploadId, rec); - api.log("info", "library:uploadResumed", { uploadId, user: rec.createdBy, received, expected }); - return rec; - } - - function loadItems() { - const parsed = readJsonOrNull(dataFile); - const items = Array.isArray(parsed?.items) ? parsed.items : []; - return items - .map((it) => ({ - id: normalizeId(it?.id), - kind: String(it?.kind || "pdf") === "text" ? "text" : "pdf", - title: String(it?.kind || "pdf") === "text" ? normalizeTextTitle(it?.title) : normalizeTitle(it?.title), - url: String(it?.url || ""), - filename: String(it?.filename || ""), - bytes: Number(it?.bytes || 0), - createdAt: Number(it?.createdAt || 0), - createdBy: String(it?.createdBy || ""), - updatedAt: Number(it?.updatedAt || 0), - text: typeof it?.text === "string" ? it.text : "", - })) - .filter((it) => { - if (!it.id) return false; - if (it.kind === "pdf") return Boolean(it.url && it.filename); - return true; - }); - } - - function saveItems(items) { - writeFileAtomic(dataFile, JSON.stringify({ version: 1, items }, null, 2) + "\n"); - } - - function send(ws, msg) { - try { - ws.send(JSON.stringify(msg)); - return true; - } catch { - return false; - } - } - - function sendError(ws, message, data) { - send(ws, { type: "plugin:library:error", message: String(message || "Error."), data: data || null }); - } - - function listForClient() { - const items = loadItems(); - items.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0)); - return items.map((it) => { - if (it.kind === "text") { - return { - id: it.id, - kind: "text", - title: it.title, - bytes: Number(it.bytes || Buffer.byteLength(String(it.text || ""), "utf8")), - createdAt: it.createdAt, - createdBy: it.createdBy, - updatedAt: it.updatedAt || it.createdAt, - }; - } - return { - id: it.id, - kind: "pdf", - title: it.title, - url: it.url, - filename: it.filename, - bytes: it.bytes, - createdAt: it.createdAt, - createdBy: it.createdBy, - }; - }); - } - - // Important: do not delete inflight uploads on WS close. Reconnects are common, and - // the client may continue the upload on a new socket. Instead, time out abandoned uploads. - setInterval(() => { - const t = nowMs(); - for (const [uploadId, rec] of inFlight.entries()) { - const last = Number(rec?.lastSeenAt || rec?.createdAt || 0); - if (!last || t - last <= INFLIGHT_TTL_MS) continue; - try { - if (rec.fd) fs.closeSync(rec.fd); - } catch { - // ignore - } - try { - if (rec.tmpPath) fs.unlinkSync(rec.tmpPath); - } catch { - // ignore - } - try { - if (rec.metaPath && fs.existsSync(rec.metaPath)) fs.unlinkSync(rec.metaPath); - } catch { - // ignore - } - inFlight.delete(uploadId); - api.log("info", "library:uploadTimeout", { uploadId, user: rec?.createdBy || "", received: rec?.received || 0, expected: rec?.expected || 0 }); - } - }, 60_000).unref?.(); - - api.registerWs("list", (ws) => { - send(ws, { type: "plugin:library:list", items: listForClient() }); - }); - - api.registerWs("textGet", (ws, msg) => { - const u = username(ws); - if (!u) return sendError(ws, "Sign in required."); - const id = normalizeId(msg?.id); - if (!id) return sendError(ws, "Missing id."); - const items = loadItems(); - const it = items.find((x) => x.id === id); - if (!it || it.kind !== "text") return sendError(ws, "Not found."); - send(ws, { - type: "plugin:library:text", - item: { - id: it.id, - kind: "text", - title: it.title, - text: String(it.text || ""), - bytes: Number(it.bytes || Buffer.byteLength(String(it.text || ""), "utf8")), - createdAt: it.createdAt, - createdBy: it.createdBy, - updatedAt: it.updatedAt || it.createdAt, - }, - }); - }); - - api.registerWs("textCreate", (ws, msg) => { - const u = username(ws); - if (!u) return sendError(ws, "Sign in required."); - const title = normalizeTextTitle(msg?.title); - const text = normalizeTextBody(msg?.text); - const bytes = Buffer.byteLength(text, "utf8"); - if (bytes > MAX_TEXT_BYTES) return sendError(ws, `Text too large. Max is ${MAX_TEXT_BYTES} bytes.`); - - const items = loadItems(); - const id = crypto.randomBytes(10).toString("hex"); - const t = nowMs(); - items.push({ - id, - kind: "text", - title, - text, - bytes, - createdAt: t, - updatedAt: t, - createdBy: u, - }); - saveItems(items); - api.log("info", "library:textCreate", { id, bytes, user: u }); - send(ws, { type: "plugin:library:textCreated", ok: true, id }); - api.broadcast({ type: "plugin:library:changed" }); - }); - - api.registerWs("textUpdate", (ws, msg) => { - const u = username(ws); - if (!u) return sendError(ws, "Sign in required."); - const id = normalizeId(msg?.id); - if (!id) return sendError(ws, "Missing id."); - - const items = loadItems(); - const idx = items.findIndex((x) => x.id === id); - if (idx < 0) return sendError(ws, "Not found."); - const it = items[idx]; - if (it.kind !== "text") return sendError(ws, "Not found."); - if (String(it.createdBy || "") !== u) return sendError(ws, "Only the author can edit this text."); - - const title = normalizeTextTitle(msg?.title ?? it.title); - const text = normalizeTextBody(msg?.text); - const bytes = Buffer.byteLength(text, "utf8"); - if (bytes > MAX_TEXT_BYTES) return sendError(ws, `Text too large. Max is ${MAX_TEXT_BYTES} bytes.`); - - const t = nowMs(); - items[idx] = { ...it, title, text, bytes, updatedAt: t }; - saveItems(items); - api.log("info", "library:textUpdate", { id, bytes, user: u }); - send(ws, { type: "plugin:library:textUpdated", ok: true, id, updatedAt: t }); - api.broadcast({ type: "plugin:library:changed" }); - }); - - api.registerWs("uploadStart", (ws, msg) => { - const u = username(ws); - if (!u) return sendError(ws, "Sign in required."); - - const size = Number(msg?.size || 0); - if (!Number.isFinite(size) || size <= 0) return sendError(ws, "Invalid file size."); - if (size > MAX_PDF_BYTES) return sendError(ws, `PDF too large. Max is ${MAX_PDF_BYTES} bytes.`); - - const original = String(msg?.filename || "").trim(); - const mime = String(msg?.mime || "").trim().toLowerCase(); - const isPdf = /\\.pdf$/i.test(original) || mime === "application/pdf"; - if (!isPdf) return sendError(ws, "Only PDF files are supported."); - - const title = normalizeTitle(msg?.title || original.replace(/\\.pdf$/i, "")); - const uploadId = crypto.randomBytes(12).toString("hex"); - - fs.mkdirSync(tmpDir, { recursive: true }); - const tmpPath = path.join(tmpDir, `${uploadId}.part`); - const metaPath = metaPathFor(uploadId); - let fd = null; - try { - fd = fs.openSync(tmpPath, "w"); - } catch (e) { - return sendError(ws, "Failed to start upload.", { error: e?.message || String(e) }); - } - - const t = nowMs(); - try { - writeMeta(metaPath, { version: 1, uploadId, tmpPath, expected: size, received: 0, createdAt: t, createdBy: u, title }); - } catch (e) { - try { - fs.closeSync(fd); - } catch { - // ignore - } - try { - fs.unlinkSync(tmpPath); - } catch { - // ignore - } - return sendError(ws, "Failed to start upload.", { error: e?.message || String(e) }); - } - - inFlight.set(uploadId, { fd, tmpPath, metaPath, expected: size, received: 0, createdAt: t, createdBy: u, title, lastSeenAt: t }); - api.log("info", "library:uploadStart", { uploadId, size, user: u }); - send(ws, { type: "plugin:library:uploadStarted", uploadId, maxBytes: MAX_PDF_BYTES }); - }); - - api.registerWs("uploadChunk", (ws, msg) => { - const u = username(ws); - if (!u) return sendError(ws, "Sign in required."); - const uploadId = normalizeId(msg?.uploadId); - let rec = inFlight.get(uploadId); - if (!rec) rec = tryResumeInflight(uploadId, u); - if (!rec || rec.createdBy !== u) return sendError(ws, "Upload not found."); - - const b64 = typeof msg?.data === "string" ? msg.data : ""; - if (!b64) return sendError(ws, "Missing chunk data."); - if (b64.length > CHUNK_MAX_B64) return sendError(ws, "Chunk too large."); - - let buf = null; - try { - buf = Buffer.from(b64, "base64"); - } catch (e) { - return sendError(ws, "Invalid base64 chunk.", { error: e?.message || String(e) }); - } - if (!buf.length) return sendError(ws, "Empty chunk."); - - const next = rec.received + buf.length; - if (next > rec.expected) return sendError(ws, "Upload exceeds expected size."); - - try { - fs.writeSync(rec.fd, buf, 0, buf.length, rec.received); - } catch (e) { - return sendError(ws, "Failed writing chunk.", { error: e?.message || String(e) }); - } - rec.received = next; - rec.lastSeenAt = nowMs(); - inFlight.set(uploadId, rec); - try { - writeMeta(rec.metaPath, { - version: 1, - uploadId, - tmpPath: rec.tmpPath, - expected: rec.expected, - received: rec.received, - createdAt: rec.createdAt, - createdBy: rec.createdBy, - title: rec.title, - }); - } catch { - // ignore - } - - if (rec.received % (1024 * 1024) < buf.length) { - send(ws, { type: "plugin:library:uploadProgress", uploadId, received: rec.received, expected: rec.expected }); - } - }); - - api.registerWs("uploadFinish", (ws, msg) => { - const u = username(ws); - if (!u) return sendError(ws, "Sign in required."); - const uploadId = normalizeId(msg?.uploadId); - let rec = inFlight.get(uploadId); - if (!rec) rec = tryResumeInflight(uploadId, u); - if (!rec || rec.createdBy !== u) return sendError(ws, "Upload not found."); - - if (rec.received !== rec.expected) { - return sendError(ws, "Upload incomplete.", { received: rec.received, expected: rec.expected }); - } - - try { - fs.closeSync(rec.fd); - } catch { - // ignore - } - - fs.mkdirSync(uploadsDir, { recursive: true }); - const stamp = new Date(rec.createdAt).toISOString().replace(/[:.]/g, "-"); - const safe = sanitizeFilename(rec.title); - const finalName = `${safe}-${stamp}-${crypto.randomBytes(4).toString("hex")}.pdf`; - const finalPath = path.join(uploadsDir, finalName); - try { - fs.renameSync(rec.tmpPath, finalPath); - } catch (e) { - try { - fs.unlinkSync(rec.tmpPath); - } catch { - // ignore - } - inFlight.delete(uploadId); - return sendError(ws, "Failed to finalize upload.", { error: e?.message || String(e) }); - } - - const items = loadItems(); - const itemId = crypto.randomBytes(10).toString("hex"); - const item = { - id: itemId, - kind: "pdf", - title: rec.title, - filename: finalName, - url: `/uploads/library/${finalName}`, - bytes: rec.expected, - createdAt: rec.createdAt, - createdBy: u, - }; - items.push(item); - saveItems(items); - inFlight.delete(uploadId); - try { - if (rec.metaPath && fs.existsSync(rec.metaPath)) fs.unlinkSync(rec.metaPath); - } catch { - // ignore - } - - api.log("info", "library:uploadFinish", { id: itemId, bytes: rec.expected, user: u }); - send(ws, { type: "plugin:library:uploadFinished", ok: true, item }); - api.broadcast({ type: "plugin:library:changed" }); - }); - - api.registerWs("delete", (ws, msg) => { - const u = username(ws); - if (!u) return sendError(ws, "Sign in required."); - const id = normalizeId(msg?.id); - if (!id) return sendError(ws, "Missing id."); - - const role = userRole(ws); - const isOwner = role === "owner"; - - const items = loadItems(); - const idx = items.findIndex((it) => it.id === id); - if (idx < 0) return sendError(ws, "Not found."); - const item = items[idx]; - if (!isOwner && item.createdBy !== u) return sendError(ws, "Not allowed."); - - items.splice(idx, 1); - saveItems(items); - - if (item.kind === "pdf") { - const filePath = path.join(uploadsDir, item.filename); - try { - if (fs.existsSync(filePath)) fs.unlinkSync(filePath); - } catch { - // ignore - } - } - - api.log("info", "library:delete", { id, user: u }); - send(ws, { type: "plugin:library:deleted", ok: true, id }); - api.broadcast({ type: "plugin:library:changed" }); - }); -}; diff --git a/data.bak.20260219-051337/plugins/maps/client.js b/data.bak.20260219-051337/plugins/maps/client.js @@ -1,3995 +0,0 @@ -(function () { - if (!window.BzlPluginHost) return; - - window.BzlPluginHost.register("maps", (ctx) => { - const ws = window.__bzlWs; - if (!ws) return; - - const devLog = (level, message, data) => { - try { - ctx.devLog?.(level, message, data); - } catch { - // ignore - } - }; - - const appRootRef = document.querySelector(".app"); - const inRackMode = (() => { - try { - // Rack mode reloads the page; this flag is available before the DOM gets the .rackMode class. - if (localStorage.getItem("bzl_rackLayout_enabled") === "1") return true; - } catch { - // ignore - } - return Boolean(appRootRef?.classList.contains("rackMode")); - })(); - - // In rack mode, Maps should render into its own dockable panel (not inside the Hives panel). - if (inRackMode && ctx?.ui?.registerPanel) { - try { - ctx.ui.registerPanel({ - id: "maps", - title: "Maps", - icon: "🗺️", - defaultRack: "main", - role: "primary", - render() { - // no-op: this plugin uses DOM mounting below, into the panel shell's mount node - }, - }); - } catch { - // ignore - } - } - - let mainPanel = document.querySelector(".main .panelFill"); - if (inRackMode) { - const shell = document.querySelector('.panel.pluginPanel[data-panel-id="maps"]'); - if (shell instanceof HTMLElement) mainPanel = shell; - } - - const panelHeader = mainPanel ? mainPanel.querySelector(".panelHeader") : null; - const panelTitle = panelHeader ? panelHeader.querySelector(".panelTitle") : null; - const filters = panelHeader ? panelHeader.querySelector(".filters") : null; - const hiveTabs = inRackMode ? null : document.getElementById("hiveTabs"); - const feed = inRackMode ? null : document.getElementById("feed"); - const pollinatePanel = inRackMode ? null : document.getElementById("pollinatePanel"); - const chatPanel = inRackMode ? null : document.querySelector(".chat"); - const chatResizeHandle = inRackMode ? null : document.getElementById("chatResizeHandle"); - const appRoot = inRackMode ? null : appRootRef; - - if (!mainPanel || !panelHeader || !panelTitle) return; - - const style = document.createElement("style"); - style.textContent = ` - .mapsTabBtn { margin-left: 10px; } - .mapsPanel.hidden { display: none; } - .mapsPanel { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; display:flex; flex-direction: column; } - .app.mapsRoom .chat { display: none !important; } - .app.mapsRoom .chatResizeHandle { display: none !important; } - /* Keep core resize handles working in map mode by preserving grid areas. */ - @media (min-width: 761px) { - .app.mapsRoom { - grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr !important; - grid-template-areas: "sidebar sidebarResize main" !important; - } - .app.mapsRoom.hasMod { - grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr 10px minmax(280px, var(--mod-width)) !important; - grid-template-areas: "sidebar sidebarResize main mainResize moderation" !important; - } - .app.mapsRoom.sidebarHidden { - grid-template-columns: 1fr !important; - grid-template-areas: "main" !important; - } - .app.mapsRoom.sidebarHidden.hasMod { - grid-template-columns: 1fr 10px minmax(280px, var(--mod-width)) !important; - grid-template-areas: "main mainResize moderation" !important; - } - } - .mapsTop { padding: 12px 12px 0; display:flex; justify-content: space-between; align-items: center; gap: 10px; } - .mapsTopTitle { font-weight: 900; } - .mapCreateWrap { padding: 12px; } - .mapCreateCard { border: 1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(255,255,255,0.02); padding: 10px; } - .mapCreateGrid { display:grid; grid-template-columns: 1fr 1fr; gap: 10px; align-items: end; } - .mapCreateGrid label span { display:block; font-size: 12px; color: rgba(246,240,255,0.72); margin-bottom: 4px; } - .mapCreateGrid input[type="text"] { width: 100%; } - .mapCreateRow { display:flex; gap: 10px; align-items: center; justify-content: flex-end; margin-top: 10px; } - .mapRangeRow { display:flex; gap: 10px; align-items: center; } - .mapRangeRow input[type="range"] { flex: 1; } - .mapRangeVal { width: 44px; text-align: right; font-variant-numeric: tabular-nums; } - .mapsGrid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; padding: 12px; } - .mapCard { border: 1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(255,255,255,0.02); padding: 10px; } - .mapThumb { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; border: 1px solid rgba(246,240,255,0.10); object-fit: cover; background: rgba(255,255,255,0.02); } - .mapTitle { font-weight: 800; margin-top: 8px; } - .mapMeta { margin-top: 6px; color: rgba(246,240,255,0.72); font-size: 12px; display:flex; justify-content: space-between; gap: 10px; } - .mapEnterRow { margin-top: 10px; display:flex; justify-content:flex-end; gap: 8px; } - .mapView { display:flex; gap: 12px; padding: 12px; min-height: 0; flex: 1; align-items: stretch; } - .mapCanvasWrap { flex: 1; border: 1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(0,0,0,0.18); position: relative; overflow:hidden; min-height: 360px; } - .mapCanvas { width: 100%; height: 100%; display:block; } - .mapHud { width: 240px; border: 1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(255,255,255,0.02); padding: 10px; min-height: 0; height: 100%; max-height: 100%; overflow-y: auto; overflow-x: hidden; } - .mapHudTitle { font-weight: 800; display:flex; justify-content: space-between; align-items:center; gap: 8px; } - .mapHudList { margin-top: 10px; display:flex; flex-direction: column; gap: 8px; max-height: 340px; overflow:auto; } - .mapHint { margin-top: 10px; color: rgba(246,240,255,0.72); font-size: 12px; line-height: 1.05rem; } - .mapChatOverlay { position:absolute; left: 12px; right: 12px; bottom: 12px; display:flex; gap: 8px; } - .mapChatOverlay input { flex:1; } - .mapWalkieBar { position:absolute; left: 12px; right: 12px; bottom: 12px; display:flex; justify-content:center; pointer-events:none; } - .mapWalkieBarInner { pointer-events:auto; display:flex; gap: 10px; align-items:center; width: min(520px, 100%); } - .mapWalkieBtn { flex: 1; height: 44px; border-radius: 14px; font-weight: 900; letter-spacing: 0.01em; } - .mapWalkieHint { font-size: 12px; color: rgba(246,240,255,0.75); white-space: nowrap; } - .mapsRoomWrap { display:flex; flex-direction: column; min-height: 0; flex: 1; } - .mapDock { border: 1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(255,255,255,0.02); margin: 0 12px 12px; padding: 10px 12px; display:flex; flex-direction: column; min-height: 0; max-height: min(46vh, 520px); overflow:hidden; } - .mapDock.collapsed { max-height: none; } - .mapDock.collapsed .dockBody { display:none; } - .dockHeaderRow { margin-bottom: 0; } - .dockBody { flex: 1; min-height: 0; overflow:auto; padding-right: 4px; padding-top: 8px; } - .dockRow { display:flex; gap: 10px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; } - .dockTitle { font-weight: 900; } - .dockRow input[type="file"] { flex: 1 1 240px; max-width: 360px; } - .dockRow input[type="text"] { flex: 1 1 220px; min-width: 180px; } - .dockRow input[type="number"] { width: 92px; } - .dockScale { display:flex; gap: 8px; align-items:center; min-width: 160px; flex: 1 1 160px; } - .dockScale input[type="range"] { flex: 1; } - .dockScaleVal { width: 52px; text-align:right; font-variant-numeric: tabular-nums; color: rgba(246,240,255,0.78); } - .spriteTray { display:flex; gap: 8px; overflow:auto; padding: 8px; border: 1px solid rgba(246,240,255,0.10); border-radius: 12px; background: rgba(0,0,0,0.12); } - .spriteThumb { width: 56px; height: 56px; border-radius: 12px; border: 1px solid rgba(246,240,255,0.14); background: rgba(255,255,255,0.02); overflow:hidden; padding: 0; display:flex; } - .spriteThumb img { width:100%; height:100%; object-fit: cover; display:block; } - .spriteThumb.selected { outline: 2px solid rgba(255,62,165,0.80); } - - /* Polygon editor: render inline in the same panel as the toggle (no full-screen modal). */ - .mapsPolyModal { display:block; margin-top: 12px; } - .mapsPolyModalInner { width: 100%; overflow:hidden; border-radius: 18px; border: 1px solid rgba(246,240,255,0.14); background: linear-gradient(180deg, rgba(30,20,38,0.96), rgba(12,10,18,0.96)); box-shadow: 0 18px 60px rgba(0,0,0,0.35); padding: 14px; display:flex; flex-direction:column; } - .mapsPolyHeader { display:flex; justify-content:space-between; gap: 14px; align-items:flex-start; } - .mapsPolyTitle { font-weight: 900; font-size: 16px; } - .mapsPolyGrid { margin-top: 12px; display:grid; grid-template-columns: 1fr; gap: 12px; min-height: 0; flex: 1; } - .mapsPolyList { min-height: 0; overflow:auto; border: 1px solid rgba(246,240,255,0.10); border-radius: 14px; background: rgba(0,0,0,0.10); padding: 8px; } - .mapsPolyInspector { min-height: 0; overflow:auto; border: 1px solid rgba(246,240,255,0.10); border-radius: 14px; background: rgba(0,0,0,0.10); padding: 10px; } - .polyRowBtn { width: 100%; text-align:left; padding: 10px; border-radius: 12px; border: 1px solid rgba(246,240,255,0.10); background: rgba(255,255,255,0.02); margin-bottom: 8px; cursor:pointer; } - .polyRowBtn:hover { border-color: rgba(246,240,255,0.18); background: rgba(255,255,255,0.03); } - .polyRowBtn.selected { outline: 2px solid rgba(255,62,165,0.55); border-color: rgba(255,62,165,0.55); } - .polyRowMain { font-weight: 800; } - .polyRowMeta { margin-top: 2px; font-size: 12px; color: rgba(246,240,255,0.62); } - `; - document.head.appendChild(style); - - const mapsBtn = inRackMode - ? null - : (() => { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "ghost smallBtn mapsTabBtn"; - btn.textContent = "Maps"; - panelTitle.insertAdjacentElement("afterend", btn); - return btn; - })(); - - const mapsPanel = document.createElement("div"); - mapsPanel.className = inRackMode ? "mapsPanel" : "mapsPanel hidden"; - const mount = inRackMode ? mainPanel.querySelector("[data-pluginmount]") : null; - (mount || mainPanel).appendChild(mapsPanel); - - let mode = inRackMode ? "maps" : "hives"; // "hives" | "maps" | "map" - let maps = []; - let activeMap = null; - let users = new Map(); // username -> {x,y,color,image} - let bubbles = new Map(); // username -> {text, expiresAt} - const avatarCache = new Map(); // username -> {src:string,img:HTMLImageElement|null,status:"loading"|"ok"|"error",failedAt:number} - let self = ""; - let localPos = { x: 0.5, y: 0.5 }; - let keys = new Set(); - let raf = 0; - let lastTick = 0; - let lastSentAt = 0; - let moveSeq = 1; - let bgImg = null; - let cameraPos = null; // {x,y} in normalized 0..1 - let createIdTouched = false; - let mapAvatarSaveTimer = 0; - let mapZoomSaveTimer = 0; - let lastEditModeLogged = false; - let lastPolyUiLogAt = 0; - let editMode = false; - let editKind = "collision"; // "collision" | "mask" | "exit" | "hidden" | "fall" | "occluder" - let editTool = "draw"; // "draw" | "select" | "move" | "vertex" - let selectedPolyKind = ""; - let selectedPolyIndex = -1; - let selectedVertexIndex = -1; - let polyClipboard = null; // { kind, poly } - let polyDrag = null; // { kind, index, start:{x,y}, origPoints:[{x,y}] } - let vertexDrag = null; // { kind, index, vIdx:number } - - // Exit metadata (used both for "new exit defaults" and for selected-exit edits) - let exitAction = "toMaps"; // "toMaps" | "toMap" - let exitTargetMapId = ""; - let exitTargetExitName = ""; - let exitDraftName = ""; - // Fog metadata ("hiddenMasks") - let fogDraftMode = "auto"; // "auto" | "manual" - let fogDraftName = ""; - // Fall-through metadata - let fallDraftDirection = "down"; // "down" | "up" | "left" | "right" - let fallDraftOffset = 0.02; // normalized units (0..1) - let fallDraftName = ""; - // Fog reveal toggle (per-map, local-only) - let revealFog = false; - let draftPoly = []; // points [{x,y}] in normalized - let lastTransform = null; // {srcX,srcY,zoom,worldW,worldH,viewW,viewH} - let selfInvisible = false; - let panning = false; - let panStart = null; // {x,y,cx,cy} - let audioCtx = null; - let audioWarned = false; - let walkieStream = null; - let walkieRecorder = null; - let walkieChunks = []; - let walkieRecording = false; - let walkieStartAt = 0; - const walkiePlaybacks = new Map(); // id -> {audio, gain, pan, filter?, interval?, ackTimer?} - const exitInside = new Map(); // idx -> boolean - let lastExitAt = 0; - let pendingSpawn = null; // { mapId, exitName } - let selectedSpriteId = ""; - let selectedPropId = ""; - let spriteKind = "prop"; // "prop" | "token" (v1 uses props) - let spriteScale = 1.0; - let spriteScaleSaveTimer = 0; - let placeRot = 0; // degrees (-180..180) - const spriteImageCache = new Map(); // url -> {img,status:"loading"|"ok"|"error",failedAt:number} - let propDrag = null; // {propId, offsetX, offsetY} - let propDragMoved = false; - let lastPropMoveAt = 0; - let canManageTtrpg = false; - let ttrpgTool = "select"; // "select" | "place" | "pan" - let placeScale = 1.0; - let speakingAsPropId = ""; - let ttrpgDockCollapsed = false; - - function setHidden(el, hidden) { - if (!el) return; - el.classList.toggle("hidden", Boolean(hidden)); - } - - function getSessionToken() { - try { - return localStorage.getItem("bzl_session_token") || ""; - } catch { - return ""; - } - } - - function dockCollapsedKey(mapId) { - const id = String(mapId || "") - .trim() - .toLowerCase(); - const safe = id && /^[a-z0-9][a-z0-9_-]{0,40}$/.test(id) ? id : "default"; - return `bzl_maps_dockCollapsed_${safe}`; - } - - function fogRevealKey(mapId) { - const id = String(mapId || "") - .trim() - .toLowerCase(); - const safe = id && /^[a-z0-9][a-z0-9_-]{0,40}$/.test(id) ? id : "default"; - return `bzl_maps_revealFog_${safe}`; - } - - function getFogReveal(mapId) { - try { - return localStorage.getItem(fogRevealKey(mapId)) === "1"; - } catch { - return false; - } - } - - function setFogReveal(mapId, on) { - revealFog = Boolean(on); - try { - localStorage.setItem(fogRevealKey(mapId), revealFog ? "1" : "0"); - } catch { - // ignore - } - } - - function readDockCollapsed(mapId) { - try { - return localStorage.getItem(dockCollapsedKey(mapId)) === "1"; - } catch { - return false; - } - } - - function writeDockCollapsed(mapId, collapsed) { - ttrpgDockCollapsed = Boolean(collapsed); - try { - localStorage.setItem(dockCollapsedKey(mapId), ttrpgDockCollapsed ? "1" : "0"); - } catch { - // ignore - } - } - - function slugifyId(title) { - const t = String(title || "") - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 28); - if (!t) return ""; - const first = t[0]; - if (!/[a-z0-9]/.test(first)) return `map-${t}`.slice(0, 31); - return t; - } - - async function uploadImageFile(file) { - const token = getSessionToken(); - if (!token) throw new Error("Sign in required."); - const maxBytes = 20 * 1024 * 1024; - if (Number(file?.size || 0) > maxBytes) throw new Error("Map image too large (max 20 MB)."); - const name = String(file?.name || "").toLowerCase(); - const guessed = - name.endsWith(".png") - ? "image/png" - : name.endsWith(".jpg") || name.endsWith(".jpeg") - ? "image/jpeg" - : name.endsWith(".gif") - ? "image/gif" - : name.endsWith(".webp") - ? "image/webp" - : ""; - const contentType = file.type || guessed || ""; - if (!contentType.startsWith("image/")) throw new Error("Unsupported image type."); - const res = await fetch("/api/upload?kind=image&purpose=map", { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": contentType - }, - body: file - }); - const json = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(String(json?.error || "Upload failed.")); - if (!json?.url) throw new Error("Upload failed."); - return String(json.url); - } - - async function uploadSpriteImageFile(file) { - const token = getSessionToken(); - if (!token) throw new Error("Sign in required."); - const maxBytes = 10 * 1024 * 1024; - if (Number(file?.size || 0) > maxBytes) throw new Error("Sprite image too large (max 10 MB)."); - const name = String(file?.name || "").toLowerCase(); - const isPng = name.endsWith(".png") || file.type === "image/png"; - const isWebp = name.endsWith(".webp") || file.type === "image/webp"; - if (!isPng && !isWebp) throw new Error("Sprites must be PNG or WebP (transparency)."); - const contentType = isWebp ? "image/webp" : "image/png"; - const res = await fetch("/api/upload?kind=image&purpose=sprite", { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": contentType - }, - body: file - }); - const json = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(String(json?.error || "Upload failed.")); - if (!json?.url) throw new Error("Upload failed."); - return String(json.url); - } - - async function uploadAudioBlob(blob, filenameHint = "walkie.webm") { - const token = getSessionToken(); - if (!token) throw new Error("Sign in required."); - const name = String(filenameHint || "").toLowerCase(); - const guessed = - name.endsWith(".wav") - ? "audio/wav" - : name.endsWith(".ogg") - ? "audio/ogg" - : name.endsWith(".m4a") - ? "audio/mp4" - : name.endsWith(".aac") - ? "audio/aac" - : "audio/webm"; - const rawType = typeof blob?.type === "string" ? blob.type : ""; - const contentType = (rawType.split(";")[0] || "").trim().toLowerCase() || guessed; - if (!contentType.startsWith("audio/")) throw new Error("Unsupported audio type."); - const res = await fetch("/api/upload?kind=audio", { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": contentType - }, - body: blob - }); - const json = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(String(json?.error || "Upload failed.")); - if (!json?.url) throw new Error("Upload failed."); - return String(json.url); - } - - function ensureAudioContext() { - if (audioCtx) return audioCtx; - const Ctx = window.AudioContext || window.webkitAudioContext; - if (!Ctx) return null; - audioCtx = new Ctx(); - return audioCtx; - } - - async function ensureAudioReady() { - const ctxA = ensureAudioContext(); - if (!ctxA) return false; - try { - if (ctxA.state !== "running") await ctxA.resume(); - return true; - } catch { - return false; - } - } - - function clamp(n, a, b) { - const x = Number(n); - if (!Number.isFinite(x)) return a; - return Math.max(a, Math.min(b, x)); - } - - function computeWalkieSpatial(from, to, dims) { - const dx = (Number(from.x || 0) - Number(to.x || 0)) * dims.w; - const dy = (Number(from.y || 0) - Number(to.y || 0)) * dims.h; - const dist = Math.hypot(dx, dy); - const base = Math.min(dims.w, dims.h); - const radius = clamp(base * 0.28, 280, 680); - const v = Math.max(0, 1 - dist / radius); - const vol = v * v; - const pan = clamp(dx / radius, -1, 1); - const cutoff = 600 + 7400 * vol; - return { vol, pan, cutoff }; - } - - async function ensureWalkieStream() { - if (walkieStream) return walkieStream; - if (!navigator.mediaDevices?.getUserMedia) throw new Error("Mic not supported in this browser."); - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - walkieStream = stream; - return stream; - } - - function pickRecorderMime() { - if (!window.MediaRecorder) return ""; - const prefs = ["audio/webm;codecs=opus", "audio/ogg;codecs=opus", "audio/webm", "audio/ogg"]; - for (const t of prefs) { - try { - if (MediaRecorder.isTypeSupported(t)) return t; - } catch { - // ignore - } - } - return ""; - } - - async function startWalkie() { - if (walkieRecording) return; - if (!activeMap?.walkiesEnabled) return; - await ensureAudioReady(); - const stream = await ensureWalkieStream(); - const mimeType = pickRecorderMime(); - walkieChunks = []; - walkieRecorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); - walkieRecording = true; - walkieStartAt = Date.now(); - walkieRecorder.ondataavailable = (e) => { - if (e.data && e.data.size) walkieChunks.push(e.data); - }; - walkieRecorder.onstop = async () => { - walkieRecording = false; - const elapsed = Date.now() - walkieStartAt; - if (elapsed < 180) return; - const blob = new Blob(walkieChunks, { type: walkieRecorder?.mimeType || walkieChunks?.[0]?.type || "audio/webm" }); - walkieChunks = []; - const id = `${Date.now()}_${Math.random().toString(16).slice(2)}`; - try { - const url = await uploadAudioBlob(blob, "walkie.webm"); - // Play locally immediately using spatial audio too. - playWalkie({ id, username: String(ctx.getUser() || "").trim().toLowerCase(), url, x: localPos.x, y: localPos.y }); - ctx.send("walkieSend", { id, url, x: localPos.x, y: localPos.y }); - } catch (e) { - ctx.toast("Walkie", String(e?.message || e)); - } - }; - walkieRecorder.start(); - } - - function stopWalkie() { - if (!walkieRecording || !walkieRecorder) return; - try { - walkieRecorder.stop(); - } catch { - // ignore - } - } - - function stopAllWalkies() { - for (const entry of walkiePlaybacks.values()) { - try { - if (entry.interval) clearInterval(entry.interval); - if (entry.ackTimer) clearTimeout(entry.ackTimer); - entry.audio?.pause?.(); - entry.audio?.remove?.(); - } catch { - // ignore - } - } - walkiePlaybacks.clear(); - } - - async function playWalkie(msg) { - const id = String(msg?.id || "").trim(); - const url = String(msg?.url || "").trim(); - const username = String(msg?.username || "").trim().toLowerCase(); - if (!id || !url || !username) return; - if (walkiePlaybacks.has(id)) return; - if (!activeMap?.walkiesEnabled) return; - - const ok = await ensureAudioReady(); - if (!ok) { - if (!audioWarned) { - audioWarned = true; - ctx.toast("Audio", "Click or press a key once to enable audio playback."); - } - return; - } - - const dims = getWorldDims(); - const from = { x: Number(msg.x || 0), y: Number(msg.y || 0) }; - const to = { x: Number(localPos.x || 0), y: Number(localPos.y || 0) }; - const spatial = computeWalkieSpatial(from, to, dims); - - const a = document.createElement("audio"); - a.src = url; - a.preload = "auto"; - a.crossOrigin = "anonymous"; - a.style.display = "none"; - document.body.appendChild(a); - - const ac = ensureAudioContext(); - let source = null; - let gain = null; - let pan = null; - let filter = null; - try { - source = ac.createMediaElementSource(a); - gain = ac.createGain(); - gain.gain.value = spatial.vol; - filter = ac.createBiquadFilter(); - filter.type = "lowpass"; - filter.frequency.value = spatial.cutoff; - if (ac.createStereoPanner) { - pan = ac.createStereoPanner(); - pan.pan.value = spatial.pan; - source.connect(filter); - filter.connect(pan); - pan.connect(gain); - gain.connect(ac.destination); - } else { - source.connect(filter); - filter.connect(gain); - gain.connect(ac.destination); - } - } catch (e) { - try { - a.remove(); - } catch { - // ignore - } - return; - } - - const ack = () => { - if (!activeMap?.id) return; - ctx.send("walkiePlayed", { id }); - }; - - const cleanup = () => { - const entry = walkiePlaybacks.get(id); - if (!entry) return; - try { - if (entry.interval) clearInterval(entry.interval); - if (entry.ackTimer) clearTimeout(entry.ackTimer); - } catch { - // ignore - } - try { - a.pause(); - } catch { - // ignore - } - try { - a.remove(); - } catch { - // ignore - } - walkiePlaybacks.delete(id); - }; - - a.onended = () => { - ack(); - cleanup(); - }; - a.onerror = () => { - ack(); - cleanup(); - }; - - const interval = setInterval(() => { - const u = users.get(username); - const fx = u && typeof u.tx === "number" ? u.tx : from.x; - const fy = u && typeof u.ty === "number" ? u.ty : from.y; - const sp = computeWalkieSpatial({ x: fx, y: fy }, { x: localPos.x, y: localPos.y }, dims); - if (gain) gain.gain.value = sp.vol; - if (pan) pan.pan.value = sp.pan; - if (filter) filter.frequency.value = sp.cutoff; - }, 120); - - const ackTimer = setTimeout(() => { - ack(); - }, 25_000); - - walkiePlaybacks.set(id, { audio: a, gain, pan, filter, interval, ackTimer }); - try { - await a.play(); - } catch { - // If autoplay blocked, we'll just cleanup (owner can click to enable and retry later). - cleanup(); - } - } - - function enterMaps() { - mode = "maps"; - if (mapsBtn) { - mapsBtn.classList.add("primary"); - mapsBtn.classList.remove("ghost"); - } - setHidden(filters, true); - setHidden(hiveTabs, true); - setHidden(feed, true); - setHidden(pollinatePanel, true); - setHidden(mapsPanel, false); - if (appRoot) appRoot.classList.remove("mapsRoom"); - if (chatPanel) chatPanel.classList.remove("hidden"); - if (chatResizeHandle) chatResizeHandle.classList.remove("hidden"); - renderMapsList(); - ctx.send("list", {}); - } - - function exitMapsToHives() { - mode = "hives"; - if (mapsBtn) { - mapsBtn.classList.add("ghost"); - mapsBtn.classList.remove("primary"); - } - setHidden(filters, false); - setHidden(hiveTabs, false); - setHidden(feed, false); - setHidden(pollinatePanel, false); - setHidden(mapsPanel, true); - if (appRoot) appRoot.classList.remove("mapsRoom"); - if (chatPanel) chatPanel.classList.remove("hidden"); - if (chatResizeHandle) chatResizeHandle.classList.remove("hidden"); - stopLoop(); - stopWalkie(); - stopAllWalkies(); - activeMap = null; - speakingAsPropId = ""; - users.clear(); - bubbles.clear(); - keys.clear(); - } - - function renderMapsList() { - if (!mapsPanel) return; - if (mode !== "maps") return; - const canCreate = ["owner", "moderator"].includes(String(ctx.getRole() || "").toLowerCase()); - const me = String(ctx.getUser() || "").trim().toLowerCase(); - const role = String(ctx.getRole() || "").toLowerCase(); - const createHtml = canCreate - ? ` - <div class="mapCreateWrap"> - <div class="mapCreateCard"> - <div class="mapsTop"> - <div class="mapsTopTitle">Create map</div> - <div class="small muted">Owner/mod only</div> - </div> - <div class="mapCreateGrid"> - <label> - <span>Title</span> - <input id="mapsCreateTitle" type="text" maxlength="60" placeholder="Example: Lounge" /> - </label> - <label> - <span>Map id</span> - <input id="mapsCreateId" type="text" maxlength="31" placeholder="lounge" /> - </label> - <label> - <span>Background image</span> - <input id="mapsCreateFile" type="file" accept="image/*" /> - </label> - <label> - <span>Avatar size</span> - <div class="mapRangeRow"> - <input id="mapsCreateAvatarSize" type="range" min="18" max="96" value="36" /> - <div class="mapRangeVal" id="mapsCreateAvatarVal">36</div> - </div> - </label> - </div> - <div class="mapCreateRow"> - <div class="small muted grow" id="mapsCreateStatus"></div> - <button type="button" class="primary smallBtn" id="mapsCreateBtn">Create</button> - </div> - </div> - </div>` - : ""; - const grid = maps - .map((m) => { - const count = Number(m.userCount || 0) || 0; - const thumb = m.thumbUrl || ""; - const owner = String(m.owner || "").trim().toLowerCase(); - const canManage = role === "owner" || role === "moderator" || (owner && me && owner === me); - return `<div class="mapCard"> - <img class="mapThumb" src="${thumb}" alt="" /> - <div class="mapTitle">${escapeHtml(m.title || m.id)}</div> - <div class="mapMeta"><span>${escapeHtml(m.id)}</span><span>${count} in room</span></div> - <div class="mapEnterRow"> - <button type="button" class="primary smallBtn" data-mapenter="${escapeHtml(m.id)}">Enter</button> - ${canManage && owner ? `<button type="button" class="ghost smallBtn" data-mapdelete="${escapeHtml(m.id)}">Delete</button>` : ""} - </div> - </div>`; - }) - .join(""); - mapsPanel.innerHTML = `${createHtml}<div class="mapsGrid">${grid || `<div class="muted">No maps available.</div>`}</div>`; - - const titleEl = document.getElementById("mapsCreateTitle"); - const idEl = document.getElementById("mapsCreateId"); - const fileEl = document.getElementById("mapsCreateFile"); - const rangeEl = document.getElementById("mapsCreateAvatarSize"); - const rangeVal = document.getElementById("mapsCreateAvatarVal"); - const btnEl = document.getElementById("mapsCreateBtn"); - const statusEl = document.getElementById("mapsCreateStatus"); - if (rangeEl && rangeVal) { - rangeEl.oninput = () => { - rangeVal.textContent = String(rangeEl.value || "36"); - }; - } - if (idEl) { - idEl.oninput = () => { - createIdTouched = true; - }; - } - if (titleEl && idEl) { - titleEl.oninput = () => { - if (createIdTouched) return; - idEl.value = slugifyId(titleEl.value); - }; - } - if (btnEl && titleEl && idEl && fileEl && statusEl && rangeEl) { - btnEl.onclick = async () => { - const title = String(titleEl.value || "").trim(); - const id = String(idEl.value || "").trim().toLowerCase(); - const file = fileEl.files && fileEl.files[0] ? fileEl.files[0] : null; - const avatarSize = Number(rangeEl.value || 36); - if (!title) { - statusEl.textContent = "Title required."; - return; - } - if (!id || !/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) { - statusEl.textContent = "Valid map id required (letters/numbers, '-', '_', '.')."; - return; - } - if (!file) { - statusEl.textContent = "Choose an image file."; - return; - } - btnEl.disabled = true; - statusEl.textContent = "Uploading..."; - try { - const url = await uploadImageFile(file); - statusEl.textContent = "Creating..."; - ctx.send("createMap", { id, title, backgroundUrl: url, thumbUrl: url, avatarSize }); - statusEl.textContent = "Created."; - titleEl.value = ""; - idEl.value = ""; - fileEl.value = ""; - createIdTouched = false; - } catch (e) { - statusEl.textContent = String(e?.message || e); - } finally { - btnEl.disabled = false; - } - }; - } - } - - function renderMapView() { - if (!mapsPanel) return; - if (mode !== "map" || !activeMap) return; - if (appRoot) appRoot.classList.add("mapsRoom"); - if (chatPanel) chatPanel.classList.add("hidden"); - if (chatResizeHandle) chatResizeHandle.classList.add("hidden"); - const title = escapeHtml(activeMap.title || activeMap.id); - const list = Array.from(users.keys()) - .sort((a, b) => a.localeCompare(b)) - .map((u) => `<div class="small">@${escapeHtml(u)}</div>`) - .join(""); - - const role = String(ctx.getRole() || "").toLowerCase(); - const me = String(ctx.getUser() || "").trim().toLowerCase(); - const canManage = role === "owner" || role === "moderator" || (activeMap.owner && me && activeMap.owner === me); - // Moderators/owners can edit maps even if legacy maps have no owner set. - const canEditMap = canManage; - const showSettings = canManage; - canManageTtrpg = Boolean(canManage); - const avatarSize = Number(activeMap.avatarSize || 36); - const cameraZoom = Math.max(0.8, Math.min(5.0, Number(activeMap.cameraZoom || 2.35) || 2.35)); - const polysCount = - (Array.isArray(activeMap.collisions) ? activeMap.collisions.length : 0) + - (Array.isArray(activeMap.masks) ? activeMap.masks.length : 0) + - (Array.isArray(activeMap.exits) ? activeMap.exits.length : 0) + - (Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks.length : 0) + - (Array.isArray(activeMap.fallThroughs) ? activeMap.fallThroughs.length : 0); - const walkiesEnabled = Boolean(activeMap.walkiesEnabled); - const ttrpgEnabled = Boolean(activeMap.ttrpgEnabled); - const fogCount = Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks.length : 0; - const shortcutHintHtml = ` - <div class="mapHint"> - Shortcuts:<br/> - Move: <b>WASD</b> / arrows, Chat: <b>T</b><br/> - ${walkiesEnabled ? `Walkie: hold <b>~</b><br/>` : ""} - ${ttrpgEnabled && canManageTtrpg ? `Tools: <b>V</b> select, <b>P</b> place, hold <b>Space</b> pan<br/>Transform: <b>Q/E</b> rotate, <b>Z/X</b> scale<br/>` : ""} - Leave: click <b>Back</b> - </div> - `; - let polyModalHtml = ""; - if (canEditMap && editMode) { - try { - polyModalHtml = renderPolyModal(); - } catch (e) { - devLog("error", "maps:renderPolyModal failed", { error: e?.message || String(e) }); - const msg = escapeHtml(e?.message || String(e)); - polyModalHtml = ` - <div class="mapsPolyModal" id="mapsPolyModal"> - <div class="mapsPolyModalInner"> - <div class="mapsPolyHeader"> - <div> - <div class="mapsPolyTitle">Polygon editor (fallback)</div> - <div class="small muted">UI render failed. You can still Close polygon and Save.</div> - </div> - </div> - <div class="small muted" style="margin-top:10px;">${msg}</div> - <div class="row" style="gap:10px; flex-wrap:wrap; margin-top:12px;"> - <button type="button" class="ghost smallBtn" id="mapsPolyCloseDraft" ${draftPoly.length >= 3 ? "" : "disabled"}>Close polygon</button> - <button type="button" class="ghost smallBtn" id="mapsPolyClearDraft" ${draftPoly.length ? "" : "disabled"}>Clear draft</button> - <button type="button" class="primary smallBtn" id="mapsPolySaveAll">Save</button> - <div class="small muted" id="mapsPolyStatus" style="margin-left:auto;"></div> - </div> - </div> - </div> - `; - } - } - const settingsHtml = showSettings - ? ` - <div class="panelDivider"></div> - <div class="small muted">Map settings</div> - <label class="checkRow" style="margin-top:10px;"> - <span>GM invisible</span> - <input id="mapsInvisibleToggle" type="checkbox" ${selfInvisible ? "checked" : ""} /> - </label> - ${canEditMap ? ` - <label> - <span class="small muted">Avatar size</span> - <div class="mapRangeRow"> - <input id="mapsAvatarSizeRange" type="range" min="18" max="96" value="${escapeHtml(avatarSize)}" /> - <div class="mapRangeVal" id="mapsAvatarSizeVal">${escapeHtml(avatarSize)}</div> - </div> - </label> - <label style="margin-top:10px;"> - <span class="small muted">Camera zoom</span> - <div class="mapRangeRow"> - <input id="mapsCameraZoomRange" type="range" min="1" max="4" step="0.05" value="${escapeHtml(cameraZoom.toFixed(2))}" /> - <div class="mapRangeVal" id="mapsCameraZoomVal">${escapeHtml(cameraZoom.toFixed(2))}</div> - </div> - </label> - <label class="checkRow" style="margin-top:10px;"> - <span>Enable walkies</span> - <input id="mapsWalkiesToggle" type="checkbox" ${walkiesEnabled ? "checked" : ""} /> - </label> - <label class="checkRow" style="margin-top:10px;"> - <span>TTRPG mode</span> - <input id="mapsTtrpgToggle" type="checkbox" ${ttrpgEnabled ? "checked" : ""} /> - </label> - ${ttrpgEnabled && canManageTtrpg ? ` - <div class="small muted" style="margin-top:10px; line-height:1.15rem;"> - Inspector shortcuts:<br/> - <b>V</b> select, <b>P</b> place, hold <b>Space</b> pan<br/> - <b>Q/E</b> rotate selected, <b>Z/X</b> scale selected - </div> - ` : ""} - <div class="row" style="margin-top:10px; gap:10px;"> - <button type="button" class="${editMode ? "primary" : "ghost"} smallBtn" id="mapsEditToggle">Polygon editor tools${editMode ? " (ON)" : ""}</button> - <div class="small muted grow">${polysCount} polys</div> - </div> - ${polyModalHtml} - ` : ""} - ` - : ""; - mapsPanel.innerHTML = ` - <div class="mapsRoomWrap"> - <div class="mapView"> - <div class="mapCanvasWrap"> - <canvas class="mapCanvas" id="mapsCanvas"></canvas> - <div class="mapChatOverlay hidden" id="mapsChatOverlay"> - <input id="mapsChatInput" placeholder="Say something..." /> - <button type="button" class="primary" id="mapsChatSend">Send</button> - </div> - <div class="mapWalkieBar ${walkiesEnabled ? "" : "hidden"}" id="mapsWalkieBar"> - <div class="mapWalkieBarInner"> - <button type="button" class="primary mapWalkieBtn" id="mapsWalkieBtn">Hold to talk</button> - <div class="mapWalkieHint">or hold <b>~</b></div> - </div> - </div> - </div> - <div class="mapHud"> - <div class="mapHudTitle"> - <div>${title}</div> - <button type="button" class="ghost smallBtn" data-mapback="1">Back</button> - </div> - <div class="small muted">${users.size} in room</div> - <div class="mapHudList">${list || `<div class="muted small">No one here yet.</div>`}</div> - <div class="mapHint"> - Exits: <b>${escapeHtml(Array.isArray(activeMap.exits) ? activeMap.exits.length : 0)}</b> - </div> - ${ - fogCount - ? ` - <label class="checkRow" style="margin-top:10px;"> - <span>Reveal fog</span> - <input id="mapsFogRevealToggle" type="checkbox" ${revealFog ? "checked" : ""} /> - </label> - <div class="small muted" style="margin-top:6px; line-height:1.15rem;"> - Fog zones: <b>${escapeHtml(String(fogCount))}</b><br/> - Auto fog reveals when you stand inside it. - </div> - ` - : "" - } - ${shortcutHintHtml} - ${settingsHtml} - </div> - </div> - <div class="mapDock ${ttrpgEnabled ? "" : "hidden"}" id="mapsTtrpgDock"></div> - </div> - `; - - if (editMode !== lastEditModeLogged) { - lastEditModeLogged = editMode; - devLog("info", "maps:editMode", { editMode, canEditMap, mapId: activeMap?.id || "" }); - } - if (editMode && canEditMap) { - const now = Date.now(); - if (now - lastPolyUiLogAt > 1500) { - lastPolyUiLogAt = now; - const modal = document.getElementById("mapsPolyModal"); - const inHud = Boolean(modal && modal.closest(".mapHud")); - const h = modal ? Number(modal.getBoundingClientRect().height || 0) : 0; - devLog("debug", "maps:polyUi", { exists: Boolean(modal), inHud, height: h, draftPts: draftPoly.length, kind: editKind, tool: editTool }); - } - } - loadBackground(activeMap.backgroundUrl || ""); - startLoop(); - - const fogRevealToggle = document.getElementById("mapsFogRevealToggle"); - if (fogRevealToggle && fogCount) { - fogRevealToggle.onchange = () => { - setFogReveal(activeMap.id, Boolean(fogRevealToggle.checked)); - }; - } - - const invToggle = document.getElementById("mapsInvisibleToggle"); - if (invToggle && showSettings) { - invToggle.onchange = () => { - const invisible = Boolean(invToggle.checked); - selfInvisible = invisible; - ctx.send("setInvisible", { mapId: activeMap.id, invisible }); - renderMapView(); - }; - } - - const range = document.getElementById("mapsAvatarSizeRange"); - const val = document.getElementById("mapsAvatarSizeVal"); - if (range && val && canEditMap) { - const commit = () => { - const next = Math.max(18, Math.min(96, Number(range.value || 36))); - val.textContent = String(next); - activeMap.avatarSize = next; - if (mapAvatarSaveTimer) clearTimeout(mapAvatarSaveTimer); - mapAvatarSaveTimer = setTimeout(() => { - ctx.send("updateMap", { id: activeMap.id, avatarSize: next }); - }, 220); - }; - range.oninput = commit; - range.onchange = commit; - } - - const zoomRange = document.getElementById("mapsCameraZoomRange"); - const zoomVal = document.getElementById("mapsCameraZoomVal"); - if (zoomRange && zoomVal && canEditMap) { - const commit = () => { - const next = Math.max(1, Math.min(4, Number(zoomRange.value || 2.35) || 2.35)); - zoomVal.textContent = next.toFixed(2); - activeMap.cameraZoom = next; - if (mapZoomSaveTimer) clearTimeout(mapZoomSaveTimer); - mapZoomSaveTimer = setTimeout(() => { - ctx.send("updateMap", { id: activeMap.id, cameraZoom: next }); - }, 220); - }; - zoomRange.oninput = commit; - zoomRange.onchange = commit; - } - - const walkiesToggle = document.getElementById("mapsWalkiesToggle"); - if (walkiesToggle && canEditMap) { - walkiesToggle.onchange = () => { - const enabled = Boolean(walkiesToggle.checked); - activeMap.walkiesEnabled = enabled; - ctx.send("updateMap", { id: activeMap.id, walkiesEnabled: enabled }); - const bar = document.getElementById("mapsWalkieBar"); - if (bar) bar.classList.toggle("hidden", !enabled); - }; - } - - const ttrpgToggle = document.getElementById("mapsTtrpgToggle"); - if (ttrpgToggle && canEditMap) { - ttrpgToggle.onchange = () => { - const enabled = Boolean(ttrpgToggle.checked); - activeMap.ttrpgEnabled = enabled; - ctx.send("ttrpgSetEnabled", { mapId: activeMap.id, enabled }); - renderMapView(); - }; - } - - const editBtn = document.getElementById("mapsEditToggle"); - if (editBtn && canEditMap) { - editBtn.onclick = () => { - devLog("info", "maps:editToggle click", { before: editMode, mapId: activeMap?.id || "" }); - editMode = !editMode; - draftPoly = []; - polyDrag = null; - vertexDrag = null; - panning = false; - panStart = null; - if (editMode) { - const list = polysForKind(activeMap, editKind); - editTool = list.length ? "select" : "draw"; - devLog("info", "maps:editToggle on", { mapId: activeMap?.id || "", kind: editKind, tool: editTool }); - } else { - selectedPolyKind = ""; - selectedPolyIndex = -1; - selectedVertexIndex = -1; - devLog("info", "maps:editToggle off", { mapId: activeMap?.id || "" }); - } - renderMapView(); - }; - } - - if (editMode) { - wirePolyModalHandlers(); - const modal = document.getElementById("mapsPolyModal"); - try { - modal?.scrollIntoView?.({ block: "nearest" }); - } catch { - // ignore - } - } - - const canvas = document.getElementById("mapsCanvas"); - if (canvas) { - if (editMode) { - canvas.style.cursor = - editTool === "draw" ? "crosshair" : editTool === "select" ? "pointer" : editTool === "move" ? "move" : "cell"; - } - else if (activeMap?.ttrpgEnabled && canManageTtrpg && ttrpgTool === "pan") canvas.style.cursor = "grab"; - else if (activeMap?.ttrpgEnabled && canManageTtrpg && ttrpgTool === "place") canvas.style.cursor = "copy"; - else canvas.style.cursor = "default"; - canvas.oncontextmenu = (e) => { - if (editMode) e.preventDefault(); - }; - canvas.onpointerdown = (e) => { - if (!lastTransform) return; - // Edit mode interactions - if (editMode) { - const isPan = e.button === 2 || e.shiftKey; - if (isPan) { - panning = true; - canvas.setPointerCapture(e.pointerId); - panStart = { x: e.clientX, y: e.clientY, cx: cameraPos?.x ?? 0.5, cy: cameraPos?.y ?? 0.5 }; - return; - } - if (e.button !== 0) return; - const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform); - if (!pt) return; - - if (editTool === "draw") { - draftPoly.push(pt); - const se = document.getElementById("mapsPolyStatus"); - if (se) se.textContent = `${draftPoly.length} pts (draft)`; - syncPolyDraftUi(); - return; - } - - if (editTool === "select") { - const hit = hitTestPoly(pt, activeMap, editKind); - if (hit) { - selectedPolyKind = editKind; - selectedPolyIndex = hit.index; - selectedVertexIndex = -1; - } else { - selectedPolyKind = ""; - selectedPolyIndex = -1; - selectedVertexIndex = -1; - } - renderMapView(); - return; - } - - if (editTool === "move") { - if (selectedPolyKind !== editKind || selectedPolyIndex < 0) return; - const list = polysForKind(activeMap, editKind); - const poly = list[selectedPolyIndex]; - if (!poly || !pointInPoly(pt, poly)) return; - polyDrag = { - kind: editKind, - index: selectedPolyIndex, - start: { x: pt.x, y: pt.y }, - origPoints: (Array.isArray(poly.points) ? poly.points : []).map((p) => ({ x: Number(p.x || 0), y: Number(p.y || 0) })), - }; - canvas.setPointerCapture(e.pointerId); - return; - } - - if (editTool === "vertex") { - if (selectedPolyKind !== editKind || selectedPolyIndex < 0) return; - const list = polysForKind(activeMap, editKind); - const poly = list[selectedPolyIndex]; - if (!poly) return; - const vIdx = hitTestVertex(e.clientX, e.clientY, canvas, lastTransform, poly); - if (vIdx < 0) return; - selectedVertexIndex = vIdx; - vertexDrag = { kind: editKind, index: selectedPolyIndex, vIdx }; - canvas.setPointerCapture(e.pointerId); - return; - } - } - - if (!activeMap?.ttrpgEnabled || !canManageTtrpg) return; - if (e.button !== 0) return; - if (ttrpgTool === "pan") { - panning = true; - canvas.setPointerCapture(e.pointerId); - panStart = { x: e.clientX, y: e.clientY, cx: cameraPos?.x ?? 0.5, cy: cameraPos?.y ?? 0.5 }; - canvas.style.cursor = "grabbing"; - return; - } - const hit = hitTestPropAtPointer(e.clientX, e.clientY, canvas, lastTransform); - if (hit) { - selectedPropId = hit.propId; - const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform); - if (!pt) return; - if (ttrpgTool === "select") { - propDrag = { propId: hit.propId, offsetX: hit.x - pt.x, offsetY: hit.y - pt.y }; - propDragMoved = false; - canvas.setPointerCapture(e.pointerId); - } - renderTtrpgDock(); - } else if (ttrpgTool === "select") { - selectedPropId = ""; - renderTtrpgDock(); - } - }; - canvas.onpointermove = (e) => { - if (!lastTransform) return; - if (editMode) { - if (panning && panStart && lastTransform) { - const dx = e.clientX - panStart.x; - const dy = e.clientY - panStart.y; - const worldDx = -(dx / lastTransform.zoom); - const worldDy = -(dy / lastTransform.zoom); - const nx = (panStart.cx * lastTransform.worldW + worldDx) / lastTransform.worldW; - const ny = (panStart.cy * lastTransform.worldH + worldDy) / lastTransform.worldH; - cameraPos = { x: Math.max(0, Math.min(1, nx)), y: Math.max(0, Math.min(1, ny)) }; - return; - } - if (polyDrag) { - const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform); - if (!pt) return; - const dx = pt.x - polyDrag.start.x; - const dy = pt.y - polyDrag.start.y; - const list = polysForKind(activeMap, polyDrag.kind); - const poly = list[polyDrag.index]; - if (!poly) return; - poly.points = polyDrag.origPoints.map((p) => ({ x: Math.max(0, Math.min(1, p.x + dx)), y: Math.max(0, Math.min(1, p.y + dy)) })); - return; - } - if (vertexDrag) { - const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform); - if (!pt) return; - const list = polysForKind(activeMap, vertexDrag.kind); - const poly = list[vertexDrag.index]; - const pts = Array.isArray(poly?.points) ? poly.points : null; - if (!poly || !pts || vertexDrag.vIdx < 0 || vertexDrag.vIdx >= pts.length) return; - pts[vertexDrag.vIdx] = { x: pt.x, y: pt.y }; - poly.points = pts; - return; - } - return; - } - if (panning && panStart && lastTransform) { - const dx = e.clientX - panStart.x; - const dy = e.clientY - panStart.y; - const worldDx = -(dx / lastTransform.zoom); - const worldDy = -(dy / lastTransform.zoom); - const nx = (panStart.cx * lastTransform.worldW + worldDx) / lastTransform.worldW; - const ny = (panStart.cy * lastTransform.worldH + worldDy) / lastTransform.worldH; - cameraPos = { x: Math.max(0, Math.min(1, nx)), y: Math.max(0, Math.min(1, ny)) }; - return; - } - if (!propDrag || !activeMap?.ttrpgEnabled || !canManageTtrpg) return; - const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform); - if (!pt) return; - const x = Math.max(0, Math.min(1, pt.x + propDrag.offsetX)); - const y = Math.max(0, Math.min(1, pt.y + propDrag.offsetY)); - const props = Array.isArray(activeMap.props) ? activeMap.props : []; - const idx = props.findIndex((p) => String(p?.id || "") === propDrag.propId); - if (idx < 0) return; - const prev = props[idx]; - if (!prev) return; - const moved = Math.hypot((prev.x || 0) - x, (prev.y || 0) - y) > 0.0006; - if (moved) propDragMoved = true; - props[idx] = { ...prev, x, y }; - activeMap.props = props; - const now = Date.now(); - if (now - lastPropMoveAt > 70) { - lastPropMoveAt = now; - ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: propDrag.propId, x, y, z: prev.z || 0, rot: prev.rot || 0, scale: prev.scale || 1 }); - } - }; - canvas.onpointerup = (e) => { - if (!lastTransform) return; - if (editMode) { - panning = false; - panStart = null; - polyDrag = null; - vertexDrag = null; - try { - canvas.releasePointerCapture(e.pointerId); - } catch { - // ignore - } - return; - } - if (panning) { - panning = false; - panStart = null; - if (activeMap?.ttrpgEnabled && canManageTtrpg && ttrpgTool === "pan") canvas.style.cursor = "grab"; - return; - } - - if (propDrag) { - // finalize drag - const props = Array.isArray(activeMap.props) ? activeMap.props : []; - const idx = props.findIndex((p) => String(p?.id || "") === propDrag.propId); - if (idx >= 0) { - const p = props[idx]; - if (p) ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: propDrag.propId, x: p.x, y: p.y, z: p.z || 0, rot: p.rot || 0, scale: p.scale || 1 }); - } - propDrag = null; - renderTtrpgDock(); - return; - } - - // Place prop (GM only) by clicking canvas with a selected sprite. - if (!activeMap?.ttrpgEnabled || !canManageTtrpg) return; - if (ttrpgTool !== "place") return; - if (!selectedSpriteId) return; - if (e.button !== 0) return; - const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform); - if (!pt) return; - const propId = `prop_${Date.now()}_${Math.random().toString(16).slice(2)}`; - const sprite = (Array.isArray(activeMap.sprites) ? activeMap.sprites : []).find((s) => String(s?.id || "") === String(selectedSpriteId || "")); - const isToken = sprite?.kind === "token"; - const optimistic = { - id: propId, - spriteId: selectedSpriteId, - x: pt.x, - y: pt.y, - z: 0, - rot: placeRot, - scale: placeScale, - nickname: "", - hpCurrent: isToken ? 10 : 0, - hpMax: isToken ? 10 : 0, - controlledBy: "" - }; - if (!Array.isArray(activeMap.props)) activeMap.props = []; - activeMap.props = [...activeMap.props.filter((p) => String(p?.id || "") !== propId), optimistic]; - selectedPropId = propId; - renderTtrpgDock(); - ctx.send("ttrpgPropAdd", { mapId: activeMap.id, id: propId, spriteId: selectedSpriteId, x: pt.x, y: pt.y, z: 0, rot: placeRot, scale: placeScale }); - }; - } - - const walkieBtn = document.getElementById("mapsWalkieBtn"); - if (walkieBtn) { - const down = async (e) => { - if (e) e.preventDefault(); - if (editMode) return; - if (!activeMap?.walkiesEnabled) return; - try { - await startWalkie(); - walkieBtn.textContent = "Recording…"; - } catch (err) { - ctx.toast("Walkie", String(err?.message || err)); - } - }; - const up = (e) => { - if (e) e.preventDefault(); - stopWalkie(); - walkieBtn.textContent = "Hold to talk"; - }; - walkieBtn.onpointerdown = down; - walkieBtn.onpointerup = up; - walkieBtn.onpointercancel = up; - walkieBtn.onpointerleave = (e) => { - // If the pointer is captured during recording, we'll still stop on up; otherwise stop on leave. - if (walkieRecording) up(e); - }; - } - - renderTtrpgDock(); - } - - function renderTtrpgDock() { - const dock = document.getElementById("mapsTtrpgDock"); - if (!dock) return; - if (!activeMap?.ttrpgEnabled) { - dock.innerHTML = ""; - dock.classList.remove("collapsed"); - return; - } - const collapsed = Boolean(ttrpgDockCollapsed); - dock.classList.toggle("collapsed", collapsed); - const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : []; - const props = Array.isArray(activeMap.props) ? activeMap.props : []; - const kind = spriteKind === "token" ? "token" : "prop"; - const spritesOfKind = sprites.filter((s) => (s?.kind || "prop") === kind); - if (canManageTtrpg) { - const hasSelected = spritesOfKind.some((s) => String(s?.id || "") === String(selectedSpriteId || "")); - if ((!selectedSpriteId || !hasSelected) && spritesOfKind.length) { - selectedSpriteId = String(spritesOfKind[0]?.id || ""); - } - } - const selectedSprite = sprites.find((s) => String(s?.id || "") === selectedSpriteId) || null; - const placingLabel = selectedSprite ? (selectedSprite.name ? selectedSprite.name : selectedSprite.id) : ""; - const thumbs = spritesOfKind - .map((s) => { - const sel = s.id === selectedSpriteId ? " selected" : ""; - const label = s.name ? escapeHtml(s.name) : escapeHtml(s.id); - return `<button type="button" class="spriteThumb${sel}" data-spriteid="${escapeHtml(s.id)}" title="${label}"> - <img src="${escapeHtml(s.url)}" alt="" /> - </button>`; - }) - .join(""); - const spriteById = new Map(sprites.map((s) => [String(s?.id || ""), s])); - const placedOfKind = props.filter((p) => (spriteById.get(String(p?.spriteId || ""))?.kind || "prop") === kind); - const placedThumbs = placedOfKind - .slice() - .sort((a, b) => Number(a?.y || 0) - Number(b?.y || 0)) - .slice(0, 160) - .map((p) => { - const spr = spriteById.get(String(p?.spriteId || "")); - if (!spr) return ""; - const sel = String(p?.id || "") === String(selectedPropId || "") ? " selected" : ""; - const label = spr.name ? spr.name : spr.id; - return `<button type="button" class="spriteThumb${sel}" data-propid="${escapeHtml(String(p.id || ""))}" title="${escapeHtml(label)}"> - <img src="${escapeHtml(String(spr.url || ""))}" alt="" /> - </button>`; - }) - .join(""); - const me = String(ctx.getUser() || "").trim().toLowerCase(); - const selectedProp = props.find((p) => String(p?.id || "") === String(selectedPropId || "")) || null; - const selectedPropSprite = selectedProp ? spriteById.get(String(selectedProp.spriteId || "")) || null : null; - const selectedIsToken = Boolean(selectedProp && selectedPropSprite?.kind === "token"); - const selectedScale = selectedProp ? Math.max(0.1, Math.min(4.0, Number(selectedProp.scale || 1))) : 1; - if (speakingAsPropId && !props.some((p) => String(p?.id || "") === String(speakingAsPropId))) { - speakingAsPropId = ""; - } - const speakingProp = speakingAsPropId ? props.find((p) => String(p?.id || "") === String(speakingAsPropId)) : null; - const speakingSprite = speakingProp ? spriteById.get(String(speakingProp.spriteId || "")) : null; - const speakingName = speakingProp ? String(speakingProp.nickname || speakingSprite?.name || speakingSprite?.id || "token") : ""; - - dock.innerHTML = ` - <div class="dockRow"> - <div class="dockTitle">TTRPG mode</div> - <div class="small muted grow">${canManageTtrpg ? "GM tools enabled" : "Waiting for GM…"}</div> - </div> - <div class="dockRow"> - <button type="button" class="${ttrpgTool === "select" ? "primary" : "ghost"} smallBtn" id="mapsToolSelect">Select</button> - <button type="button" class="${ttrpgTool === "place" ? "primary" : "ghost"} smallBtn" id="mapsToolPlace">Place</button> - <button type="button" class="${ttrpgTool === "pan" ? "primary" : "ghost"} smallBtn" id="mapsToolPan">Pan</button> - <div class="small muted grow">V select · P place · Space pan</div> - </div> - <div class="dockRow"> - <button type="button" class="${kind === "prop" ? "primary" : "ghost"} smallBtn" id="mapsKindProp">Props</button> - <button type="button" class="${kind === "token" ? "primary" : "ghost"} smallBtn" id="mapsKindToken">Tokens</button> - <div class="small muted grow">${spritesOfKind.length} sprites • ${placedOfKind.length} placed</div> - </div> - <div class="dockRow"> - <div class="small muted grow">${ - canManageTtrpg - ? placingLabel - ? `Placing: <b>${escapeHtml(placingLabel)}</b> · Rot <b>Q/E</b> ${escapeHtml(placeRot.toFixed(0))}° · Scale <b>Z/X</b> ${escapeHtml(placeScale.toFixed(2))}x` - : `Select a sprite then place it on the map.` - : `${kind === "token" ? "Tokens" : "Props"} are controlled by the GM.` - }</div> - </div> - <div class="dockRow"> - ${canManageTtrpg ? ` - <input id="mapsSpriteFile" type="file" accept="image/png,image/webp" /> - <input id="mapsSpriteName" type="text" maxlength="40" placeholder="Sprite name" /> - <div class="dockScale"> - <input id="mapsSpriteScale" type="range" min="0.25" max="4" step="0.05" value="${escapeHtml(String(spriteScale))}" /> - <div class="dockScaleVal" id="mapsSpriteScaleVal">${escapeHtml(spriteScale.toFixed(2))}</div> - </div> - <div class="dockScale"> - <input id="mapsPlaceScale" type="range" min="0.10" max="4" step="0.05" value="${escapeHtml(String(placeScale))}" /> - <div class="dockScaleVal" id="mapsPlaceScaleVal">${escapeHtml(placeScale.toFixed(2))}</div> - </div> - <button type="button" class="ghost smallBtn" id="mapsSpriteAddBtn">Add</button> - <button type="button" class="ghost smallBtn" id="mapsSpriteRemoveBtn" ${selectedSpriteId ? "" : "disabled"}>Remove</button> - ` : `<div class="muted small">Props/tokens are controlled by the GM.</div>`} - </div> - <div class="spriteTray" id="mapsSpriteTray"> - ${thumbs || `<div class="muted small">No sprites yet.</div>`} - </div> - <div class="dockRow" style="margin-top:6px;"> - <div class="small muted grow">Placed ${kind === "token" ? "tokens" : "props"}</div> - <button type="button" class="ghost smallBtn" id="mapsPropDeleteBtn" ${selectedPropId ? "" : "disabled"}>Delete</button> - </div> - <div class="spriteTray" id="mapsPropTray"> - ${placedThumbs || `<div class="muted small">None placed yet.</div>`} - </div> - <div class="dockRow" style="margin-top:8px;"> - ${selectedProp ? `<div class="small muted grow">Selected: <b>${escapeHtml(String(selectedProp.nickname || selectedPropSprite?.name || selectedPropSprite?.id || selectedProp.id || "item"))}</b> · ${escapeHtml(selectedScale.toFixed(2))}x</div>` : `<div class="small muted grow">Select an item to edit it.</div>`} - <button type="button" class="ghost smallBtn" id="mapsScaleDownBtn" ${selectedProp ? "" : "disabled"}>-</button> - <button type="button" class="ghost smallBtn" id="mapsScaleUpBtn" ${selectedProp ? "" : "disabled"}>+</button> - </div> - ${ - selectedIsToken - ? `<div class="dockRow" style="gap:8px;"> - <input id="mapsPropNick" type="text" maxlength="40" placeholder="Token nickname" value="${escapeHtml(String(selectedProp.nickname || ""))}" /> - <input id="mapsPropHpCur" type="number" min="0" max="9999" value="${escapeHtml(String(selectedProp.hpCurrent || 0))}" /> - <input id="mapsPropHpMax" type="number" min="0" max="9999" value="${escapeHtml(String(selectedProp.hpMax || 0))}" /> - <button type="button" class="ghost smallBtn" id="mapsPropSaveMeta">Save</button> - </div> - <div class="dockRow" style="gap:8px;"> - <div class="small muted grow">Controller: <b>${escapeHtml(String(selectedProp.controlledBy || "none"))}</b></div> - <button type="button" class="ghost smallBtn" id="mapsTokenPossessBtn" ${selectedProp.controlledBy && selectedProp.controlledBy !== me ? "disabled" : ""}>${selectedProp.controlledBy === me ? "Release" : "Possess"}</button> - <button type="button" class="${speakingAsPropId === selectedPropId ? "primary" : "ghost"} smallBtn" id="mapsTokenSpeakBtn">${speakingAsPropId === selectedPropId ? "Speaking" : "Speak as"}</button> - </div>` - : "" - } - ${speakingProp ? `<div class="dockRow"><div class="small muted">Chat voice: <b>${escapeHtml(speakingName)}</b></div></div>` : ""} - `; - - const dockChildren = Array.from(dock.children); - const headerRow = dockChildren[0]; - if (headerRow) { - headerRow.classList.add("dockHeaderRow"); - const grow = headerRow.querySelector(".grow"); - if (grow) { - const base = grow.getAttribute("data-base") || grow.textContent || ""; - if (!grow.hasAttribute("data-base")) grow.setAttribute("data-base", base); - grow.textContent = base + (collapsed ? " (hidden)" : ""); - } - let toggleBtn = headerRow.querySelector("#mapsDockToggle"); - if (!toggleBtn) { - toggleBtn = document.createElement("button"); - toggleBtn.type = "button"; - toggleBtn.className = "ghost smallBtn"; - toggleBtn.id = "mapsDockToggle"; - headerRow.appendChild(toggleBtn); - } - - let releaseBtn = headerRow.querySelector("#mapsReleasePossession"); - if (canManageTtrpg) { - const possessed = getPossessedTokenForMe(); - if (!releaseBtn) { - releaseBtn = document.createElement("button"); - releaseBtn.type = "button"; - releaseBtn.className = "ghost smallBtn"; - releaseBtn.id = "mapsReleasePossession"; - } - if (toggleBtn) headerRow.insertBefore(releaseBtn, toggleBtn); - else headerRow.appendChild(releaseBtn); - releaseBtn.textContent = "Release"; - releaseBtn.disabled = !possessed; - releaseBtn.title = possessed ? "Release token control" : "Not controlling a token"; - releaseBtn.onclick = () => { - if (!activeMap?.id) return; - const pid = possessed ? String(possessed.id || "") : String(selectedPropId || ""); - const meU = String(ctx.getUser() || "").trim().toLowerCase(); - speakingAsPropId = ""; - if (meU) { - const list = Array.isArray(activeMap.props) ? activeMap.props : []; - let changed = false; - const nextList = list.map((p) => { - if (!p) return p; - if (String(p.controlledBy || "") !== meU) return p; - const spr = spriteById.get(String(p?.spriteId || "")); - if (spr?.kind !== "token") return p; - changed = true; - return { ...p, controlledBy: "" }; - }); - if (changed) activeMap.props = nextList; - } - ctx.send("ttrpgTokenPossess", { mapId: activeMap.id, propId: pid, action: "release" }); - renderTtrpgDock(); - }; - } else if (releaseBtn) { - releaseBtn.remove(); - } - - toggleBtn.textContent = collapsed ? "Show" : "Hide"; - toggleBtn.onclick = () => { - if (!activeMap?.id) return; - writeDockCollapsed(activeMap.id, !ttrpgDockCollapsed); - renderTtrpgDock(); - }; - - const body = document.createElement("div"); - body.className = "dockBody"; - for (let i = 1; i < dockChildren.length; i++) { - body.appendChild(dockChildren[i]); - } - dock.appendChild(body); - } - - const tray = document.getElementById("mapsSpriteTray"); - if (tray) { - tray.onclick = (e) => { - const btn = e.target.closest("[data-spriteid]"); - if (!btn) return; - selectedSpriteId = String(btn.getAttribute("data-spriteid") || ""); - selectedPropId = ""; - ttrpgTool = "place"; - renderTtrpgDock(); - }; - } - - const toolSelect = document.getElementById("mapsToolSelect"); - const toolPlace = document.getElementById("mapsToolPlace"); - const toolPan = document.getElementById("mapsToolPan"); - if (toolSelect) toolSelect.onclick = () => { ttrpgTool = "select"; renderMapView(); }; - if (toolPlace) toolPlace.onclick = () => { ttrpgTool = "place"; renderMapView(); }; - if (toolPan) toolPan.onclick = () => { ttrpgTool = "pan"; renderMapView(); }; - - const kindPropBtn = document.getElementById("mapsKindProp"); - const kindTokenBtn = document.getElementById("mapsKindToken"); - if (kindPropBtn) kindPropBtn.onclick = () => { spriteKind = "prop"; selectedSpriteId = ""; selectedPropId = ""; renderTtrpgDock(); }; - if (kindTokenBtn) kindTokenBtn.onclick = () => { spriteKind = "token"; selectedSpriteId = ""; selectedPropId = ""; renderTtrpgDock(); }; - - const propTray = document.getElementById("mapsPropTray"); - if (propTray) { - propTray.onclick = (e) => { - const btn = e.target.closest("[data-propid]"); - if (!btn) return; - selectedPropId = String(btn.getAttribute("data-propid") || ""); - ttrpgTool = "select"; - renderTtrpgDock(); - }; - } - - if (!canManageTtrpg) return; - const scaleEl = document.getElementById("mapsSpriteScale"); - const scaleVal = document.getElementById("mapsSpriteScaleVal"); - if (scaleEl && scaleVal) { - const update = () => { - const v = Math.max(0.1, Math.min(4.0, Number(scaleEl.value || 1))); - spriteScale = v; - scaleVal.textContent = v.toFixed(2); - }; - scaleEl.oninput = update; - update(); - } - const placeScaleEl = document.getElementById("mapsPlaceScale"); - const placeScaleVal = document.getElementById("mapsPlaceScaleVal"); - if (placeScaleEl && placeScaleVal) { - const update = () => { - const v = Math.max(0.1, Math.min(4.0, Number(placeScaleEl.value || 1))); - placeScale = v; - placeScaleVal.textContent = v.toFixed(2); - }; - placeScaleEl.oninput = update; - update(); - } - const addBtn = document.getElementById("mapsSpriteAddBtn"); - const fileEl = document.getElementById("mapsSpriteFile"); - const nameEl = document.getElementById("mapsSpriteName"); - if (addBtn && fileEl) { - addBtn.onclick = async () => { - const file = fileEl.files && fileEl.files[0] ? fileEl.files[0] : null; - if (!file) return; - addBtn.disabled = true; - try { - const url = await uploadSpriteImageFile(file); - const name = nameEl ? String(nameEl.value || "").trim() : ""; - const k = spriteKind === "token" ? "token" : "prop"; - const id = `spr_${Date.now()}_${Math.random().toString(16).slice(2)}`; - const sprite = { id, kind: k, name, url, scale: spriteScale }; - if (!Array.isArray(activeMap.sprites)) activeMap.sprites = []; - activeMap.sprites = [...activeMap.sprites.filter((s) => String(s?.id || "") !== id), sprite]; - selectedSpriteId = id; - selectedPropId = ""; - ttrpgTool = "place"; - renderTtrpgDock(); - ctx.send("ttrpgSpriteAdd", { mapId: activeMap.id, id, kind: k, name, url, scale: spriteScale }); - fileEl.value = ""; - if (nameEl) nameEl.value = ""; - } catch (e) { - ctx.toast("Sprites", String(e?.message || e)); - } finally { - addBtn.disabled = false; - } - }; - } - const removeBtn = document.getElementById("mapsSpriteRemoveBtn"); - if (removeBtn) { - removeBtn.onclick = () => { - if (!selectedSpriteId) return; - const ok = window.confirm("Remove this sprite? Props using it will also be removed."); - if (!ok) return; - const spriteId = String(selectedSpriteId || ""); - activeMap.sprites = (Array.isArray(activeMap.sprites) ? activeMap.sprites : []).filter((s) => String(s?.id || "") !== spriteId); - activeMap.props = (Array.isArray(activeMap.props) ? activeMap.props : []).filter((p) => String(p?.spriteId || "") !== spriteId); - if (speakingAsPropId && !activeMap.props.some((p) => String(p?.id || "") === String(speakingAsPropId))) speakingAsPropId = ""; - selectedSpriteId = ""; - selectedPropId = ""; - renderTtrpgDock(); - ctx.send("ttrpgSpriteRemove", { mapId: activeMap.id, spriteId }); - }; - } - - const delPropBtn = document.getElementById("mapsPropDeleteBtn"); - if (delPropBtn) { - delPropBtn.onclick = () => { - if (!selectedPropId) return; - const ok = window.confirm("Delete this placed item?"); - if (!ok) return; - const propId = String(selectedPropId || ""); - activeMap.props = (Array.isArray(activeMap.props) ? activeMap.props : []).filter((p) => String(p?.id || "") !== propId); - if (speakingAsPropId === propId) speakingAsPropId = ""; - selectedPropId = ""; - renderTtrpgDock(); - ctx.send("ttrpgPropRemove", { mapId: activeMap.id, propId }); - }; - } - - const scaleDownBtn = document.getElementById("mapsScaleDownBtn"); - const scaleUpBtn = document.getElementById("mapsScaleUpBtn"); - if (scaleDownBtn && selectedProp) { - scaleDownBtn.onclick = () => { - const next = Math.max(0.1, Math.min(4.0, Number(selectedProp.scale || 1) - 0.1)); - const list = Array.isArray(activeMap.props) ? activeMap.props : []; - const idx = list.findIndex((p) => String(p?.id || "") === String(selectedProp.id || "")); - if (idx < 0) return; - list[idx] = { ...list[idx], scale: next }; - activeMap.props = list; - renderTtrpgDock(); - ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: selectedProp.id, x: list[idx].x, y: list[idx].y, z: list[idx].z || 0, rot: list[idx].rot || 0, scale: next }); - }; - } - if (scaleUpBtn && selectedProp) { - scaleUpBtn.onclick = () => { - const next = Math.max(0.1, Math.min(4.0, Number(selectedProp.scale || 1) + 0.1)); - const list = Array.isArray(activeMap.props) ? activeMap.props : []; - const idx = list.findIndex((p) => String(p?.id || "") === String(selectedProp.id || "")); - if (idx < 0) return; - list[idx] = { ...list[idx], scale: next }; - activeMap.props = list; - renderTtrpgDock(); - ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: selectedProp.id, x: list[idx].x, y: list[idx].y, z: list[idx].z || 0, rot: list[idx].rot || 0, scale: next }); - }; - } - - const saveMetaBtn = document.getElementById("mapsPropSaveMeta"); - if (saveMetaBtn && selectedProp && selectedIsToken) { - saveMetaBtn.onclick = () => { - const nickEl = document.getElementById("mapsPropNick"); - const hpCurEl = document.getElementById("mapsPropHpCur"); - const hpMaxEl = document.getElementById("mapsPropHpMax"); - const nickname = String(nickEl?.value || "").trim().slice(0, 40); - const hpMax = Math.max(0, Math.min(9999, Number(hpMaxEl?.value || 0) || 0)); - let hpCurrent = Math.max(0, Math.min(hpMax > 0 ? hpMax : 9999, Number(hpCurEl?.value || 0) || 0)); - if (hpCurrent > hpMax && hpMax > 0) hpCurrent = hpMax; - const list = Array.isArray(activeMap.props) ? activeMap.props : []; - const idx = list.findIndex((p) => String(p?.id || "") === String(selectedProp.id || "")); - if (idx < 0) return; - list[idx] = { ...list[idx], nickname, hpCurrent, hpMax }; - activeMap.props = list; - renderTtrpgDock(); - ctx.send("ttrpgPropPatch", { mapId: activeMap.id, propId: selectedProp.id, nickname, hpCurrent, hpMax }); - }; - } - - const possessBtn = document.getElementById("mapsTokenPossessBtn"); - if (possessBtn && selectedProp && selectedIsToken) { - possessBtn.onclick = () => { - if (!activeMap?.id) return; - const isMine = String(selectedProp.controlledBy || "") === me; - const action = isMine ? "release" : "possess"; - - // Optimistic UI: keep possession exclusive and make release always "release all my tokens". - const list = Array.isArray(activeMap.props) ? activeMap.props : []; - const targetId = String(selectedProp.id || ""); - let changed = false; - const nextList = list.map((p) => { - if (!p) return p; - const pid = String(p?.id || ""); - const spr = spriteById.get(String(p?.spriteId || "")); - const isToken = spr?.kind === "token"; - if (!isToken) return p; - if (action === "release") { - if (String(p.controlledBy || "") !== me) return p; - changed = true; - return { ...p, controlledBy: "" }; - } - // possess: release other tokens I control, and claim selected - if (pid === targetId) { - if (String(p.controlledBy || "") !== me) changed = true; - return { ...p, controlledBy: me }; - } - if (String(p.controlledBy || "") === me) { - changed = true; - return { ...p, controlledBy: "" }; - } - return p; - }); - if (action === "release") speakingAsPropId = ""; - if (action === "possess") speakingAsPropId = targetId; - if (changed) activeMap.props = nextList; - - ctx.send("ttrpgTokenPossess", { mapId: activeMap.id, propId: selectedProp.id, action }); - renderTtrpgDock(); - }; - } - - const speakBtn = document.getElementById("mapsTokenSpeakBtn"); - if (speakBtn && selectedProp && selectedIsToken) { - speakBtn.onclick = () => { - speakingAsPropId = speakingAsPropId === selectedProp.id ? "" : selectedProp.id; - renderTtrpgDock(); - }; - } - } - - function screenToWorldNormalized(clientX, clientY, canvas, tr) { - const rect = canvas.getBoundingClientRect(); - const sx = clientX - rect.left; - const sy = clientY - rect.top; - if (sx < 0 || sy < 0 || sx > rect.width || sy > rect.height) return null; - const worldX = tr.srcX + (sx / tr.zoom); - const worldY = tr.srcY + (sy / tr.zoom); - return { x: Math.max(0, Math.min(1, worldX / tr.worldW)), y: Math.max(0, Math.min(1, worldY / tr.worldH)) }; - } - - function getSpriteImage(url) { - const src = String(url || "").trim(); - if (!src) return null; - const now = Date.now(); - const cached = spriteImageCache.get(src); - if (cached) { - if (cached.status === "ok" && cached.img) return cached.img; - if (cached.status === "loading") return null; - if (cached.status === "error" && now - Number(cached.failedAt || 0) < 5000) return null; - } - const img = new Image(); - if (!src.startsWith("data:")) img.crossOrigin = "anonymous"; - spriteImageCache.set(src, { img: null, status: "loading", failedAt: 0 }); - img.onload = () => spriteImageCache.set(src, { img, status: "ok", failedAt: 0 }); - img.onerror = () => spriteImageCache.set(src, { img: null, status: "error", failedAt: Date.now() }); - img.src = src; - return null; - } - - function commitDraftPoly() { - if (!draftPoly || draftPoly.length < 3) return false; - const poly = { points: draftPoly.map((p) => ({ x: p.x, y: p.y })) }; - const list = polysForKind(activeMap, editKind, true); - if (editKind === "exit") { - let name = String(exitDraftName || "").trim().slice(0, 40); - if (!name) name = `Exit ${list.length + 1}`.slice(0, 40); - const action = exitAction === "toMap" ? "toMap" : "toMaps"; - const selfId = String(activeMap?.id || "").trim().toLowerCase(); - const otherMaps = (Array.isArray(maps) ? maps : []) - .map((m) => String(m?.id || "").trim().toLowerCase()) - .filter(Boolean) - .filter((id) => id !== selfId) - .sort((a, b) => a.localeCompare(b)); - let toMapId = action === "toMap" ? String(exitTargetMapId || "").trim().toLowerCase() : ""; - if (action === "toMap" && (!toMapId || toMapId === selfId)) { - toMapId = otherMaps[0] || ""; - } - if (action === "toMap" && !toMapId) return false; - const targetExit = action === "toMap" ? String(exitTargetExitName || "").trim().slice(0, 40) : ""; - list.push({ ...poly, name, action, toMapId, targetExit }); - } else if (editKind === "hidden") { - const mode = fogDraftMode === "manual" ? "manual" : "auto"; - const name = String(fogDraftName || "").trim().slice(0, 40); - list.push({ ...poly, mode, name }); - } else if (editKind === "fall") { - const dir = String(fallDraftDirection || "").trim().toLowerCase(); - const direction = dir === "up" || dir === "left" || dir === "right" ? dir : "down"; - const offset = Math.max(0.002, Math.min(0.08, Number(fallDraftOffset || 0.02) || 0.02)); - const name = String(fallDraftName || "").trim().slice(0, 40); - list.push({ ...poly, direction, offset, name }); - } else { - list.push(poly); - } - draftPoly = []; - selectedPolyKind = editKind; - selectedPolyIndex = Math.max(0, list.length - 1); - selectedVertexIndex = -1; - return true; - } - - function syncPolyDraftUi() { - const ptsEl = document.getElementById("mapsPolyDraftPts"); - if (ptsEl) ptsEl.textContent = String(draftPoly.length); - const closeDraft = document.getElementById("mapsPolyCloseDraft"); - if (closeDraft) closeDraft.toggleAttribute("disabled", draftPoly.length < 3); - const undoPt = document.getElementById("mapsPolyUndoPt"); - if (undoPt) undoPt.toggleAttribute("disabled", !draftPoly.length); - const clearDraft = document.getElementById("mapsPolyClearDraft"); - if (clearDraft) clearDraft.toggleAttribute("disabled", !draftPoly.length); - } - - function polysForKind(map, kind, ensure = false) { - if (!map) return []; - const k = String(kind || ""); - if (k === "collision") { - if (ensure && !Array.isArray(map.collisions)) map.collisions = []; - return Array.isArray(map.collisions) ? map.collisions : []; - } - if (k === "mask") { - if (ensure && !Array.isArray(map.masks)) map.masks = []; - return Array.isArray(map.masks) ? map.masks : []; - } - if (k === "exit") { - if (ensure && !Array.isArray(map.exits)) map.exits = []; - return Array.isArray(map.exits) ? map.exits : []; - } - if (k === "hidden") { - if (ensure && !Array.isArray(map.hiddenMasks)) map.hiddenMasks = []; - return Array.isArray(map.hiddenMasks) ? map.hiddenMasks : []; - } - if (k === "fall") { - if (ensure && !Array.isArray(map.fallThroughs)) map.fallThroughs = []; - return Array.isArray(map.fallThroughs) ? map.fallThroughs : []; - } - if (k === "occluder") { - if (ensure && !Array.isArray(map.occluders)) map.occluders = []; - return Array.isArray(map.occluders) ? map.occluders : []; - } - return []; - } - - function kindLabel(kind) { - if (kind === "collision") return "Collisions"; - if (kind === "mask") return "Y-sort masks"; - if (kind === "exit") return "Exits"; - if (kind === "hidden") return "Fog zones"; - if (kind === "fall") return "Fall-through zones"; - if (kind === "occluder") return "Occluders"; - return String(kind || ""); - } - - function hitTestPoly(pt, map, kind) { - const list = polysForKind(map, kind); - for (let i = list.length - 1; i >= 0; i--) { - const p = list[i]; - if (p && pointInPoly(pt, p)) return { index: i }; - } - return null; - } - - function hitTestVertex(clientX, clientY, canvas, tr, poly) { - const pts = Array.isArray(poly?.points) ? poly.points : []; - if (!pts.length) return -1; - const rect = canvas.getBoundingClientRect(); - const sx = clientX - rect.left; - const sy = clientY - rect.top; - const threshold = 12; - let best = { idx: -1, d2: Infinity }; - for (let i = 0; i < pts.length; i++) { - const p = pts[i]; - const x = (Number(p.x) * tr.worldW - tr.srcX) * tr.zoom; - const y = (Number(p.y) * tr.worldH - tr.srcY) * tr.zoom; - const dx = x - sx; - const dy = y - sy; - const d2 = dx * dx + dy * dy; - if (d2 < best.d2) best = { idx: i, d2 }; - } - if (best.idx < 0) return -1; - return best.d2 <= threshold * threshold ? best.idx : -1; - } - - function polyCentroid(points) { - const pts = Array.isArray(points) ? points : []; - if (!pts.length) return { x: 0.5, y: 0.5 }; - let sx = 0; - let sy = 0; - for (const p of pts) { - sx += Number(p?.x || 0); - sy += Number(p?.y || 0); - } - return { x: Math.max(0, Math.min(1, sx / pts.length)), y: Math.max(0, Math.min(1, sy / pts.length)) }; - } - - function renderPolyModal() { - const list = polysForKind(activeMap, editKind); - const selOk = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length; - const selected = selOk ? list[selectedPolyIndex] : null; - - const exitModel = - editKind === "exit" && selected - ? { - name: String(selected.name || "").trim(), - action: String(selected.action || "toMaps") === "toMap" ? "toMap" : "toMaps", - toMapId: String(selected.toMapId || "").trim().toLowerCase(), - targetExit: String(selected.targetExit || "").trim(), - } - : { - name: String(exitDraftName || "").trim(), - action: exitAction === "toMap" ? "toMap" : "toMaps", - toMapId: String(exitTargetMapId || "").trim().toLowerCase(), - targetExit: String(exitTargetExitName || "").trim(), - }; - - const otherMaps = (Array.isArray(maps) ? maps : []) - .map((m) => String(m?.id || "").trim().toLowerCase()) - .filter(Boolean) - .filter((id) => id !== String(activeMap?.id || "").trim().toLowerCase()) - .sort((a, b) => a.localeCompare(b)); - - const mapOptions = otherMaps - .map((id) => `<option value="${escapeHtml(id)}" ${id === exitModel.toMapId ? "selected" : ""}>${escapeHtml(id)}</option>`) - .join(""); - - const polyRows = list - .map((p, idx) => { - const on = selOk && idx === selectedPolyIndex ? " selected" : ""; - let label = `${kindLabel(editKind).replace(/s$/, "")} ${idx + 1}`; - if (editKind === "exit") { - const name = String(p?.name || "").trim(); - label = name ? name : `Exit ${idx + 1}`; - } - const pts = Array.isArray(p?.points) ? p.points.length : 0; - const meta = - editKind === "exit" - ? `${String(p?.action || "toMaps") === "toMap" ? `to ${escapeHtml(String(p?.toMapId || ""))}` : "to maps"}` - : `${pts} pts`; - return `<button type="button" class="polyRowBtn${on}" data-polysel="${idx}"> - <div class="polyRowMain">${escapeHtml(label)}</div> - <div class="polyRowMeta">${meta}</div> - </button>`; - }) - .join(""); - - const kindBtn = (kind, label, disabled = false) => { - const on = editKind === kind; - const dis = disabled ? "disabled" : ""; - return `<button type="button" class="${on ? "primary" : "ghost"} smallBtn" ${dis} data-polykind="${escapeHtml(kind)}">${escapeHtml(label)}</button>`; - }; - const toolBtn = (tool, label) => { - const on = editTool === tool; - return `<button type="button" class="${on ? "primary" : "ghost"} smallBtn" data-polytool="${escapeHtml(tool)}">${escapeHtml(label)}</button>`; - }; - - const inspectorBody = (() => { - const pts = Array.isArray(selected?.points) ? selected.points.length : 0; - if (editKind !== "exit") { - const header = selOk ? "Selected" : "New polygon defaults"; - const fogModel = - editKind === "hidden" && selected - ? { mode: String(selected.mode || "auto") === "manual" ? "manual" : "auto", name: String(selected.name || "").trim().slice(0, 40) } - : { mode: fogDraftMode === "manual" ? "manual" : "auto", name: String(fogDraftName || "").trim().slice(0, 40) }; - const fallModel = - editKind === "fall" && selected - ? { - direction: ["up", "down", "left", "right"].includes(String(selected.direction || "")) ? String(selected.direction || "") : "down", - offset: Math.max(0.002, Math.min(0.08, Number(selected.offset || 0.02) || 0.02)), - name: String(selected.name || "").trim().slice(0, 40), - } - : { - direction: ["up", "down", "left", "right"].includes(String(fallDraftDirection || "")) ? String(fallDraftDirection || "") : "down", - offset: Math.max(0.002, Math.min(0.08, Number(fallDraftOffset || 0.02) || 0.02)), - name: String(fallDraftName || "").trim().slice(0, 40), - }; - - const metaControls = - editKind === "hidden" - ? ` - <label style="margin-top:10px;"> - <div class="small muted">Reveal mode</div> - <select id="mapsFogMode"> - <option value="auto" ${fogModel.mode === "auto" ? "selected" : ""}>Auto (reveal when inside)</option> - <option value="manual" ${fogModel.mode === "manual" ? "selected" : ""}>Manual (toggle “Reveal fog”)</option> - </select> - </label> - <label style="margin-top:10px;"> - <div class="small muted">Label (optional)</div> - <input id="mapsFogName" type="text" maxlength="40" placeholder="Example: Secret room" value="${escapeHtml(fogModel.name)}" /> - </label> - ` - : editKind === "fall" - ? ` - <label style="margin-top:10px;"> - <div class="small muted">Direction</div> - <select id="mapsFallDirection"> - <option value="down" ${fallModel.direction === "down" ? "selected" : ""}>Down</option> - <option value="up" ${fallModel.direction === "up" ? "selected" : ""}>Up</option> - <option value="left" ${fallModel.direction === "left" ? "selected" : ""}>Left</option> - <option value="right" ${fallModel.direction === "right" ? "selected" : ""}>Right</option> - </select> - </label> - <label style="margin-top:10px;"> - <div class="small muted">Nudge distance</div> - <input id="mapsFallOffset" type="number" min="0.002" max="0.08" step="0.002" value="${escapeHtml(fallModel.offset.toFixed(3))}" /> - </label> - <label style="margin-top:10px;"> - <div class="small muted">Label (optional)</div> - <input id="mapsFallName" type="text" maxlength="40" placeholder="Example: Cliff edge" value="${escapeHtml(fallModel.name)}" /> - </label> - ` - : ""; - - return ` - <div class="small muted">${header}</div> - <div style="margin-top:6px;"><b>${escapeHtml(kindLabel(editKind))}</b></div> - ${selOk ? `<div class="small muted" style="margin-top:6px;">${pts} points</div>` : `<div class="small muted" style="margin-top:6px;">Draw a polygon, then Close polygon.</div>`} - ${metaControls} - `; - } - - const header = selOk ? "Selected exit" : "New exit defaults"; - return ` - <div class="small muted">${header}</div> - <label style="margin-top:6px;"> - <div class="small muted">Name</div> - <input id="mapsExitName" type="text" maxlength="40" placeholder="Example: North Gate" value="${escapeHtml(exitModel.name)}" /> - </label> - <label style="margin-top:10px;"> - <div class="small muted">Behavior</div> - <select id="mapsExitBehavior"> - <option value="toMaps" ${exitModel.action === "toMaps" ? "selected" : ""}>Exit to Maps</option> - <option value="toMap" ${exitModel.action === "toMap" ? "selected" : ""}>Exit to Map</option> - </select> - </label> - <div class="${exitModel.action === "toMap" ? "" : "hidden"}" id="mapsExitToMapWrap" style="margin-top:10px;"> - <label> - <div class="small muted">Target map</div> - <select id="mapsExitToMap">${mapOptions}</select> - </label> - <label style="margin-top:10px;"> - <div class="small muted">Target exit name (optional)</div> - <input id="mapsExitTargetExit" type="text" maxlength="40" placeholder="Example: South Gate" value="${escapeHtml(exitModel.targetExit)}" /> - </label> - </div> - <div class="small muted" style="margin-top:10px;">${selOk ? `${pts} points` : "Tip: pick Draw, click 3+ points, then Close polygon."}</div> - `; - })(); - - return ` - <div class="mapsPolyModal" id="mapsPolyModal"> - <div class="mapsPolyModalInner"> - <div class="mapsPolyHeader"> - <div> - <div class="mapsPolyTitle">Polygon editor</div> - <div class="small muted">Right-click or Shift+drag to pan. Esc clears draft. Delete removes selected.</div> - </div> - <button type="button" class="ghost smallBtn" id="mapsPolyModalClose">Close</button> - </div> - <div class="row" style="gap:10px; flex-wrap:wrap; margin-top:10px;"> - ${kindBtn("collision", "Collisions")} - ${kindBtn("mask", "Y-sort")} - ${kindBtn("exit", "Exits")} - ${kindBtn("hidden", "Fog")} - ${kindBtn("fall", "Fall-through")} - ${kindBtn("occluder", "Occluders (soon)", true)} - <div class="small muted" style="margin-left:auto;">${escapeHtml(String(list.length))} in ${escapeHtml(kindLabel(editKind))}</div> - </div> - <div class="row" style="gap:10px; flex-wrap:wrap; margin-top:10px;"> - ${toolBtn("draw", "Draw")} - ${toolBtn("select", "Select")} - ${toolBtn("move", "Move")} - ${toolBtn("vertex", "Vertices")} - <div class="row" style="gap:10px; margin-left:auto; flex-wrap:wrap;"> - <button type="button" class="ghost smallBtn" id="mapsPolyPrev">Prev</button> - <button type="button" class="ghost smallBtn" id="mapsPolyNext">Next</button> - <button type="button" class="ghost smallBtn" id="mapsPolyCopy">Copy</button> - <button type="button" class="ghost smallBtn" id="mapsPolyPaste" ${polyClipboard ? "" : "disabled"}>Paste</button> - <button type="button" class="ghost smallBtn" id="mapsPolyDuplicate" ${selOk ? "" : "disabled"}>Duplicate</button> - <button type="button" class="danger smallBtn" id="mapsPolyDelete" ${selOk ? "" : "disabled"}>Delete</button> - <button type="button" class="primary smallBtn" id="mapsPolySaveAll">Save</button> - </div> - </div> - <div class="row" style="gap:10px; flex-wrap:wrap; margin-top:10px; align-items:center;"> - <div class="small muted">Draft: <b id="mapsPolyDraftPts">${escapeHtml(String(draftPoly.length))}</b> pts</div> - <button type="button" class="ghost smallBtn" id="mapsPolyUndoPt" ${draftPoly.length ? "" : "disabled"}>Undo point</button> - <button type="button" class="ghost smallBtn" id="mapsPolyCloseDraft" ${draftPoly.length >= 3 ? "" : "disabled"}>Close polygon</button> - <button type="button" class="ghost smallBtn" id="mapsPolyClearDraft" ${draftPoly.length ? "" : "disabled"}>Clear draft</button> - <button type="button" class="ghost smallBtn" id="mapsPolyClearKind" ${list.length ? "" : "disabled"}>Clear kind</button> - <div class="small muted" id="mapsPolyStatus" style="margin-left:auto;"></div> - </div> - <div class="mapsPolyGrid"> - <div class="mapsPolyList" id="mapsPolyList">${polyRows || `<div class="small muted" style="padding:10px;">No polygons yet.</div>`}</div> - <div class="mapsPolyInspector" id="mapsPolyInspector">${inspectorBody}</div> - </div> - </div> - </div> - `; - } - - function syncPolyModal(canEditMap) { - const existing = document.getElementById("mapsPolyModal"); - if (!(mode === "map" && canEditMap && editMode && activeMap)) { - if (existing) existing.remove(); - return; - } - const html = String(renderPolyModal() || "").trim(); - if (!html) return; - const tmp = document.createElement("div"); - tmp.innerHTML = html; - const next = tmp.firstElementChild; - if (!next) return; - if (existing) existing.replaceWith(next); - else document.body.appendChild(next); - } - - function wirePolyModalHandlers() { - const modal = document.getElementById("mapsPolyModal"); - if (!modal) return; - - const modalClose = document.getElementById("mapsPolyModalClose"); - if (modalClose) { - modalClose.onclick = () => { - editMode = false; - draftPoly = []; - polyDrag = null; - vertexDrag = null; - selectedPolyKind = ""; - selectedPolyIndex = -1; - selectedVertexIndex = -1; - renderMapView(); - }; - } - - const statusEl = document.getElementById("mapsPolyStatus"); - const setStatus = (txt) => { - if (statusEl) statusEl.textContent = txt; - }; - - modal.onclick = (e) => { - const k = e.target.closest?.("[data-polykind]"); - if (k) { - if (k.hasAttribute("disabled")) return; - const kind = String(k.getAttribute("data-polykind") || ""); - if (!kind) return; - devLog("info", "maps:polyKind", { from: editKind, to: kind }); - editKind = kind; - const list = polysForKind(activeMap, editKind); - if (!(selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length)) { - selectedPolyKind = ""; - selectedPolyIndex = -1; - selectedVertexIndex = -1; - } - renderMapView(); - return; - } - const t = e.target.closest?.("[data-polytool]"); - if (t) { - const tool = String(t.getAttribute("data-polytool") || ""); - if (!tool) return; - devLog("info", "maps:polyTool", { from: editTool, to: tool }); - editTool = tool; - renderMapView(); - return; - } - }; - - const listEl = document.getElementById("mapsPolyList"); - if (listEl) { - listEl.onclick = (e) => { - const btn = e.target.closest?.("[data-polysel]"); - if (!btn) return; - const idx = Number(btn.getAttribute("data-polysel") || -1); - const list = polysForKind(activeMap, editKind); - if (idx < 0 || idx >= list.length) return; - selectedPolyKind = editKind; - selectedPolyIndex = idx; - selectedVertexIndex = -1; - editTool = "select"; - renderMapView(); - }; - } - - const undoPt = document.getElementById("mapsPolyUndoPt"); - if (undoPt) { - undoPt.onclick = () => { - if (!draftPoly.length) return; - draftPoly.pop(); - setStatus(`${draftPoly.length} pts (draft)`); - syncPolyDraftUi(); - renderMapView(); - }; - } - const clearDraft = document.getElementById("mapsPolyClearDraft"); - if (clearDraft) { - clearDraft.onclick = () => { - draftPoly = []; - setStatus("Draft cleared."); - syncPolyDraftUi(); - renderMapView(); - }; - } - const closeDraft = document.getElementById("mapsPolyCloseDraft"); - if (closeDraft) { - closeDraft.onclick = () => { - const before = { kind: editKind, draftPts: draftPoly.length, action: exitAction, toMapId: exitTargetMapId }; - const ok = commitDraftPoly(); - devLog("info", "maps:closeDraft", { ok, ...before, exits: Array.isArray(activeMap?.exits) ? activeMap.exits.length : 0 }); - setStatus(ok ? "Polygon added." : editKind === "exit" ? "Exit needs a name + target map (if to map)." : "Need at least 3 points."); - if (ok) editTool = "select"; - syncPolyDraftUi(); - renderMapView(); - }; - } - - const clearKind = document.getElementById("mapsPolyClearKind"); - if (clearKind) { - clearKind.onclick = () => { - const list = polysForKind(activeMap, editKind); - if (!list.length) return; - const ok = window.confirm(`Clear all polygons in ${kindLabel(editKind)}? (Not saved yet)`); - if (!ok) return; - const target = polysForKind(activeMap, editKind, true); - target.length = 0; - selectedPolyKind = ""; - selectedPolyIndex = -1; - selectedVertexIndex = -1; - draftPoly = []; - setStatus("Cleared kind (not saved)."); - renderMapView(); - }; - } - - const saveAll = document.getElementById("mapsPolySaveAll"); - if (saveAll) { - saveAll.onclick = () => { - if (!activeMap?.id) return; - const collisions = Array.isArray(activeMap.collisions) ? activeMap.collisions : []; - const masks = Array.isArray(activeMap.masks) ? activeMap.masks : []; - const exits = Array.isArray(activeMap.exits) ? activeMap.exits : []; - const hiddenMasks = Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks : []; - const fallThroughs = Array.isArray(activeMap.fallThroughs) ? activeMap.fallThroughs : []; - const occluders = Array.isArray(activeMap.occluders) ? activeMap.occluders : []; - devLog("info", "maps:saveAll", { - mapId: activeMap.id, - collisions: collisions.length, - masks: masks.length, - exits: exits.length, - hiddenMasks: hiddenMasks.length, - fallThroughs: fallThroughs.length, - occluders: occluders.length, - }); - ctx.send("updateMap", { id: activeMap.id, collisions, masks, exits, hiddenMasks, fallThroughs, occluders }); - setStatus("Saved."); - }; - } - - const cycle = (delta) => { - const list = polysForKind(activeMap, editKind); - if (!list.length) return; - const current = selectedPolyKind === editKind ? selectedPolyIndex : -1; - const next = current < 0 ? 0 : (current + delta + list.length) % list.length; - selectedPolyKind = editKind; - selectedPolyIndex = next; - selectedVertexIndex = -1; - editTool = "select"; - renderMapView(); - }; - const prevBtn = document.getElementById("mapsPolyPrev"); - const nextBtn = document.getElementById("mapsPolyNext"); - if (prevBtn) prevBtn.onclick = () => cycle(-1); - if (nextBtn) nextBtn.onclick = () => cycle(+1); - - const copyBtn = document.getElementById("mapsPolyCopy"); - if (copyBtn) { - copyBtn.onclick = () => { - const list = polysForKind(activeMap, editKind); - const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length; - if (!ok) return; - const src = list[selectedPolyIndex]; - polyClipboard = { kind: editKind, poly: JSON.parse(JSON.stringify(src)) }; - setStatus("Copied."); - renderMapView(); - }; - } - const pasteBtn = document.getElementById("mapsPolyPaste"); - if (pasteBtn) { - pasteBtn.onclick = () => { - if (!polyClipboard || !polyClipboard.poly) return; - const targetKind = editKind; - const list = polysForKind(activeMap, targetKind, true); - const copy = JSON.parse(JSON.stringify(polyClipboard.poly)); - const pts = Array.isArray(copy.points) ? copy.points : []; - for (const p of pts) { - p.x = Math.max(0, Math.min(1, Number(p.x || 0) + 0.02)); - p.y = Math.max(0, Math.min(1, Number(p.y || 0) + 0.02)); - } - copy.points = pts; - if (targetKind === "exit") { - copy.name = String(copy.name || "Exit").trim().slice(0, 40); - } - list.push(copy); - selectedPolyKind = targetKind; - selectedPolyIndex = list.length - 1; - selectedVertexIndex = -1; - setStatus("Pasted."); - renderMapView(); - }; - } - - const dupBtn = document.getElementById("mapsPolyDuplicate"); - if (dupBtn) { - dupBtn.onclick = () => { - const list = polysForKind(activeMap, editKind); - const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length; - if (!ok) return; - polyClipboard = { kind: editKind, poly: JSON.parse(JSON.stringify(list[selectedPolyIndex])) }; - const paste = document.getElementById("mapsPolyPaste"); - if (paste) paste.click(); - }; - } - - const delBtn = document.getElementById("mapsPolyDelete"); - if (delBtn) { - delBtn.onclick = () => { - const list = polysForKind(activeMap, editKind); - const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length; - if (!ok) return; - const label = editKind === "exit" ? String(list[selectedPolyIndex]?.name || `Exit ${selectedPolyIndex + 1}`) : `${kindLabel(editKind)} #${selectedPolyIndex + 1}`; - const yes = window.confirm(`Delete "${label}"? (Not saved yet)`); - if (!yes) return; - list.splice(selectedPolyIndex, 1); - selectedPolyKind = ""; - selectedPolyIndex = -1; - selectedVertexIndex = -1; - setStatus("Deleted (not saved)."); - renderMapView(); - }; - } - - // Exit meta fields (selected exit OR draft defaults) - const exitNameEl = document.getElementById("mapsExitName"); - const exitBehaviorEl = document.getElementById("mapsExitBehavior"); - const exitToMapWrap = document.getElementById("mapsExitToMapWrap"); - const exitToMapEl = document.getElementById("mapsExitToMap"); - const exitTargetExitEl = document.getElementById("mapsExitTargetExit"); - // Fog meta fields (selected fog OR draft defaults) - const fogModeEl = document.getElementById("mapsFogMode"); - const fogNameEl = document.getElementById("mapsFogName"); - // Fall-through meta fields (selected fall OR draft defaults) - const fallDirEl = document.getElementById("mapsFallDirection"); - const fallOffsetEl = document.getElementById("mapsFallOffset"); - const fallNameEl = document.getElementById("mapsFallName"); - - const applyExitModel = (patch) => { - if (editKind !== "exit") return; - const list = polysForKind(activeMap, "exit", true); - const isSel = selectedPolyKind === "exit" && selectedPolyIndex >= 0 && selectedPolyIndex < list.length; - if (isSel) { - list[selectedPolyIndex] = { ...list[selectedPolyIndex], ...patch }; - } else { - if (Object.prototype.hasOwnProperty.call(patch, "name")) exitDraftName = String(patch.name || ""); - if (Object.prototype.hasOwnProperty.call(patch, "action")) exitAction = patch.action === "toMap" ? "toMap" : "toMaps"; - if (Object.prototype.hasOwnProperty.call(patch, "toMapId")) exitTargetMapId = String(patch.toMapId || ""); - if (Object.prototype.hasOwnProperty.call(patch, "targetExit")) exitTargetExitName = String(patch.targetExit || ""); - } - }; - - const applyFogModel = (patch) => { - if (editKind !== "hidden") return; - const list = polysForKind(activeMap, "hidden", true); - const isSel = selectedPolyKind === "hidden" && selectedPolyIndex >= 0 && selectedPolyIndex < list.length; - if (isSel) { - const next = { ...list[selectedPolyIndex], ...patch }; - next.mode = String(next.mode || "auto") === "manual" ? "manual" : "auto"; - next.name = typeof next.name === "string" ? next.name.trim().slice(0, 40) : ""; - list[selectedPolyIndex] = next; - } else { - if (Object.prototype.hasOwnProperty.call(patch, "mode")) fogDraftMode = String(patch.mode || "") === "manual" ? "manual" : "auto"; - if (Object.prototype.hasOwnProperty.call(patch, "name")) fogDraftName = typeof patch.name === "string" ? patch.name.trim().slice(0, 40) : ""; - } - }; - - const applyFallModel = (patch) => { - if (editKind !== "fall") return; - const list = polysForKind(activeMap, "fall", true); - const isSel = selectedPolyKind === "fall" && selectedPolyIndex >= 0 && selectedPolyIndex < list.length; - const normalizeDir = (d) => { - const dir = String(d || "").trim().toLowerCase(); - return dir === "up" || dir === "left" || dir === "right" ? dir : "down"; - }; - const normalizeOffset = (n) => Math.max(0.002, Math.min(0.08, Number(n || 0.02) || 0.02)); - if (isSel) { - const next = { ...list[selectedPolyIndex], ...patch }; - next.direction = normalizeDir(next.direction); - next.offset = normalizeOffset(next.offset); - next.name = typeof next.name === "string" ? next.name.trim().slice(0, 40) : ""; - list[selectedPolyIndex] = next; - } else { - if (Object.prototype.hasOwnProperty.call(patch, "direction")) fallDraftDirection = normalizeDir(patch.direction); - if (Object.prototype.hasOwnProperty.call(patch, "offset")) fallDraftOffset = normalizeOffset(patch.offset); - if (Object.prototype.hasOwnProperty.call(patch, "name")) fallDraftName = typeof patch.name === "string" ? patch.name.trim().slice(0, 40) : ""; - } - }; - - const syncExitVis = () => { - const behavior = exitBehaviorEl ? String(exitBehaviorEl.value || "toMaps") : "toMaps"; - if (exitToMapWrap) exitToMapWrap.classList.toggle("hidden", behavior !== "toMap"); - }; - - if (exitBehaviorEl) { - exitBehaviorEl.onchange = () => { - const behavior = String(exitBehaviorEl.value || "toMaps") === "toMap" ? "toMap" : "toMaps"; - applyExitModel({ action: behavior }); - if (behavior === "toMap") { - const want = String(exitToMapEl?.value || "").trim().toLowerCase() || (otherMaps && otherMaps.length ? otherMaps[0] : ""); - if (want) { - if (exitToMapEl && exitToMapEl.value !== want) exitToMapEl.value = want; - applyExitModel({ toMapId: want }); - } - } - syncExitVis(); - renderMapView(); - }; - } - if (exitNameEl) { - exitNameEl.oninput = () => { - applyExitModel({ name: String(exitNameEl.value || "").slice(0, 40) }); - }; - } - if (exitToMapEl) { - if (!exitToMapEl.value && otherMaps.length) { - exitToMapEl.value = otherMaps[0]; - // If we're editing draft defaults, keep the draft model in sync with the UI. - applyExitModel({ toMapId: String(exitToMapEl.value || "").trim().toLowerCase() }); - } - exitToMapEl.onchange = () => { - applyExitModel({ toMapId: String(exitToMapEl.value || "").trim().toLowerCase() }); - }; - } - if (exitTargetExitEl) { - exitTargetExitEl.oninput = () => { - applyExitModel({ targetExit: String(exitTargetExitEl.value || "").slice(0, 40) }); - }; - } - syncExitVis(); - - if (fogModeEl) { - fogModeEl.onchange = () => { - applyFogModel({ mode: String(fogModeEl.value || "auto") === "manual" ? "manual" : "auto" }); - }; - } - if (fogNameEl) { - fogNameEl.oninput = () => { - applyFogModel({ name: String(fogNameEl.value || "").slice(0, 40) }); - }; - } - - if (fallDirEl) { - fallDirEl.onchange = () => { - applyFallModel({ direction: String(fallDirEl.value || "down") }); - }; - } - if (fallOffsetEl) { - const onOffset = () => applyFallModel({ offset: Number(fallOffsetEl.value || 0.02) || 0.02 }); - fallOffsetEl.oninput = onOffset; - fallOffsetEl.onchange = onOffset; - } - if (fallNameEl) { - fallNameEl.oninput = () => { - applyFallModel({ name: String(fallNameEl.value || "").slice(0, 40) }); - }; - } - } - - function pointInPoly(pt, poly) { - const x = pt.x; - const y = pt.y; - const pts = Array.isArray(poly?.points) ? poly.points : []; - if (pts.length < 3) return false; - let inside = false; - for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { - const xi = Number(pts[i].x); - const yi = Number(pts[i].y); - const xj = Number(pts[j].x); - const yj = Number(pts[j].y); - const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi + 1e-12) + xi; - if (intersect) inside = !inside; - } - return inside; - } - - function loadBackground(url) { - bgImg = null; - if (!url) return; - const img = new Image(); - img.crossOrigin = "anonymous"; - img.onload = () => { - bgImg = img; - }; - img.src = url; - } - - function stopLoop() { - if (raf) cancelAnimationFrame(raf); - raf = 0; - lastTick = 0; - } - - function startLoop() { - stopLoop(); - lastTick = performance.now(); - raf = requestAnimationFrame(tick); - } - - function tick(ts) { - raf = requestAnimationFrame(tick); - const dt = Math.max(0, Math.min(0.05, (ts - lastTick) / 1000)); - lastTick = ts; - if (mode !== "map" || !activeMap) return; - - // Smooth remote users to reduce jitter. - for (const [name, u] of users.entries()) { - if (!u) continue; - if (name === (self || String(ctx.getUser() || "").trim().toLowerCase())) continue; - if (typeof u.tx !== "number" || typeof u.ty !== "number") continue; - if (typeof u.x !== "number" || typeof u.y !== "number") { - u.x = u.tx; - u.y = u.ty; - continue; - } - const k = 1 - Math.exp(-dt * 14); - u.x = u.x + (u.tx - u.x) * k; - u.y = u.y + (u.ty - u.y) * k; - } - - // Movement speed in world pixels/sec, converted to normalized units based on map size. - const dims = getWorldDims(); - const speedPxPerSec = 220; - const possessedToken = getPossessedTokenForMe(); - const controlPos = possessedToken - ? { x: Math.max(0, Math.min(1, Number(possessedToken.x || 0.5))), y: Math.max(0, Math.min(1, Number(possessedToken.y || 0.5))) } - : { x: localPos.x, y: localPos.y }; - let dx = 0; - let dy = 0; - if (!editMode) { - if (keys.has("ArrowUp") || keys.has("KeyW")) dy -= 1; - if (keys.has("ArrowDown") || keys.has("KeyS")) dy += 1; - if (keys.has("ArrowLeft") || keys.has("KeyA")) dx -= 1; - if (keys.has("ArrowRight") || keys.has("KeyD")) dx += 1; - } - if (selfInvisible && !possessedToken) { - dx = 0; - dy = 0; - } - const mag = Math.hypot(dx, dy) || 1; - dx /= mag; - dy /= mag; - - const moved = Boolean(dx || dy); - if (moved) { - const speedNx = speedPxPerSec / Math.max(1, dims.w); - const speedNy = speedPxPerSec / Math.max(1, dims.h); - let nextX = Math.max(0, Math.min(1, controlPos.x + dx * speedNx * dt)); - let nextY = Math.max(0, Math.min(1, controlPos.y + dy * speedNy * dt)); - - // Fall-through zones: if you enter one, teleport to the far side based on direction. - const fallThroughs = Array.isArray(activeMap.fallThroughs) ? activeMap.fallThroughs : []; - if (fallThroughs.length) { - const prevPt = { x: controlPos.x, y: controlPos.y }; - const entered = (poly) => !pointInPoly(prevPt, poly) && pointInPoly({ x: nextX, y: nextY }, poly); - for (const poly of fallThroughs) { - if (!poly || !Array.isArray(poly.points) || poly.points.length < 3) continue; - if (!entered(poly)) continue; - const pts = poly.points; - let minX = 1, - maxX = 0, - minY = 1, - maxY = 0; - for (const p of pts) { - const x = Number(p?.x); - const y = Number(p?.y); - if (!Number.isFinite(x) || !Number.isFinite(y)) continue; - minX = Math.min(minX, x); - maxX = Math.max(maxX, x); - minY = Math.min(minY, y); - maxY = Math.max(maxY, y); - } - const dirRaw = String(poly.direction || "").trim().toLowerCase(); - const direction = dirRaw === "up" || dirRaw === "left" || dirRaw === "right" ? dirRaw : "down"; - const off = Math.max(0.002, Math.min(0.08, Number(poly.offset || 0.02) || 0.02)); - if (direction === "up" || direction === "down") { - const clampedX = Math.max(minX + 1e-4, Math.min(maxX - 1e-4, nextX)); - nextX = Math.max(0, Math.min(1, clampedX)); - nextY = direction === "down" ? Math.max(0, Math.min(1, maxY + off)) : Math.max(0, Math.min(1, minY - off)); - for (let i = 0; i < 8 && pointInPoly({ x: nextX, y: nextY }, poly); i++) { - nextY = direction === "down" ? Math.max(0, Math.min(1, nextY + off)) : Math.max(0, Math.min(1, nextY - off)); - } - } else { - const clampedY = Math.max(minY + 1e-4, Math.min(maxY - 1e-4, nextY)); - nextY = Math.max(0, Math.min(1, clampedY)); - nextX = direction === "right" ? Math.max(0, Math.min(1, maxX + off)) : Math.max(0, Math.min(1, minX - off)); - for (let i = 0; i < 8 && pointInPoly({ x: nextX, y: nextY }, poly); i++) { - nextX = direction === "right" ? Math.max(0, Math.min(1, nextX + off)) : Math.max(0, Math.min(1, nextX - off)); - } - } - break; - } - } - const collisions = Array.isArray(activeMap.collisions) ? activeMap.collisions : []; - const tryPtX = { x: nextX, y: controlPos.y }; - const tryPtY = { x: controlPos.x, y: nextY }; - const blockedX = collisions.some((p) => pointInPoly(tryPtX, p)); - const blockedY = collisions.some((p) => pointInPoly(tryPtY, p)); - const finalX = !blockedX ? nextX : controlPos.x; - const finalY = !blockedY ? nextY : controlPos.y; - if (possessedToken && activeMap?.ttrpgEnabled && canManageTtrpg) { - const props = Array.isArray(activeMap.props) ? activeMap.props : []; - const idx = props.findIndex((p) => String(p?.id || "") === String(possessedToken.id || "")); - if (idx >= 0) { - const current = props[idx]; - props[idx] = { ...current, x: finalX, y: finalY }; - activeMap.props = props; - const now = Date.now(); - if (now - lastPropMoveAt > 60) { - lastPropMoveAt = now; - ctx.send("ttrpgPropMove", { - mapId: activeMap.id, - propId: current.id, - x: finalX, - y: finalY, - z: current.z || 0, - rot: current.rot || 0, - scale: current.scale || 1 - }); - } - } - } else { - localPos.x = finalX; - localPos.y = finalY; - const me = (self || String(ctx.getUser() || "")).trim().toLowerCase(); - if (me) { - const prev = users.get(me) || { x: localPos.x, y: localPos.y, tx: localPos.x, ty: localPos.y, color: "", image: "" }; - users.set(me, { ...prev, x: localPos.x, y: localPos.y, tx: localPos.x, ty: localPos.y }); - } - const now = Date.now(); - if (now - lastSentAt > 60) { - lastSentAt = now; - ctx.send("move", { x: localPos.x, y: localPos.y, seq: moveSeq++ }); - } - } - } - - if (!editMode) { - const exitPos = possessedToken - ? { x: Math.max(0, Math.min(1, Number(possessedToken.x || 0.5))), y: Math.max(0, Math.min(1, Number(possessedToken.y || 0.5))) } - : localPos; - checkExits(exitPos); - } - draw(); - cleanupBubbles(); - } - - function checkExits(position = localPos) { - if (!activeMap) return; - const exits = Array.isArray(activeMap.exits) ? activeMap.exits : []; - if (!exits.length) return; - const now = Date.now(); - if (now - lastExitAt < 900) return; - let triggered = null; - for (let i = 0; i < exits.length; i++) { - const ex = exits[i]; - const inside = pointInPoly({ x: Number(position?.x || 0), y: Number(position?.y || 0) }, ex); - const was = Boolean(exitInside.get(i)); - exitInside.set(i, inside); - if (inside && !was) { - triggered = ex; - break; - } - } - if (!triggered) return; - lastExitAt = now; - const action = String(triggered.action || "toMaps"); - devLog("info", "maps:exitTriggered", { - mapId: activeMap?.id || "", - action, - toMapId: String(triggered.toMapId || ""), - targetExit: String(triggered.targetExit || ""), - }); - if (action === "toMap") { - const to = String(triggered.toMapId || "").trim().toLowerCase(); - const targetExit = String(triggered.targetExit || "").trim(); - const selfId = String(activeMap?.id || "").trim().toLowerCase(); - if (!to) { - devLog("warn", "maps:exitMissingTarget", { mapId: activeMap?.id || "", action: "toMap" }); - leaveMap(); - return; - } - if (to === selfId) { - devLog("warn", "maps:exitSelfTarget", { mapId: activeMap?.id || "", toMapId: to, action: "toMap" }); - leaveMap(); - return; - } - transitionToMap(to, targetExit); - return; - } - leaveMap(); - } - - function transitionToMap(mapId, targetExitName = "") { - // Leave current room on the server side, then join target. - try { - ctx.send("leave", {}); - } catch { - // ignore - } - stopWalkie(); - stopAllWalkies(); - exitInside.clear(); - const to = String(mapId || "").trim().toLowerCase(); - pendingSpawn = targetExitName ? { mapId: to, exitName: String(targetExitName || "").trim().toLowerCase() } : null; - enterMap(to); - } - - function getWorldDims() { - const w = - activeMap?.world?.w && Number.isFinite(Number(activeMap.world.w)) - ? Number(activeMap.world.w) - : bgImg && (bgImg.naturalWidth || bgImg.width) - ? Number(bgImg.naturalWidth || bgImg.width) - : 1400; - const h = - activeMap?.world?.h && Number.isFinite(Number(activeMap.world.h)) - ? Number(activeMap.world.h) - : bgImg && (bgImg.naturalHeight || bgImg.height) - ? Number(bgImg.naturalHeight || bgImg.height) - : 900; - return { w: Math.max(200, Math.min(10000, w)), h: Math.max(200, Math.min(10000, h)) }; - } - - function propScreenBox(prop, spriteById, tr) { - const sprite = spriteById.get(String(prop?.spriteId || "")) || null; - if (!sprite) return null; - const img = getSpriteImage(sprite.url || ""); - if (!img) return null; - const spriteScale = Math.max(0.1, Math.min(4.0, Number(sprite.scale || 1))); - const instanceScale = Math.max(0.1, Math.min(4.0, Number(prop?.scale || 1))); - const scale = spriteScale * instanceScale; - const maxWorld = 220; - const minWorld = 12; - const iw = Math.max(1, Number(img.naturalWidth || img.width || 1)); - const ih = Math.max(1, Number(img.naturalHeight || img.height || 1)); - const wWorld = Math.max(minWorld, Math.min(maxWorld, iw * scale)); - const hWorld = Math.max(minWorld, Math.min(maxWorld, ih * scale)); - - const xw = Number(prop?.x || 0) * tr.worldW; - const yw = Number(prop?.y || 0) * tr.worldH; - const cx = (xw - tr.srcX) * tr.zoom; - const cy = (yw - tr.srcY) * tr.zoom; - const w = wWorld * tr.zoom; - const h = hWorld * tr.zoom; - return { x: cx - w / 2, y: cy - h / 2, w, h, cx, cy, img, sprite }; - } - - function hitTestPropAtPointer(clientX, clientY, canvas, tr) { - if (!activeMap?.ttrpgEnabled) return null; - const props = Array.isArray(activeMap.props) ? activeMap.props : []; - if (!props.length) return null; - const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : []; - const spriteById = new Map(sprites.map((s) => [String(s.id || ""), s])); - const rect = canvas.getBoundingClientRect(); - const sx = clientX - rect.left; - const sy = clientY - rect.top; - if (sx < 0 || sy < 0 || sx > rect.width || sy > rect.height) return null; - - // Check top-most first: sort by y then z. - const sorted = props - .slice() - .sort((a, b) => { - const ay = Number(a?.y || 0); - const by = Number(b?.y || 0); - if (ay !== by) return ay - by; - return Number(a?.z || 0) - Number(b?.z || 0); - }); - for (let i = sorted.length - 1; i >= 0; i--) { - const p = sorted[i]; - const box = propScreenBox(p, spriteById, tr); - if (!box) continue; - if (sx >= box.x && sx <= box.x + box.w && sy >= box.y && sy <= box.y + box.h) { - return { propId: String(p.id || ""), x: Number(p.x || 0), y: Number(p.y || 0) }; - } - } - return null; - } - - function getPossessedTokenForMe() { - if (!activeMap?.ttrpgEnabled) return null; - const me = String(ctx.getUser() || "").trim().toLowerCase(); - if (!me) return null; - const props = Array.isArray(activeMap.props) ? activeMap.props : []; - if (!props.length) return null; - const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : []; - const spriteById = new Map(sprites.map((s) => [String(s?.id || ""), s])); - const isTokenControlledByMe = (prop) => { - if (!prop) return false; - if (String(prop.controlledBy || "").trim().toLowerCase() !== me) return false; - const spr = spriteById.get(String(prop.spriteId || "")); - return spr?.kind === "token"; - }; - if (speakingAsPropId) { - const preferred = props.find((p) => String(p?.id || "") === String(speakingAsPropId || "")); - if (isTokenControlledByMe(preferred)) return preferred; - } - const fallback = props.find((p) => isTokenControlledByMe(p)); - return fallback || null; - } - - function cleanupBubbles() { - const t = Date.now(); - let changed = false; - for (const [u, b] of bubbles.entries()) { - if (!b || Number(b.expiresAt || 0) <= t) { - bubbles.delete(u); - changed = true; - } - } - if (changed && mode === "map") { - // force redraw by leaving tick running - } - } - - function draw() { - const canvas = document.getElementById("mapsCanvas"); - if (!canvas) return; - const wrap = canvas.parentElement; - if (!wrap) return; - const rect = wrap.getBoundingClientRect(); - const w = Math.max(1, Math.floor(rect.width)); - const h = Math.max(1, Math.floor(rect.height)); - if (canvas.width !== w || canvas.height !== h) { - canvas.width = w; - canvas.height = h; - } - const g = canvas.getContext("2d"); - if (!g) return; - g.clearRect(0, 0, w, h); - - // Camera + zoom. - const zoom = Math.max(0.8, Math.min(5.0, Number(activeMap?.cameraZoom || 2.35) || 2.35)); - const me = (self || String(ctx.getUser() || "")).trim().toLowerCase(); - const possessedToken = getPossessedTokenForMe(); - const followTarget = editMode - ? null - : possessedToken - ? { x: Number(possessedToken.x || 0.5), y: Number(possessedToken.y || 0.5) } - : me && !selfInvisible - ? { x: Number(localPos.x || 0.5), y: Number(localPos.y || 0.5) } - : null; - if (!cameraPos) { - const seed = followTarget || { x: Number(localPos.x || 0.5), y: Number(localPos.y || 0.5) }; - cameraPos = { x: seed.x, y: seed.y }; - } - if (followTarget) { - const dist = Math.hypot(followTarget.x - cameraPos.x, followTarget.y - cameraPos.y); - const lerp = dist > 0.25 ? 1 : 0.28; - cameraPos.x = cameraPos.x + (followTarget.x - cameraPos.x) * lerp; - cameraPos.y = cameraPos.y + (followTarget.y - cameraPos.y) * lerp; - } - const cam = cameraPos; - - const worldW = activeMap?.world?.w ? Number(activeMap.world.w) : bgImg ? bgImg.naturalWidth : 1400; - const worldH = activeMap?.world?.h ? Number(activeMap.world.h) : bgImg ? bgImg.naturalHeight : 900; - const viewW = w / zoom; - const viewH = h / zoom; - const cx = Math.max(viewW / 2, Math.min(worldW - viewW / 2, cam.x * worldW)); - const cy = Math.max(viewH / 2, Math.min(worldH - viewH / 2, cam.y * worldH)); - const srcX = Math.max(0, Math.min(worldW - viewW, cx - viewW / 2)); - const srcY = Math.max(0, Math.min(worldH - viewH, cy - viewH / 2)); - lastTransform = { srcX, srcY, zoom, worldW, worldH, viewW, viewH }; - - // Background (cropped to camera view) - if (bgImg) { - g.globalAlpha = 0.92; - g.drawImage(bgImg, srcX, srcY, viewW, viewH, 0, 0, w, h); - g.globalAlpha = 1; - } else { - g.fillStyle = "rgba(0,0,0,0.25)"; - g.fillRect(0, 0, w, h); - } - - // Props (TTRPG mode) — draw before players. - const tr = { srcX, srcY, zoom, worldW, worldH }; - if (activeMap?.ttrpgEnabled) { - const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : []; - const spriteById = new Map(sprites.map((s) => [String(s?.id || ""), s])); - const props = Array.isArray(activeMap.props) ? activeMap.props : []; - const sortedProps = props - .slice() - .sort((a, b) => { - const ay = Number(a?.y || 0); - const by = Number(b?.y || 0); - if (ay !== by) return ay - by; - return Number(a?.z || 0) - Number(b?.z || 0); - }); - for (const p of sortedProps) { - const box = propScreenBox(p, spriteById, tr); - if (!box) continue; - // Skip if far outside viewport for perf. - if (box.x > w + 80 || box.y > h + 80 || box.x + box.w < -80 || box.y + box.h < -80) continue; - const rotDeg = Number(p?.rot || 0); - const rot = Number.isFinite(rotDeg) ? (rotDeg * Math.PI) / 180 : 0; - g.save(); - g.globalAlpha = 0.98; - g.imageSmoothingEnabled = true; - g.shadowColor = "rgba(0,0,0,0.35)"; - g.shadowBlur = 10; - g.shadowOffsetY = 6; - g.translate(box.cx, box.cy); - if (rot) g.rotate(rot); - g.drawImage(box.img, -box.w / 2, -box.h / 2, box.w, box.h); - g.restore(); - } - } - - // Players (draw in world coords -> screen coords) - for (const [username, u] of users.entries()) { - if (!u) continue; - const rx = typeof u.x === "number" ? u.x : Number(u.tx || 0); - const ry = typeof u.y === "number" ? u.y : Number(u.ty || 0); - const xw = Number(rx || 0) * worldW; - const yw = Number(ry || 0) * worldH; - const px = Math.floor((xw - srcX) * zoom); - const py = Math.floor((yw - srcY) * zoom); - - const size = Math.max(18, Math.min(96, Math.floor(Number(activeMap?.avatarSize || 36)))); - const radius = Math.floor(size / 2); - const color = typeof u.color === "string" && u.color ? u.color : "#ff3ea5"; - - // Avatar circle - const img = getAvatarImage(username, u.image || ""); - g.save(); - g.beginPath(); - g.arc(px, py, radius, 0, Math.PI * 2); - g.closePath(); - g.clip(); - if (img) { - g.drawImage(img, px - radius, py - radius, size, size); - } else { - g.fillStyle = color; - g.beginPath(); - g.arc(px, py, radius, 0, Math.PI * 2); - g.fill(); - } - g.restore(); - g.strokeStyle = "rgba(255,255,255,0.28)"; - g.lineWidth = 2; - g.beginPath(); - g.arc(px, py, radius, 0, Math.PI * 2); - g.stroke(); - - // Username in user's color, with contrast highlight (bigger + darker for readability) - const nameText = `@${username}`; - const nameColor = normalizeReadableColor(color); - g.font = "700 15px system-ui, -apple-system, Segoe UI, sans-serif"; - g.textAlign = "center"; - const nm = g.measureText(nameText); - const nameW = Math.ceil(nm.width) + 14; - const nameH = 22; - const nameX = px - nameW / 2; - const nameY = py - (radius + 30); - const bg = chooseHighlightBg(nameColor); - g.fillStyle = bg; - g.strokeStyle = "rgba(255,255,255,0.10)"; - roundRect(g, nameX, nameY, nameW, nameH, 10); - g.fill(); - g.stroke(); - g.fillStyle = nameColor; - g.shadowColor = "rgba(0,0,0,0.55)"; - g.shadowBlur = 6; - g.shadowOffsetY = 2; - g.fillText(nameText, px, nameY + 16); - g.shadowBlur = 0; - - const b = bubbles.get(`user:${username}`); - if (b && b.text) { - const text = String(b.text); - const pad = 7; - g.font = "14px system-ui, -apple-system, Segoe UI, sans-serif"; - const tw = Math.min(w - 20, Math.ceil(g.measureText(text).width) + pad * 2); - const th = 26; - const bx = Math.max(10, Math.min(w - 10 - tw, px - tw / 2)); - const by = Math.max(10, py - (radius + 64)); - g.fillStyle = "rgba(10,9,14,0.88)"; - g.strokeStyle = "rgba(246,240,255,0.14)"; - roundRect(g, bx, by, tw, th, 12); - g.fill(); - g.stroke(); - g.fillStyle = "rgba(246,240,255,0.92)"; - g.shadowColor = "rgba(0,0,0,0.55)"; - g.shadowBlur = 6; - g.shadowOffsetY = 2; - g.fillText(text, bx + tw / 2, by + 18); - g.shadowBlur = 0; - } - } - - // Token chat bubbles - if (activeMap?.ttrpgEnabled) { - const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : []; - const spriteById = new Map(sprites.map((s) => [String(s?.id || ""), s])); - const props = Array.isArray(activeMap.props) ? activeMap.props : []; - for (const [key, b] of bubbles.entries()) { - if (!b || b.actorType !== "token") continue; - const propId = String(b.actorPropId || ""); - if (!propId || key !== `token:${propId}`) continue; - const prop = props.find((p) => String(p?.id || "") === propId); - if (!prop) continue; - const box = propScreenBox(prop, spriteById, { srcX, srcY, zoom, worldW, worldH }); - if (!box) continue; - const text = String(b.text || "").trim(); - if (!text) continue; - const pad = 7; - g.font = "14px system-ui, -apple-system, Segoe UI, sans-serif"; - const tw = Math.min(w - 20, Math.ceil(g.measureText(text).width) + pad * 2); - const th = 26; - const bx = Math.max(10, Math.min(w - 10 - tw, box.cx - tw / 2)); - const by = Math.max(10, box.y - 34); - g.fillStyle = "rgba(10,9,14,0.88)"; - g.strokeStyle = "rgba(246,240,255,0.14)"; - roundRect(g, bx, by, tw, th, 12); - g.fill(); - g.stroke(); - g.fillStyle = "rgba(246,240,255,0.92)"; - g.shadowColor = "rgba(0,0,0,0.55)"; - g.shadowBlur = 6; - g.shadowOffsetY = 2; - g.fillText(text, bx + tw / 2, by + 18); - g.shadowBlur = 0; - } - } - - // Y-sort masks: redraw background clipped to polygon on top of entities when they're "behind". - const masks = Array.isArray(activeMap.masks) ? activeMap.masks : []; - if (bgImg && masks.length) { - for (const poly of masks) { - const pts = Array.isArray(poly?.points) ? poly.points : []; - if (pts.length < 3) continue; - const sortY = Math.max(...pts.map((p) => Number(p?.y || 0))); - let needs = false; - for (const [, u] of users.entries()) { - if (!u) continue; - const ux = typeof u.x === "number" ? u.x : Number(u.tx || 0); - const uy = typeof u.y === "number" ? u.y : Number(u.ty || 0); - if (uy >= sortY) continue; // in front - if (!pointInPoly({ x: ux, y: uy }, poly)) continue; - needs = true; - break; - } - if (!needs && activeMap?.ttrpgEnabled) { - const props = Array.isArray(activeMap.props) ? activeMap.props : []; - for (const p of props) { - if (!p) continue; - const px = Number(p.x || 0); - const py = Number(p.y || 0); - if (py >= sortY) continue; - if (!pointInPoly({ x: px, y: py }, poly)) continue; - needs = true; - break; - } - } - if (!needs) continue; - g.save(); - g.beginPath(); - const first = pts[0]; - g.moveTo(((Number(first.x) * worldW - srcX) * zoom) | 0, ((Number(first.y) * worldH - srcY) * zoom) | 0); - for (let i = 1; i < pts.length; i++) { - const p = pts[i]; - g.lineTo(((Number(p.x) * worldW - srcX) * zoom) | 0, ((Number(p.y) * worldH - srcY) * zoom) | 0); - } - g.closePath(); - g.clip(); - g.globalAlpha = 0.92; - g.drawImage(bgImg, srcX, srcY, viewW, viewH, 0, 0, w, h); - g.restore(); - } - } - - // Fog zones: draw dark overlays over polygons, unless revealed. - if (!editMode && !revealFog) { - const fogs = Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks : []; - if (fogs.length) { - const possessed = getPossessedTokenForMe(); - const myPos = possessed - ? { x: Math.max(0, Math.min(1, Number(possessed.x || 0.5))), y: Math.max(0, Math.min(1, Number(possessed.y || 0.5))) } - : { x: localPos.x, y: localPos.y }; - g.save(); - g.globalAlpha = 1; - for (const poly of fogs) { - const pts = Array.isArray(poly?.points) ? poly.points : []; - if (pts.length < 3) continue; - const mode = String(poly?.mode || "auto") === "manual" ? "manual" : "auto"; - if (mode === "auto" && pointInPoly(myPos, poly)) continue; - g.beginPath(); - const first = pts[0]; - g.moveTo((Number(first.x) * worldW - srcX) * zoom, (Number(first.y) * worldH - srcY) * zoom); - for (let i = 1; i < pts.length; i++) { - const p = pts[i]; - g.lineTo((Number(p.x) * worldW - srcX) * zoom, (Number(p.y) * worldH - srcY) * zoom); - } - g.closePath(); - g.fillStyle = "rgba(5,4,10,0.78)"; - g.strokeStyle = "rgba(180,120,255,0.22)"; - g.lineWidth = 1.2; - g.fill(); - g.stroke(); - } - g.restore(); - } - } - - // Edit overlays - if (editMode) { - drawPolysOverlay(g, activeMap, worldW, worldH, srcX, srcY, zoom); - } - } - - function drawPolysOverlay(g, map, worldW, worldH, srcX, srcY, zoom) { - const selected = - selectedPolyKind && selectedPolyKind === editKind - ? (() => { - const list = polysForKind(map, editKind); - return selectedPolyIndex >= 0 && selectedPolyIndex < list.length ? list[selectedPolyIndex] : null; - })() - : null; - - const drawPoly = (poly, stroke, fill, showPoints, emphasized) => { - const pts = Array.isArray(poly?.points) ? poly.points : []; - if (pts.length < 2) return; - g.save(); - g.beginPath(); - g.moveTo((Number(pts[0].x) * worldW - srcX) * zoom, (Number(pts[0].y) * worldH - srcY) * zoom); - for (let i = 1; i < pts.length; i++) { - g.lineTo((Number(pts[i].x) * worldW - srcX) * zoom, (Number(pts[i].y) * worldH - srcY) * zoom); - } - g.closePath(); - g.fillStyle = fill; - g.strokeStyle = stroke; - g.lineWidth = emphasized ? 3.5 : 2; - if (emphasized) { - g.shadowColor = "rgba(0,0,0,0.45)"; - g.shadowBlur = 12; - } - g.fill(); - g.stroke(); - if (showPoints) { - g.fillStyle = stroke; - for (let i = 0; i < pts.length; i++) { - const p = pts[i]; - const x = (Number(p.x) * worldW - srcX) * zoom; - const y = (Number(p.y) * worldH - srcY) * zoom; - g.beginPath(); - const r = emphasized && selectedVertexIndex === i ? 7.0 : emphasized ? 5.2 : 3.2; - g.arc(x, y, r, 0, Math.PI * 2); - g.fill(); - if (emphasized) { - g.strokeStyle = "rgba(0,0,0,0.35)"; - g.lineWidth = 1; - g.stroke(); - } - } - } - g.restore(); - }; - - const collisions = Array.isArray(map.collisions) ? map.collisions : []; - const masks = Array.isArray(map.masks) ? map.masks : []; - for (const p of collisions) drawPoly(p, "rgba(255,70,70,0.82)", "rgba(255,70,70,0.10)", false, selected === p); - for (const p of masks) drawPoly(p, "rgba(80,195,255,0.82)", "rgba(80,195,255,0.08)", false, selected === p); - const exits = Array.isArray(map.exits) ? map.exits : []; - for (const p of exits) drawPoly(p, "rgba(255,215,90,0.90)", "rgba(255,215,90,0.10)", false, selected === p); - const hidden = Array.isArray(map.hiddenMasks) ? map.hiddenMasks : []; - const occ = Array.isArray(map.occluders) ? map.occluders : []; - const fall = Array.isArray(map.fallThroughs) ? map.fallThroughs : []; - for (const p of hidden) drawPoly(p, "rgba(180,120,255,0.80)", "rgba(180,120,255,0.08)", false, selected === p); - for (const p of fall) drawPoly(p, "rgba(255,140,80,0.80)", "rgba(255,140,80,0.08)", false, selected === p); - for (const p of occ) drawPoly(p, "rgba(120,255,180,0.80)", "rgba(120,255,180,0.08)", false, selected === p); - - if (selected) { - const stroke = - editKind === "collision" - ? "rgba(255,70,70,0.98)" - : editKind === "mask" - ? "rgba(80,195,255,0.98)" - : editKind === "exit" - ? "rgba(255,215,90,0.98)" - : editKind === "hidden" - ? "rgba(180,120,255,0.98)" - : editKind === "fall" - ? "rgba(255,140,80,0.98)" - : "rgba(120,255,180,0.98)"; - const fill = - editKind === "collision" - ? "rgba(255,70,70,0.16)" - : editKind === "mask" - ? "rgba(80,195,255,0.14)" - : editKind === "exit" - ? "rgba(255,215,90,0.14)" - : editKind === "hidden" - ? "rgba(180,120,255,0.12)" - : editKind === "fall" - ? "rgba(255,140,80,0.12)" - : "rgba(120,255,180,0.12)"; - drawPoly(selected, stroke, fill, true, true); - } - - if (draftPoly && draftPoly.length) { - const poly = { points: draftPoly }; - const stroke = - editKind === "collision" - ? "rgba(255,70,70,0.95)" - : editKind === "mask" - ? "rgba(80,195,255,0.95)" - : editKind === "exit" - ? "rgba(255,215,90,0.98)" - : editKind === "hidden" - ? "rgba(180,120,255,0.98)" - : editKind === "fall" - ? "rgba(255,140,80,0.98)" - : "rgba(120,255,180,0.98)"; - const fill = - editKind === "collision" - ? "rgba(255,70,70,0.10)" - : editKind === "mask" - ? "rgba(80,195,255,0.10)" - : editKind === "exit" - ? "rgba(255,215,90,0.10)" - : editKind === "hidden" - ? "rgba(180,120,255,0.10)" - : editKind === "fall" - ? "rgba(255,140,80,0.10)" - : "rgba(120,255,180,0.10)"; - drawPoly(poly, stroke, fill, true, false); - } - } - - function parseHexColor(hex) { - const s = String(hex || "").trim(); - const m = s.match(/^#([0-9a-f]{6})$/i); - if (!m) return null; - const n = parseInt(m[1], 16); - return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; - } - - function relLuma(rgb) { - // sRGB relative luminance - const srgb = [rgb.r, rgb.g, rgb.b].map((v) => v / 255).map((c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4))); - return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2]; - } - - function mix(a, b, t) { - return Math.round(a + (b - a) * t); - } - - function normalizeReadableColor(hex) { - const rgb = parseHexColor(hex); - if (!rgb) return "#ff3ea5"; - const l = relLuma(rgb); - if (l > 0.25) return hex; - // brighten toward white a bit - const t = (0.25 - l) * 1.15; - const r = mix(rgb.r, 255, Math.min(0.65, Math.max(0.15, t))); - const g = mix(rgb.g, 255, Math.min(0.65, Math.max(0.15, t))); - const b = mix(rgb.b, 255, Math.min(0.65, Math.max(0.15, t))); - return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`; - } - - function chooseHighlightBg(textHex) { - // Always use a darker background for legibility on busy maps. - // (We still tint the text itself via normalizeReadableColor().) - const rgb = parseHexColor(textHex); - if (!rgb) return "rgba(10,9,14,0.80)"; - return "rgba(10,9,14,0.80)"; - } - - function getAvatarImage(username, url) { - const u = String(username || "").toLowerCase(); - if (!u) return null; - const src = String(url || "").trim(); - if (!src) return null; - const now = Date.now(); - const cached = avatarCache.get(u); - if (cached && cached.src === src) { - if (cached.status === "ok" && cached.img) return cached.img; - if (cached.status === "loading") return null; - if (cached.status === "error" && now - Number(cached.failedAt || 0) < 5000) return null; - } - const img = new Image(); - if (!src.startsWith("data:")) img.crossOrigin = "anonymous"; - avatarCache.set(u, { src, img: null, status: "loading", failedAt: 0 }); - img.onload = () => avatarCache.set(u, { src, img, status: "ok", failedAt: 0 }); - img.onerror = () => avatarCache.set(u, { src, img: null, status: "error", failedAt: Date.now() }); - img.src = src; - return null; - } - - function roundRect(g, x, y, w, h, r) { - const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2)); - g.beginPath(); - g.moveTo(x + rr, y); - g.arcTo(x + w, y, x + w, y + h, rr); - g.arcTo(x + w, y + h, x, y + h, rr); - g.arcTo(x, y + h, x, y, rr); - g.arcTo(x, y, x + w, y, rr); - g.closePath(); - } - - function escapeHtml(s) { - return String(s || "") - .replace(/&/g, "&amp;") - .replace(/</g, "&lt;") - .replace(/>/g, "&gt;") - .replace(/\"/g, "&quot;") - .replace(/'/g, "&#39;"); - } - - function enterMap(mapId) { - mode = "map"; - users.clear(); - bubbles.clear(); - editMode = false; - draftPoly = []; - polyDrag = null; - vertexDrag = null; - selectedPolyKind = ""; - selectedPolyIndex = -1; - selectedVertexIndex = -1; - selfInvisible = false; - speakingAsPropId = ""; - ttrpgDockCollapsed = readDockCollapsed(mapId); - ttrpgTool = "select"; - cameraPos = null; - revealFog = getFogReveal(mapId); - // Seed a known-good local position (will be replaced once we get roomState). - localPos = { x: 0.5, y: 0.5 }; - exitInside.clear(); - activeMap = - maps.find((m) => m.id === mapId) || { - id: mapId, - title: mapId, - owner: "", - backgroundUrl: "", - thumbUrl: "", - userCount: 0, - avatarSize: 36, - cameraZoom: 2.35, - collisions: [], - masks: [], - exits: [], - hiddenMasks: [], - fallThroughs: [], - occluders: [], - ttrpgEnabled: false, - sprites: [], - props: [], - walkiesEnabled: false - }; - selectedPropId = ""; - renderMapView(); - ctx.send("join", { mapId }); - } - - function leaveMap() { - ctx.send("leave", {}); - mode = "maps"; - activeMap = null; - speakingAsPropId = ""; - if (appRoot) appRoot.classList.remove("mapsRoom"); - if (chatPanel) chatPanel.classList.remove("hidden"); - if (chatResizeHandle) chatResizeHandle.classList.remove("hidden"); - stopWalkie(); - stopAllWalkies(); - users.clear(); - bubbles.clear(); - keys.clear(); - stopLoop(); - renderMapsList(); - } - - if (mapsBtn) { - mapsBtn.addEventListener("click", () => { - if (mode === "hives") enterMaps(); - else exitMapsToHives(); - }); - } - - mapsPanel.addEventListener("click", (e) => { - const enter = e.target.closest("[data-mapenter]"); - if (enter) { - const id = String(enter.getAttribute("data-mapenter") || ""); - if (id) enterMap(id); - return; - } - const del = e.target.closest("[data-mapdelete]"); - if (del) { - const id = String(del.getAttribute("data-mapdelete") || ""); - if (!id) return; - const ok = window.confirm(`Delete map "${id}"? This cannot be undone.`); - if (!ok) return; - ctx.send("deleteMap", { id }); - return; - } - const back = e.target.closest("[data-mapback]"); - if (back) { - leaveMap(); - return; - } - }); - - function setChatOverlayOpen(open) { - const overlay = document.getElementById("mapsChatOverlay"); - const input = document.getElementById("mapsChatInput"); - const send = document.getElementById("mapsChatSend"); - const walkieBar = document.getElementById("mapsWalkieBar"); - if (!overlay || !input || !send) return; - overlay.classList.toggle("hidden", !open); - if (walkieBar) walkieBar.classList.toggle("hidden", Boolean(open) || !Boolean(activeMap?.walkiesEnabled)); - if (open) { - input.value = ""; - input.focus(); - } else { - input.blur(); - } - send.onclick = () => { - const text = String(input.value || "").trim(); - if (!text) return; - const me = String(ctx.getUser() || "").trim().toLowerCase(); - const actorPropId = speakingAsPropId ? String(speakingAsPropId) : ""; - if (actorPropId) bubbles.set(`token:${actorPropId}`, { text: text.slice(0, 120), actorType: "token", actorPropId, expiresAt: Date.now() + 4000 }); - else if (me) bubbles.set(`user:${me}`, { text: text.slice(0, 120), actorType: "user", username: me, expiresAt: Date.now() + 4000 }); - ctx.send("say", { text, actorPropId }); - setChatOverlayOpen(false); - }; - input.onkeydown = (ev) => { - if (ev.key === "Escape") { - ev.preventDefault(); - setChatOverlayOpen(false); - } - if (ev.key === "Enter") { - ev.preventDefault(); - const text = String(input.value || "").trim(); - if (!text) return; - const me = String(ctx.getUser() || "").trim().toLowerCase(); - const actorPropId = speakingAsPropId ? String(speakingAsPropId) : ""; - if (actorPropId) bubbles.set(`token:${actorPropId}`, { text: text.slice(0, 120), actorType: "token", actorPropId, expiresAt: Date.now() + 4000 }); - else if (me) bubbles.set(`user:${me}`, { text: text.slice(0, 120), actorType: "user", username: me, expiresAt: Date.now() + 4000 }); - ctx.send("say", { text, actorPropId }); - setChatOverlayOpen(false); - } - }; - } - - window.addEventListener("keydown", (e) => { - if (mode !== "map") return; - // This is a user gesture; try to unlock audio playback early. - ensureAudioReady(); - const overlay = document.getElementById("mapsChatOverlay"); - const overlayOpen = overlay && !overlay.classList.contains("hidden"); - if (editMode) { - if (e.key === "Escape") { - draftPoly = []; - polyDrag = null; - vertexDrag = null; - const se = document.getElementById("mapsPolyStatus"); - if (se) se.textContent = "Draft cleared."; - renderMapView(); - return; - } - if (e.key === "Enter") { - if (draftPoly.length < 3) return; - e.preventDefault(); - const ok = commitDraftPoly(); - const se = document.getElementById("mapsPolyStatus"); - if (se) se.textContent = ok ? "Polygon added." : "Need at least 3 points."; - if (ok) editTool = "select"; - renderMapView(); - return; - } - if ((e.ctrlKey || e.metaKey) && (e.key === "s" || e.key === "S")) { - e.preventDefault(); - if (!activeMap?.id) return; - const collisions = Array.isArray(activeMap.collisions) ? activeMap.collisions : []; - const masks = Array.isArray(activeMap.masks) ? activeMap.masks : []; - const exits = Array.isArray(activeMap.exits) ? activeMap.exits : []; - const hiddenMasks = Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks : []; - const fallThroughs = Array.isArray(activeMap.fallThroughs) ? activeMap.fallThroughs : []; - const occluders = Array.isArray(activeMap.occluders) ? activeMap.occluders : []; - ctx.send("updateMap", { id: activeMap.id, collisions, masks, exits, hiddenMasks, fallThroughs, occluders }); - const se = document.getElementById("mapsPolyStatus"); - if (se) se.textContent = "Saved."; - return; - } - if (e.key === "Delete" || e.key === "Backspace") { - const list = polysForKind(activeMap, editKind); - const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length; - if (!ok) return; - e.preventDefault(); - list.splice(selectedPolyIndex, 1); - selectedPolyKind = ""; - selectedPolyIndex = -1; - selectedVertexIndex = -1; - renderMapView(); - return; - } - if ((e.ctrlKey || e.metaKey) && (e.key === "c" || e.key === "C")) { - const list = polysForKind(activeMap, editKind); - const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length; - if (!ok) return; - e.preventDefault(); - polyClipboard = { kind: editKind, poly: JSON.parse(JSON.stringify(list[selectedPolyIndex])) }; - renderMapView(); - return; - } - if ((e.ctrlKey || e.metaKey) && (e.key === "v" || e.key === "V")) { - if (!polyClipboard || !polyClipboard.poly) return; - e.preventDefault(); - const list = polysForKind(activeMap, editKind, true); - const copy = JSON.parse(JSON.stringify(polyClipboard.poly)); - const pts = Array.isArray(copy.points) ? copy.points : []; - for (const p of pts) { - p.x = Math.max(0, Math.min(1, Number(p.x || 0) + 0.02)); - p.y = Math.max(0, Math.min(1, Number(p.y || 0) + 0.02)); - } - copy.points = pts; - list.push(copy); - selectedPolyKind = editKind; - selectedPolyIndex = list.length - 1; - selectedVertexIndex = -1; - renderMapView(); - return; - } - // Don't move / chat while editing. - return; - } - if (activeMap?.walkiesEnabled && !overlayOpen && !editMode && e.code === "Backquote") { - e.preventDefault(); - startWalkie().catch((err) => ctx.toast("Walkie", String(err?.message || err))); - const btn = document.getElementById("mapsWalkieBtn"); - if (btn) btn.textContent = "Recording…"; - return; - } - if (e.code === "KeyT" && !overlayOpen) { - e.preventDefault(); - setChatOverlayOpen(true); - return; - } - if (!overlayOpen && !editMode && activeMap?.ttrpgEnabled && canManageTtrpg) { - if (e.code === "KeyV") { - e.preventDefault(); - ttrpgTool = "select"; - renderMapView(); - return; - } - if (e.code === "KeyP") { - e.preventDefault(); - ttrpgTool = "place"; - renderMapView(); - return; - } - if (e.code === "Space") { - e.preventDefault(); - ttrpgTool = "pan"; - renderMapView(); - return; - } - } - if (!overlayOpen && !editMode && activeMap?.ttrpgEnabled && canManageTtrpg && (e.code === "KeyQ" || e.code === "KeyE")) { - e.preventDefault(); - const step = e.shiftKey ? 45 : 15; - const dir = e.code === "KeyQ" ? -1 : 1; - const wrapRot = (deg) => { - let d = Number(deg || 0); - if (!Number.isFinite(d)) d = 0; - while (d > 180) d -= 360; - while (d < -180) d += 360; - return d; - }; - const props = Array.isArray(activeMap?.props) ? activeMap.props : []; - const pidx = selectedPropId ? props.findIndex((p) => String(p?.id || "") === selectedPropId) : -1; - if (pidx >= 0) { - const p = props[pidx]; - const nextRot = wrapRot(Number(p?.rot || 0) + step * dir); - props[pidx] = { ...p, rot: nextRot }; - activeMap.props = props; - ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: selectedPropId, x: p.x, y: p.y, z: p.z || 0, rot: nextRot, scale: p.scale || 1 }); - return; - } - if (selectedSpriteId) { - placeRot = wrapRot(placeRot + step * dir); - renderTtrpgDock(); - return; - } - } - if (!overlayOpen && !editMode && activeMap?.ttrpgEnabled && canManageTtrpg && (e.code === "KeyZ" || e.code === "KeyX")) { - e.preventDefault(); - const delta = e.shiftKey ? 0.2 : 0.1; - const dir = e.code === "KeyZ" ? -1 : 1; - const props = Array.isArray(activeMap?.props) ? activeMap.props : []; - const pidx = selectedPropId ? props.findIndex((p) => String(p?.id || "") === selectedPropId) : -1; - if (pidx >= 0) { - const p = props[pidx]; - const nextScale = Math.max(0.1, Math.min(4.0, Number(p?.scale || 1) + delta * dir)); - props[pidx] = { ...p, scale: nextScale }; - activeMap.props = props; - ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: selectedPropId, x: p.x, y: p.y, z: p.z || 0, rot: p.rot || 0, scale: nextScale }); - renderTtrpgDock(); - return; - } - if (selectedSpriteId) { - placeScale = Math.max(0.1, Math.min(4.0, Number(placeScale || 1) + delta * dir)); - renderTtrpgDock(); - return; - } - } - if (overlayOpen) return; - if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "KeyW", "KeyA", "KeyS", "KeyD"].includes(e.code)) { - keys.add(e.code); - } - }); - window.addEventListener("keyup", (e) => { - if (mode !== "map") return; - keys.delete(e.code); - if (activeMap?.ttrpgEnabled && canManageTtrpg && e.code === "Space" && ttrpgTool === "pan") { - ttrpgTool = "select"; - renderMapView(); - } - if (activeMap?.walkiesEnabled && e.code === "Backquote") { - stopWalkie(); - const btn = document.getElementById("mapsWalkieBtn"); - if (btn) btn.textContent = "Hold to talk"; - } - }); - - ws.addEventListener("message", (evt) => { - let msg; - try { - msg = JSON.parse(evt.data); - } catch { - return; - } - if (!msg || typeof msg !== "object") return; - const type = String(msg.type || ""); - - if (type === "plugin:maps:mapsList") { - maps = Array.isArray(msg.maps) ? msg.maps : []; - if (mode === "maps") renderMapsList(); - return; - } - - if (type === "plugin:maps:joinOk") { - self = String(ctx.getUser() || "").trim().toLowerCase(); - selfInvisible = Boolean(msg.selfInvisible); - if (msg.map && typeof msg.map === "object") { - activeMap = { - id: String(msg.map.id || "").trim().toLowerCase(), - title: String(msg.map.title || "").trim(), - owner: String(msg.map.owner || "").trim().toLowerCase(), - backgroundUrl: String(msg.map.backgroundUrl || "").trim(), - world: msg.map.world || null, - avatarSize: Number(msg.map.avatarSize || 36) || 36, - cameraZoom: Number(msg.map.cameraZoom || 2.35) || 2.35, - collisions: Array.isArray(msg.map.collisions) ? msg.map.collisions : [], - masks: Array.isArray(msg.map.masks) ? msg.map.masks : [], - exits: Array.isArray(msg.map.exits) ? msg.map.exits : [], - hiddenMasks: Array.isArray(msg.map.hiddenMasks) ? msg.map.hiddenMasks : [], - occluders: Array.isArray(msg.map.occluders) ? msg.map.occluders : [], - fallThroughs: Array.isArray(msg.map.fallThroughs) ? msg.map.fallThroughs : [], - ttrpgEnabled: Boolean(msg.map.ttrpgEnabled), - sprites: Array.isArray(msg.map.sprites) ? msg.map.sprites : [], - props: Array.isArray(msg.map.props) ? msg.map.props : [], - walkiesEnabled: Boolean(msg.map.walkiesEnabled) - }; - ttrpgDockCollapsed = readDockCollapsed(activeMap.id); - revealFog = getFogReveal(activeMap.id); - if (pendingSpawn && pendingSpawn.mapId === activeMap.id && pendingSpawn.exitName) { - const exits = Array.isArray(activeMap.exits) ? activeMap.exits : []; - const want = String(pendingSpawn.exitName || "").trim().toLowerCase(); - const target = exits.find((ex) => String(ex?.name || "").trim().toLowerCase() === want); - if (target && Array.isArray(target.points) && target.points.length) { - const c = polyCentroid(target.points); - localPos = { x: c.x, y: c.y }; - lastExitAt = Date.now(); - try { - ctx.send("move", { x: c.x, y: c.y, seq: moveSeq++ }); - } catch { - // ignore - } - } - pendingSpawn = null; - } - renderMapView(); - } - return; - } - - if (type === "plugin:maps:mapPatched") { - if (mode !== "map") return; - const mapId = String(msg.mapId || "").trim().toLowerCase(); - if (!activeMap || mapId !== String(activeMap.id || "")) return; - const patch = msg.patch && typeof msg.patch === "object" ? msg.patch : null; - if (!patch) return; - if (Object.prototype.hasOwnProperty.call(patch, "avatarSize")) activeMap.avatarSize = Number(patch.avatarSize || 36) || 36; - if (Object.prototype.hasOwnProperty.call(patch, "cameraZoom")) activeMap.cameraZoom = Number(patch.cameraZoom || 2.35) || 2.35; - if (Object.prototype.hasOwnProperty.call(patch, "walkiesEnabled")) activeMap.walkiesEnabled = Boolean(patch.walkiesEnabled); - if (Object.prototype.hasOwnProperty.call(patch, "collisions")) activeMap.collisions = Array.isArray(patch.collisions) ? patch.collisions : []; - if (Object.prototype.hasOwnProperty.call(patch, "masks")) activeMap.masks = Array.isArray(patch.masks) ? patch.masks : []; - if (Object.prototype.hasOwnProperty.call(patch, "exits")) activeMap.exits = Array.isArray(patch.exits) ? patch.exits : []; - if (Object.prototype.hasOwnProperty.call(patch, "hiddenMasks")) activeMap.hiddenMasks = Array.isArray(patch.hiddenMasks) ? patch.hiddenMasks : []; - if (Object.prototype.hasOwnProperty.call(patch, "occluders")) activeMap.occluders = Array.isArray(patch.occluders) ? patch.occluders : []; - if (Object.prototype.hasOwnProperty.call(patch, "fallThroughs")) activeMap.fallThroughs = Array.isArray(patch.fallThroughs) ? patch.fallThroughs : []; - renderMapView(); - return; - } - - if (type === "plugin:maps:selfInvisible") { - if (mode !== "map") return; - const mapId = String(msg.mapId || "").trim().toLowerCase(); - if (!activeMap || mapId !== String(activeMap.id || "")) return; - selfInvisible = Boolean(msg.invisible); - renderMapView(); - return; - } - - if (type === "plugin:maps:roomState") { - if (mode !== "map") return; - const list = Array.isArray(msg.users) ? msg.users : []; - const next = new Map(); - for (const raw of list) { - const name = String(raw?.username || "").toLowerCase(); - if (!name) continue; - const tx = Number(raw?.x || 0); - const ty = Number(raw?.y || 0); - const prev = users.get(name) || { x: tx, y: ty, tx, ty, color: "", image: "" }; - prev.tx = tx; - prev.ty = ty; - // Initialize render position on first sight. - if (typeof prev.x !== "number" || typeof prev.y !== "number") { - prev.x = tx; - prev.y = ty; - } - prev.color = raw?.color || prev.color || ""; - prev.image = raw?.image || prev.image || ""; - next.set(name, prev); - } - users = next; - const me = (self || String(ctx.getUser() || "")).trim().toLowerCase(); - const mine = me ? users.get(me) : null; - if (mine) { - // Keep local prediction, but if we're brand new, seed from server once. - if (!Number.isFinite(localPos?.x) || !Number.isFinite(localPos?.y)) localPos = { x: Number(mine.tx || 0.5), y: Number(mine.ty || 0.5) }; - } - renderMapView(); - return; - } - - if (type === "plugin:maps:userMoved") { - if (mode !== "map") return; - const username = String(msg.username || "").toLowerCase(); - if (!username) return; - const me = (self || String(ctx.getUser() || "")).trim().toLowerCase(); - // Ignore self movement echoes to avoid jitter/snapback. - if (me && username === me) return; - const tx = Number(msg.x || 0); - const ty = Number(msg.y || 0); - const prev = users.get(username) || { x: tx, y: ty, tx, ty, color: "", image: "" }; - prev.tx = tx; - prev.ty = ty; - if (typeof prev.x !== "number" || typeof prev.y !== "number") { - prev.x = tx; - prev.y = ty; - } - users.set(username, prev); - return; - } - - if (type === "plugin:maps:bubble") { - if (mode !== "map") return; - const username = String(msg.username || "").toLowerCase(); - const actorType = String(msg.actorType || "user"); - const actorPropId = String(msg.actorPropId || ""); - const text = String(msg.text || "").trim(); - if (!username || !text) return; - const bubbleKey = actorType === "token" && actorPropId ? `token:${actorPropId}` : `user:${username}`; - bubbles.set(bubbleKey, { - text: text.slice(0, 120), - actorType: actorType === "token" ? "token" : "user", - actorPropId, - username, - displayName: String(msg.displayName || ""), - color: String(msg.color || ""), - expiresAt: Date.now() + 4000 - }); - return; - } - - if (type === "plugin:maps:walkie") { - if (mode !== "map") return; - playWalkie(msg); - return; - } - - if (type === "plugin:maps:ttrpgEnabled") { - if (mode !== "map") return; - const mapId = String(msg.mapId || "").trim().toLowerCase(); - if (!activeMap || mapId !== String(activeMap.id || "")) return; - activeMap.ttrpgEnabled = Boolean(msg.enabled); - if (!activeMap.ttrpgEnabled) { - selectedSpriteId = ""; - } - renderMapView(); - return; - } - - if (type === "plugin:maps:spriteAdded") { - if (mode !== "map") return; - const mapId = String(msg.mapId || "").trim().toLowerCase(); - if (!activeMap || mapId !== String(activeMap.id || "")) return; - const sprite = msg.sprite && typeof msg.sprite === "object" ? msg.sprite : null; - if (!sprite || !sprite.id) return; - if (!Array.isArray(activeMap.sprites)) activeMap.sprites = []; - activeMap.sprites = [...activeMap.sprites.filter((s) => String(s?.id || "") !== String(sprite.id)), sprite]; - if (canManageTtrpg && !selectedSpriteId) { - const k = spriteKind === "token" ? "token" : "prop"; - if ((sprite.kind || "prop") === k) selectedSpriteId = String(sprite.id); - } - renderTtrpgDock(); - return; - } - - if (type === "plugin:maps:spriteRemoved") { - if (mode !== "map") return; - const mapId = String(msg.mapId || "").trim().toLowerCase(); - if (!activeMap || mapId !== String(activeMap.id || "")) return; - const spriteId = String(msg.spriteId || ""); - if (!spriteId) return; - activeMap.sprites = (Array.isArray(activeMap.sprites) ? activeMap.sprites : []).filter((s) => String(s?.id || "") !== spriteId); - activeMap.props = (Array.isArray(activeMap.props) ? activeMap.props : []).filter((p) => String(p?.spriteId || "") !== spriteId); - if (selectedSpriteId === spriteId) selectedSpriteId = ""; - selectedPropId = ""; - renderTtrpgDock(); - return; - } - - if (type === "plugin:maps:propsReset") { - if (mode !== "map") return; - const mapId = String(msg.mapId || "").trim().toLowerCase(); - if (!activeMap || mapId !== String(activeMap.id || "")) return; - activeMap.props = Array.isArray(msg.props) ? msg.props : []; - if (speakingAsPropId && !activeMap.props.some((p) => String(p?.id || "") === String(speakingAsPropId))) speakingAsPropId = ""; - selectedPropId = ""; - renderTtrpgDock(); - return; - } - - if (type === "plugin:maps:propAdded") { - if (mode !== "map") return; - const mapId = String(msg.mapId || "").trim().toLowerCase(); - if (!activeMap || mapId !== String(activeMap.id || "")) return; - const prop = msg.prop && typeof msg.prop === "object" ? msg.prop : null; - if (!prop || !prop.id) return; - if (!Array.isArray(activeMap.props)) activeMap.props = []; - activeMap.props = [...activeMap.props.filter((p) => String(p?.id || "") !== String(prop.id)), prop]; - renderTtrpgDock(); - return; - } - - if (type === "plugin:maps:propMoved") { - if (mode !== "map") return; - const mapId = String(msg.mapId || "").trim().toLowerCase(); - if (!activeMap || mapId !== String(activeMap.id || "")) return; - const propId = String(msg.propId || ""); - if (!propId) return; - const props = Array.isArray(activeMap.props) ? activeMap.props : []; - const idx = props.findIndex((p) => String(p?.id || "") === propId); - if (idx < 0) return; - props[idx] = { - ...props[idx], - x: Number(msg.x || 0), - y: Number(msg.y || 0), - z: Number(msg.z || props[idx]?.z || 0), - rot: Object.prototype.hasOwnProperty.call(msg || {}, "rot") ? Number(msg.rot || 0) : Number(props[idx]?.rot || 0), - scale: Object.prototype.hasOwnProperty.call(msg || {}, "scale") ? Number(msg.scale || 1) : Number(props[idx]?.scale || 1) - }; - activeMap.props = props; - if (selectedPropId === propId) renderTtrpgDock(); - return; - } - - if (type === "plugin:maps:propRemoved") { - if (mode !== "map") return; - const mapId = String(msg.mapId || "").trim().toLowerCase(); - if (!activeMap || mapId !== String(activeMap.id || "")) return; - const propId = String(msg.propId || ""); - if (!propId) return; - activeMap.props = (Array.isArray(activeMap.props) ? activeMap.props : []).filter((p) => String(p?.id || "") !== propId); - if (selectedPropId === propId) selectedPropId = ""; - if (speakingAsPropId === propId) speakingAsPropId = ""; - renderTtrpgDock(); - return; - } - - if (type === "plugin:maps:propPatched") { - if (mode !== "map") return; - const mapId = String(msg.mapId || "").trim().toLowerCase(); - if (!activeMap || mapId !== String(activeMap.id || "")) return; - const prop = msg.prop && typeof msg.prop === "object" ? msg.prop : null; - if (!prop || !prop.id) return; - const props = Array.isArray(activeMap.props) ? activeMap.props : []; - const idx = props.findIndex((p) => String(p?.id || "") === String(prop.id || "")); - if (idx >= 0) props[idx] = { ...props[idx], ...prop }; - else props.push(prop); - activeMap.props = props; - if (speakingAsPropId && String(prop.id || "") === String(speakingAsPropId || "")) { - const controller = String(prop.controlledBy || "").trim().toLowerCase(); - const me = String(ctx.getUser() || "").trim().toLowerCase(); - if (controller && controller !== me) speakingAsPropId = ""; - } - renderTtrpgDock(); - return; - } - - if (type === "plugin:maps:error") { - const message = String(msg.message || "Maps error."); - ctx.toast("Maps", message); - return; - } - }); - - if (inRackMode) { - // In rack mode, Maps is its own panel: start in the list view immediately. - enterMaps(); - } else { - // Initial list request (in case the Maps view is opened immediately). - // The Maps panel triggers another list() on open. - ctx.send("list", {}); - } - }); -})(); - diff --git a/data.bak.20260219-051337/plugins/maps/plugin.json b/data.bak.20260219-051337/plugins/maps/plugin.json @@ -1,9 +0,0 @@ -{ - "id": "maps", - "name": "Maps", - "version": "0.3.9", - "description": "Adds spatial chat rooms with map camera, collisions/masks/exits, fog + fall-through zones, and TTRPG tooling.", - "entryClient": "client.js", - "entryServer": "server.js", - "permissions": ["ui", "ws"] -} diff --git a/data.bak.20260219-051337/plugins/maps/server.js b/data.bak.20260219-051337/plugins/maps/server.js @@ -1,1177 +0,0 @@ -const fs = require("fs"); -const path = require("path"); - -module.exports = function init(api) { - const MAP_CHAT_GLOBAL_MAX = 200; - const MAP_CHAT_LOCAL_RADIUS = Number.isFinite(Number(process.env.MAP_CHAT_LOCAL_RADIUS)) - ? Math.max(0.01, Math.min(1.0, Number(process.env.MAP_CHAT_LOCAL_RADIUS))) - : 0.12; // positions are normalized 0..1 - - const BUILTIN_MAPS = [ - { - id: "studio", - title: "Studio (demo)", - owner: "", - // Placeholder image; replace with your own PNG in a real plugin build. - backgroundUrl: "/assets/logobzl.png", - thumbUrl: "/assets/logobzl.png", - world: { w: 1400, h: 900 }, - avatarSize: 36, - cameraZoom: 2.35, - collisions: [], - masks: [], - exits: [], - hiddenMasks: [], - occluders: [], - ttrpgEnabled: false, - sprites: [], - props: [], - walkiesEnabled: false - } - ]; - - const DATA_DIR = path.join(process.cwd(), "data", "plugin-data"); - const MAPS_FILE = path.join(DATA_DIR, "maps.json"); - - /** @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}>} */ - let customMaps = []; - - /** @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}>, chatGlobal?: Array<{id:string,fromUser:string,text:string,createdAt:number}>}>} */ - const rooms = new Map(); - - function normId(raw) { - const s = typeof raw === "string" ? raw.trim().toLowerCase() : ""; - if (!s) return ""; - if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(s)) return ""; - return s; - } - - function clampInt(n, min, max) { - const x = Math.floor(Number(n)); - if (!Number.isFinite(x)) return min; - return Math.max(min, Math.min(max, x)); - } - - function isSafeImageUrl(url) { - const u = typeof url === "string" ? url.trim() : ""; - if (!u) return false; - if (u.startsWith("/uploads/")) return true; - if (u.startsWith("/assets/")) return true; - return false; - } - - function isSafeUploadUrl(url) { - const u = typeof url === "string" ? url.trim() : ""; - if (!u.startsWith("/uploads/")) return false; - if (!/^\/uploads\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,220}$/.test(u)) return false; - return true; - } - - function uploadsDir() { - return process.env.UPLOADS_DIR || path.join(process.cwd(), "data", "uploads"); - } - - function tryDeleteUploadSoon(url, createdAt) { - if (!isSafeUploadUrl(url)) return false; - const filename = url.replace("/uploads/", ""); - const filePath = path.resolve(path.join(uploadsDir(), filename)); - const root = path.resolve(uploadsDir()) + path.sep; - if (!filePath.startsWith(root)) return false; - const now = api.now(); - // Only delete "fresh" uploads to avoid nuking older content. - if (now - Number(createdAt || 0) > 10 * 60 * 1000) return false; - try { - const st = fs.statSync(filePath); - if (!st.isFile()) return false; - if (now - st.mtimeMs > 10 * 60 * 1000) return false; - fs.unlinkSync(filePath); - return true; - } catch { - return false; - } - } - - - function normalizePolyList(list) { - const input = Array.isArray(list) ? list : []; - const out = []; - const maxPolys = 80; - const maxPoints = 60; - for (const raw of input.slice(0, maxPolys)) { - const points = Array.isArray(raw?.points) ? raw.points : []; - if (points.length < 3) continue; - const normPoints = []; - for (const p of points.slice(0, maxPoints)) { - const x = Number(p?.x); - const y = Number(p?.y); - if (!Number.isFinite(x) || !Number.isFinite(y)) continue; - normPoints.push({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }); - } - if (normPoints.length < 3) continue; - out.push({ points: normPoints }); - } - return out; - } - - function normalizeFogList(list) { - const input = Array.isArray(list) ? list : []; - const out = []; - const maxPolys = 80; - const maxPoints = 60; - for (const raw of input.slice(0, maxPolys)) { - const points = Array.isArray(raw?.points) ? raw.points : []; - if (points.length < 3) continue; - const normPoints = []; - for (const p of points.slice(0, maxPoints)) { - const x = Number(p?.x); - const y = Number(p?.y); - if (!Number.isFinite(x) || !Number.isFinite(y)) continue; - normPoints.push({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }); - } - if (normPoints.length < 3) continue; - const modeRaw = - typeof raw?.mode === "string" - ? raw.mode.trim().toLowerCase() - : typeof raw?.reveal === "string" - ? raw.reveal.trim().toLowerCase() - : ""; - const mode = modeRaw === "manual" ? "manual" : "auto"; - const name = typeof raw?.name === "string" ? raw.name.trim().slice(0, 40) : ""; - out.push({ points: normPoints, mode, name }); - } - return out; - } - - function normalizeFallList(list) { - const input = Array.isArray(list) ? list : []; - const out = []; - const maxPolys = 60; - const maxPoints = 60; - for (const raw of input.slice(0, maxPolys)) { - const points = Array.isArray(raw?.points) ? raw.points : []; - if (points.length < 3) continue; - const normPoints = []; - for (const p of points.slice(0, maxPoints)) { - const x = Number(p?.x); - const y = Number(p?.y); - if (!Number.isFinite(x) || !Number.isFinite(y)) continue; - normPoints.push({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }); - } - if (normPoints.length < 3) continue; - const dirRaw = typeof raw?.direction === "string" ? raw.direction.trim().toLowerCase() : ""; - const direction = dirRaw === "up" || dirRaw === "left" || dirRaw === "right" ? dirRaw : "down"; - const offset = clampFloat(raw?.offset, 0.002, 0.08, 0.02); - const name = typeof raw?.name === "string" ? raw.name.trim().slice(0, 40) : ""; - out.push({ points: normPoints, direction, offset, name }); - } - return out; - } - - function normalizeExitList(list) { - const input = Array.isArray(list) ? list : []; - const out = []; - const maxExits = 40; - const maxPoints = 60; - for (const raw of input.slice(0, maxExits)) { - const points = Array.isArray(raw?.points) ? raw.points : []; - if (points.length < 3) continue; - const normPoints = []; - for (const p of points.slice(0, maxPoints)) { - const x = Number(p?.x); - const y = Number(p?.y); - if (!Number.isFinite(x) || !Number.isFinite(y)) continue; - normPoints.push({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }); - } - if (normPoints.length < 3) continue; - const name = typeof raw?.name === "string" ? raw.name.trim().slice(0, 40) : ""; - const actionRaw = typeof raw?.action === "string" ? raw.action.trim() : ""; - const action = actionRaw === "toMap" ? "toMap" : "toMaps"; - const toMapId = action === "toMap" ? normId(raw?.toMapId || "") : ""; - if (action === "toMap" && !toMapId) continue; - const targetExit = action === "toMap" && typeof raw?.targetExit === "string" ? raw.targetExit.trim().slice(0, 40) : ""; - out.push({ points: normPoints, name, action, toMapId, targetExit }); - } - return out; - } - - function normalizeSpriteList(list) { - const input = Array.isArray(list) ? list : []; - const out = []; - const max = 120; - for (const raw of input.slice(0, max)) { - const id = typeof raw?.id === "string" ? raw.id.trim() : ""; - const safeId = id && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,64}$/.test(id) ? id : randId("spr"); - const kind = raw?.kind === "token" ? "token" : "prop"; - const name = typeof raw?.name === "string" ? raw.name.trim().slice(0, 40) : ""; - const url = typeof raw?.url === "string" ? raw.url.trim() : ""; - if (!url.startsWith("/uploads/")) continue; - if (!isSafeImageUrl(url)) continue; - const scale = clampFloat(raw?.scale, 0.1, 4.0, 1.0); - out.push({ id: safeId, kind, name, url, scale }); - } - return out; - } - - function normalizePropList(list, allowedSpriteIds = null) { - const input = Array.isArray(list) ? list : []; - const out = []; - const max = 800; - for (const raw of input.slice(0, max)) { - const id = typeof raw?.id === "string" ? raw.id.trim() : ""; - const safeId = id && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,64}$/.test(id) ? id : randId("prop"); - const spriteId = typeof raw?.spriteId === "string" ? raw.spriteId.trim() : ""; - if (!spriteId) continue; - if (allowedSpriteIds && !allowedSpriteIds.has(spriteId)) continue; - const x = clamp01(raw?.x); - const y = clamp01(raw?.y); - const z = clampInt(raw?.z || 0, -10_000, 10_000); - const rot = clampFloat(raw?.rot, -180, 180, 0); - const scale = clampFloat(raw?.scale, 0.1, 4.0, 1.0); - const nickname = typeof raw?.nickname === "string" ? raw.nickname.trim().slice(0, 40) : ""; - const hpMax = clampInt(raw?.hpMax || 10, 0, 9999); - const hpCurrent = clampInt(raw?.hpCurrent || hpMax, 0, hpMax > 0 ? hpMax : 9999); - const controlledBy = typeof raw?.controlledBy === "string" ? normId(raw.controlledBy) : ""; - out.push({ id: safeId, spriteId, x, y, z, rot, scale, nickname, hpCurrent, hpMax, controlledBy }); - } - return out; - } - - function canManageMaps(ws, map) { - const role = String(ws?.user?.role || "").toLowerCase(); - const username = userIdentity(ws); - if (role === "owner" || role === "moderator") return true; - if (map && username && map.owner && username === map.owner) return true; - return false; - } - - function clamp01(n) { - const x = Number(n); - if (!Number.isFinite(x)) return 0; - return Math.max(0, Math.min(1, x)); - } - - function clampSeq(n) { - const x = Math.floor(Number(n)); - if (!Number.isFinite(x) || x < 0) return 0; - return Math.min(1_000_000_000, x); - } - - function clampFloat(n, min, max, fallback = min) { - const x = Number(n); - if (!Number.isFinite(x)) return fallback; - return Math.max(min, Math.min(max, x)); - } - - function randId(prefix = "id") { - return `${prefix}_${api.now()}_${Math.random().toString(16).slice(2)}`; - } - - const saveTimersByMapId = new Map(); - function scheduleSaveSoon(mapId) { - const mid = normId(mapId); - if (!mid) return; - const existing = saveTimersByMapId.get(mid); - if (existing) clearTimeout(existing); - saveTimersByMapId.set( - mid, - setTimeout(() => { - saveTimersByMapId.delete(mid); - try { - saveCustomMapsToDisk(); - } catch (e) { - console.warn("Maps plugin: failed to persist maps:", e?.message || e); - } - }, 500) - ); - } - - function mapById(id) { - const mid = normId(id); - if (!mid) return null; - return BUILTIN_MAPS.find((m) => m.id === mid) || customMaps.find((m) => m.id === mid) || null; - } - - function spriteById(map, spriteId) { - const sid = typeof spriteId === "string" ? spriteId.trim() : ""; - if (!sid) return null; - const sprites = Array.isArray(map?.sprites) ? map.sprites : []; - return sprites.find((s) => String(s?.id || "") === sid) || null; - } - - function propById(map, propId) { - const pid = typeof propId === "string" ? propId.trim() : ""; - if (!pid) return { prop: null, index: -1 }; - const props = Array.isArray(map?.props) ? map.props : []; - const index = props.findIndex((p) => String(p?.id || "") === pid); - return { prop: index >= 0 ? props[index] : null, index }; - } - - function roomFor(mapId) { - const mid = normId(mapId); - if (!mid) return null; - if (!rooms.has(mid)) rooms.set(mid, { users: new Map(), lastListAt: 0, walkies: new Map(), chatGlobal: [] }); - return rooms.get(mid) || null; - } - - function sanitizeMapChatText(text) { - const raw = typeof text === "string" ? text : ""; - return raw.replace(/\s+/g, " ").trim().slice(0, 420); - } - - function distance01(ax, ay, bx, by) { - const dx = Number(ax) - Number(bx); - const dy = Number(ay) - Number(by); - return Math.sqrt(dx * dx + dy * dy); - } - - function userIdentity(ws) { - const u = ws?.user?.username ? String(ws.user.username).trim().toLowerCase() : ""; - return u && /^[a-z0-9][a-z0-9_.-]{0,31}$/.test(u) ? u : ""; - } - - function listMapsPayload() { - const all = [...BUILTIN_MAPS, ...customMaps]; - return all.map((m) => { - const room = rooms.get(m.id); - const count = room ? Array.from(room.users.values()).filter((u) => !u?.invisible).length : 0; - return { - id: m.id, - title: m.title, - owner: m.owner || "", - thumbUrl: m.thumbUrl, - backgroundUrl: m.backgroundUrl, - world: m.world, - avatarSize: clampInt(m.avatarSize || 36, 18, 96), - cameraZoom: clampFloat(m.cameraZoom, 0.8, 5.0, 2.35), - walkiesEnabled: Boolean(m.walkiesEnabled), - ttrpgEnabled: Boolean(m.ttrpgEnabled), - spritesCount: Array.isArray(m.sprites) ? m.sprites.length : 0, - propsCount: Array.isArray(m.props) ? m.props.length : 0, - collisionsCount: Array.isArray(m.collisions) ? m.collisions.length : 0, - masksCount: Array.isArray(m.masks) ? m.masks.length : 0, - exitsCount: Array.isArray(m.exits) ? m.exits.length : 0, - userCount: count - }; - }); - } - - function broadcastMapsListThrottled() { - // Avoid spamming when users move around maps frequently. - const t = api.now(); - let should = false; - for (const m of [...BUILTIN_MAPS, ...customMaps]) { - const r = roomFor(m.id); - if (!r) continue; - if (t - (r.lastListAt || 0) > 750) { - r.lastListAt = t; - should = true; - } - } - if (!should) return; - api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); - } - - function usersInRoom(mapId) { - const room = rooms.get(normId(mapId)); - if (!room) return []; - return Array.from(room.users.keys()); - } - - function broadcastRoomState(mapId) { - const mid = normId(mapId); - const room = rooms.get(mid); - if (!room) return; - const all = Array.from(room.users.entries()); - const recipients = usersInRoom(mid); - for (const recipient of recipients) { - const users = all - .filter(([username, u]) => username === recipient || !u?.invisible) - .map(([username, u]) => ({ - username, - x: u.x, - y: u.y, - color: u.color || "", - image: u.image || "" - })); - api.sendToUsers([recipient], { type: "plugin:maps:roomState", mapId: mid, users }); - } - broadcastMapsListThrottled(); - } - - function leaveAnyRoom(ws) { - const username = userIdentity(ws); - if (!username) return; - const current = normId(ws.__mapsRoomId || ""); - if (!current) return; - const room = rooms.get(current); - if (!room) { - ws.__mapsRoomId = ""; - ws.__mapsInvisible = 0; - ws.__mapsSpeakAsPropId = ""; - return; - } - if (room.users.has(username)) room.users.delete(username); - ws.__mapsRoomId = ""; - ws.__mapsInvisible = 0; - ws.__mapsSpeakAsPropId = ""; - if (room.users.size === 0) rooms.delete(current); - broadcastRoomState(current); - } - - api.onWsClose((ws) => { - leaveAnyRoom(ws); - }); - - function loadCustomMapsFromDisk() { - try { - fs.mkdirSync(DATA_DIR, { recursive: true }); - if (!fs.existsSync(MAPS_FILE)) { - customMaps = []; - return; - } - const raw = fs.readFileSync(MAPS_FILE, "utf8"); - const json = JSON.parse(raw); - const list = Array.isArray(json?.maps) ? json.maps : []; - const next = []; - for (const m of list) { - const id = normId(m?.id || ""); - if (!id) continue; - if (BUILTIN_MAPS.some((b) => b.id === id)) continue; - const title = typeof m?.title === "string" ? m.title.trim().slice(0, 60) : id; - const owner = typeof m?.owner === "string" ? normId(m.owner) : ""; - const backgroundUrl = typeof m?.backgroundUrl === "string" ? m.backgroundUrl.trim() : ""; - const thumbUrl = typeof m?.thumbUrl === "string" ? m.thumbUrl.trim() : backgroundUrl; - if (!isSafeImageUrl(backgroundUrl) || !isSafeImageUrl(thumbUrl)) continue; - const avatarSize = clampInt(m?.avatarSize || 36, 18, 96); - const cameraZoom = clampFloat(m?.cameraZoom, 0.8, 5.0, 2.35); - const walkiesEnabled = Boolean(m?.walkiesEnabled); - const world = - m?.world && typeof m.world === "object" - ? { w: clampInt(m.world.w, 200, 10000), h: clampInt(m.world.h, 200, 10000) } - : null; - const collisions = normalizePolyList(m?.collisions); - const masks = normalizePolyList(m?.masks); - const exits = normalizeExitList(m?.exits); - const hiddenMasks = normalizeFogList(m?.hiddenMasks); - const occluders = normalizePolyList(m?.occluders); - const fallThroughs = normalizeFallList(m?.fallThroughs); - const ttrpgEnabled = Boolean(m?.ttrpgEnabled); - const sprites = normalizeSpriteList(m?.sprites); - const spriteIds = new Set(sprites.map((s) => s.id)); - const props = normalizePropList(m?.props, spriteIds); - next.push({ - id, - title, - owner, - backgroundUrl, - thumbUrl, - world, - avatarSize, - cameraZoom, - collisions, - masks, - exits, - hiddenMasks, - occluders, - fallThroughs, - ttrpgEnabled, - sprites, - props, - walkiesEnabled - }); - } - customMaps = next; - } catch (e) { - console.warn("Maps plugin: failed to load custom maps:", e?.message || e); - customMaps = []; - } - } - - function saveCustomMapsToDisk() { - fs.mkdirSync(DATA_DIR, { recursive: true }); - fs.writeFileSync(MAPS_FILE, JSON.stringify({ maps: customMaps }, null, 2)); - } - - loadCustomMapsFromDisk(); - - api.registerWs("list", (ws) => { - ws.send(JSON.stringify({ type: "plugin:maps:mapsList", maps: listMapsPayload() })); - }); - - api.registerWs("createMap", (ws, msg) => { - const username = userIdentity(ws); - if (!username) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Sign in required." })); - return; - } - const role = String(ws?.user?.role || "").toLowerCase(); - if (role !== "owner" && role !== "moderator") { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Owner/mod access required to create maps." })); - return; - } - - const id = normId(msg?.id || ""); - if (!id) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Invalid map id." })); - return; - } - if (mapById(id)) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map id already exists." })); - return; - } - - const title = typeof msg?.title === "string" ? msg.title.trim().slice(0, 60) : ""; - if (!title) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Missing map title." })); - return; - } - const backgroundUrl = typeof msg?.backgroundUrl === "string" ? msg.backgroundUrl.trim() : ""; - const thumbUrl = typeof msg?.thumbUrl === "string" ? msg.thumbUrl.trim() : backgroundUrl; - if (!isSafeImageUrl(backgroundUrl) || !isSafeImageUrl(thumbUrl)) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Invalid map image URL." })); - return; - } - const avatarSize = clampInt(msg?.avatarSize || 36, 18, 96); - - customMaps.push({ - id, - title, - owner: username, - backgroundUrl, - thumbUrl, - world: null, - avatarSize, - cameraZoom: 2.35, - collisions: [], - masks: [], - exits: [], - hiddenMasks: [], - occluders: [], - fallThroughs: [], - ttrpgEnabled: false, - sprites: [], - props: [], - walkiesEnabled: false - }); - try { - saveCustomMapsToDisk(); - } catch (e) { - customMaps = customMaps.filter((m) => m.id !== id); - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Failed to save map." })); - return; - } - - api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); - }); - - api.registerWs("updateMap", (ws, msg) => { - const mapId = normId(msg?.id || ""); - const map = mapById(mapId); - if (!map || BUILTIN_MAPS.some((m) => m.id === mapId)) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); - return; - } - if (!canManageMaps(ws, map)) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); - return; - } - const idx = customMaps.findIndex((m) => m.id === mapId); - if (idx < 0) return; - - const next = { ...customMaps[idx] }; - const patch = {}; - if (msg && Object.prototype.hasOwnProperty.call(msg, "avatarSize")) { - next.avatarSize = clampInt(msg.avatarSize, 18, 96); - patch.avatarSize = next.avatarSize; - } - if (msg && Object.prototype.hasOwnProperty.call(msg, "cameraZoom")) { - next.cameraZoom = clampFloat(msg.cameraZoom, 0.8, 5.0, 2.35); - patch.cameraZoom = next.cameraZoom; - } - if (msg && Object.prototype.hasOwnProperty.call(msg, "collisions")) { - next.collisions = normalizePolyList(msg.collisions); - patch.collisions = next.collisions; - } - if (msg && Object.prototype.hasOwnProperty.call(msg, "masks")) { - next.masks = normalizePolyList(msg.masks); - patch.masks = next.masks; - } - if (msg && Object.prototype.hasOwnProperty.call(msg, "exits")) { - next.exits = normalizeExitList(msg.exits); - patch.exits = next.exits; - } - if (msg && Object.prototype.hasOwnProperty.call(msg, "hiddenMasks")) { - next.hiddenMasks = normalizeFogList(msg.hiddenMasks); - patch.hiddenMasks = next.hiddenMasks; - } - if (msg && Object.prototype.hasOwnProperty.call(msg, "occluders")) { - next.occluders = normalizePolyList(msg.occluders); - patch.occluders = next.occluders; - } - if (msg && Object.prototype.hasOwnProperty.call(msg, "fallThroughs")) { - next.fallThroughs = normalizeFallList(msg.fallThroughs); - patch.fallThroughs = next.fallThroughs; - } - if (msg && Object.prototype.hasOwnProperty.call(msg, "ttrpgEnabled")) { - next.ttrpgEnabled = Boolean(msg.ttrpgEnabled); - } - if (msg && Object.prototype.hasOwnProperty.call(msg, "sprites")) { - next.sprites = normalizeSpriteList(msg.sprites); - } - if (msg && Object.prototype.hasOwnProperty.call(msg, "props")) { - const spriteIds = new Set((Array.isArray(next.sprites) ? next.sprites : []).map((s) => s?.id).filter(Boolean)); - next.props = normalizePropList(msg.props, spriteIds); - } - if (msg && Object.prototype.hasOwnProperty.call(msg, "walkiesEnabled")) { - next.walkiesEnabled = Boolean(msg.walkiesEnabled); - patch.walkiesEnabled = next.walkiesEnabled; - } - customMaps[idx] = next; - scheduleSaveSoon(mapId); - api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); - if (Object.keys(patch).length) { - sendToRoom(mapId, { type: "plugin:maps:mapPatched", mapId, patch }); - } - }); - - function customMapIndex(mapId) { - const mid = normId(mapId); - if (!mid) return -1; - return customMaps.findIndex((m) => m.id === mid); - } - - function sendToRoom(mapId, msg) { - const mid = normId(mapId); - if (!mid) return 0; - return api.sendToUsers(usersInRoom(mid), msg); - } - - api.registerWs("ttrpgSetEnabled", (ws, msg) => { - const mapId = normId(msg?.mapId || msg?.id || ""); - const idx = customMapIndex(mapId); - if (idx < 0) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); - return; - } - const map = customMaps[idx]; - if (!canManageMaps(ws, map)) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); - return; - } - map.ttrpgEnabled = Boolean(msg?.enabled); - scheduleSaveSoon(mapId); - api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); - sendToRoom(mapId, { type: "plugin:maps:ttrpgEnabled", mapId, enabled: Boolean(map.ttrpgEnabled) }); - }); - - api.registerWs("ttrpgSpriteAdd", (ws, msg) => { - const mapId = normId(msg?.mapId || ""); - const idx = customMapIndex(mapId); - if (idx < 0) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); - return; - } - const map = customMaps[idx]; - if (!canManageMaps(ws, map)) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); - return; - } - const url = typeof msg?.url === "string" ? msg.url.trim() : ""; - if (!url.startsWith("/uploads/") || !isSafeImageUrl(url)) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Invalid sprite image URL." })); - return; - } - const kind = msg?.kind === "token" ? "token" : "prop"; - const name = typeof msg?.name === "string" ? msg.name.trim().slice(0, 40) : ""; - const scale = clampFloat(msg?.scale, 0.1, 4.0, 1.0); - const id = typeof msg?.id === "string" && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,64}$/.test(msg.id) ? msg.id : randId("spr"); - const sprite = { id, kind, name, url, scale }; - if (!Array.isArray(map.sprites)) map.sprites = []; - map.sprites = normalizeSpriteList([...map.sprites, sprite]); - scheduleSaveSoon(mapId); - sendToRoom(mapId, { type: "plugin:maps:spriteAdded", mapId, sprite }); - api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); - }); - - api.registerWs("ttrpgSpriteRemove", (ws, msg) => { - const mapId = normId(msg?.mapId || ""); - const idx = customMapIndex(mapId); - if (idx < 0) return; - const map = customMaps[idx]; - if (!canManageMaps(ws, map)) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); - return; - } - const spriteId = typeof msg?.spriteId === "string" ? msg.spriteId.trim() : ""; - if (!spriteId) return; - map.sprites = (Array.isArray(map.sprites) ? map.sprites : []).filter((s) => String(s?.id || "") !== spriteId); - map.props = (Array.isArray(map.props) ? map.props : []).filter((p) => String(p?.spriteId || "") !== spriteId); - scheduleSaveSoon(mapId); - sendToRoom(mapId, { type: "plugin:maps:spriteRemoved", mapId, spriteId }); - sendToRoom(mapId, { type: "plugin:maps:propsReset", mapId, props: Array.isArray(map.props) ? map.props : [] }); - api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); - }); - - api.registerWs("ttrpgPropAdd", (ws, msg) => { - const username = userIdentity(ws); - if (!username) return; - const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); - const idx = customMapIndex(mapId); - if (idx < 0) return; - const map = customMaps[idx]; - if (!map.ttrpgEnabled) return; - if (!canManageMaps(ws, map)) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); - return; - } - const spriteId = typeof msg?.spriteId === "string" ? msg.spriteId.trim() : ""; - const spriteOk = (Array.isArray(map.sprites) ? map.sprites : []).some((s) => String(s?.id || "") === spriteId); - if (!spriteId || !spriteOk) return; - const x = clamp01(msg?.x); - const y = clamp01(msg?.y); - const z = clampInt(msg?.z || 0, -10_000, 10_000); - const rot = clampFloat(msg?.rot, -180, 180, 0); - const scale = clampFloat(msg?.scale, 0.1, 4.0, 1.0); - const sprite = spriteById(map, spriteId); - const nickname = typeof msg?.nickname === "string" ? msg.nickname.trim().slice(0, 40) : ""; - const hpMax = clampInt(msg?.hpMax || 10, 0, 9999); - const hpCurrent = clampInt(msg?.hpCurrent || hpMax, 0, hpMax > 0 ? hpMax : 9999); - const id = typeof msg?.id === "string" && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,64}$/.test(msg.id) ? msg.id : randId("prop"); - const prop = { - id, - spriteId, - x, - y, - z, - rot, - scale, - nickname: sprite?.kind === "token" ? nickname : "", - hpCurrent: sprite?.kind === "token" ? hpCurrent : 0, - hpMax: sprite?.kind === "token" ? hpMax : 0, - controlledBy: "" - }; - if (!Array.isArray(map.props)) map.props = []; - map.props = normalizePropList([...map.props, prop], new Set(map.sprites.map((s) => s.id))); - scheduleSaveSoon(mapId); - sendToRoom(mapId, { type: "plugin:maps:propAdded", mapId, prop }); - api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); - }); - - api.registerWs("ttrpgPropMove", (ws, msg) => { - const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); - const idx = customMapIndex(mapId); - if (idx < 0) return; - const map = customMaps[idx]; - if (!map.ttrpgEnabled) return; - if (!canManageMaps(ws, map)) return; - const propId = typeof msg?.propId === "string" ? msg.propId.trim() : ""; - if (!propId) return; - const list = Array.isArray(map.props) ? map.props : []; - const pidx = list.findIndex((p) => String(p?.id || "") === propId); - if (pidx < 0) return; - const prev = list[pidx] || {}; - const x = clamp01(msg?.x); - const y = clamp01(msg?.y); - const z = Object.prototype.hasOwnProperty.call(msg || {}, "z") ? clampInt(msg?.z || 0, -10_000, 10_000) : prev.z || 0; - const rot = Object.prototype.hasOwnProperty.call(msg || {}, "rot") ? clampFloat(msg?.rot, -180, 180, 0) : prev.rot || 0; - 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); - list[pidx] = { ...prev, x, y, z, rot, scale }; - map.props = list; - scheduleSaveSoon(mapId); - sendToRoom(mapId, { type: "plugin:maps:propMoved", mapId, propId, x, y, z, rot, scale }); - }); - - api.registerWs("ttrpgPropPatch", (ws, msg) => { - const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); - const idx = customMapIndex(mapId); - if (idx < 0) return; - const map = customMaps[idx]; - if (!map.ttrpgEnabled) return; - if (!canManageMaps(ws, map)) return; - const propId = typeof msg?.propId === "string" ? msg.propId.trim() : ""; - const { prop: prev, index: pidx } = propById(map, propId); - if (!prev || pidx < 0) return; - const sprite = spriteById(map, String(prev.spriteId || "")); - const isToken = sprite?.kind === "token"; - const patch = {}; - if (Object.prototype.hasOwnProperty.call(msg || {}, "nickname")) { - patch.nickname = isToken ? String(msg?.nickname || "").trim().slice(0, 40) : ""; - } - if (Object.prototype.hasOwnProperty.call(msg || {}, "hpMax")) { - patch.hpMax = isToken ? clampInt(msg?.hpMax || 0, 0, 9999) : 0; - } - if (Object.prototype.hasOwnProperty.call(msg || {}, "hpCurrent")) { - const currentCap = Object.prototype.hasOwnProperty.call(patch, "hpMax") ? patch.hpMax : clampInt(prev.hpMax || 0, 0, 9999); - patch.hpCurrent = isToken ? clampInt(msg?.hpCurrent || 0, 0, currentCap > 0 ? currentCap : 9999) : 0; - } - if (!Object.keys(patch).length) return; - const next = { ...prev, ...patch }; - map.props[pidx] = next; - scheduleSaveSoon(mapId); - sendToRoom(mapId, { type: "plugin:maps:propPatched", mapId, prop: next }); - }); - - api.registerWs("ttrpgTokenPossess", (ws, msg) => { - const username = userIdentity(ws); - if (!username) return; - const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); - const idx = customMapIndex(mapId); - if (idx < 0) return; - const map = customMaps[idx]; - if (!map.ttrpgEnabled) return; - if (!canManageMaps(ws, map)) return; - const action = msg?.action === "release" ? "release" : "possess"; - const props = Array.isArray(map.props) ? map.props : []; - - // Release always clears *all* tokens controlled by this user (prevents "stuck" control). - if (action === "release") { - let changed = false; - for (let i = 0; i < props.length; i++) { - const p = props[i]; - if (!p) continue; - if (String(p.controlledBy || "") !== username) continue; - const spr = spriteById(map, String(p.spriteId || "")); - if (!spr || spr.kind !== "token") continue; - props[i] = { ...p, controlledBy: "" }; - changed = true; - sendToRoom(mapId, { type: "plugin:maps:propPatched", mapId, prop: props[i] }); - } - ws.__mapsSpeakAsPropId = ""; - if (changed) { - map.props = props; - scheduleSaveSoon(mapId); - } - return; - } - - const propId = typeof msg?.propId === "string" ? msg.propId.trim() : ""; - const { prop: prev, index: pidx } = propById(map, propId); - if (!prev || pidx < 0) return; - const sprite = spriteById(map, String(prev.spriteId || "")); - if (!sprite || sprite.kind !== "token") return; - if (prev.controlledBy && prev.controlledBy !== username) return; - - // Possession is exclusive per user: release any other controlled tokens first. - for (let i = 0; i < props.length; i++) { - const p = props[i]; - if (!p) continue; - if (String(p.id || "") === propId) continue; - if (String(p.controlledBy || "") !== username) continue; - const spr = spriteById(map, String(p.spriteId || "")); - if (!spr || spr.kind !== "token") continue; - props[i] = { ...p, controlledBy: "" }; - sendToRoom(mapId, { type: "plugin:maps:propPatched", mapId, prop: props[i] }); - } - - const next = { ...prev, controlledBy: username }; - props[pidx] = next; - map.props = props; - ws.__mapsSpeakAsPropId = propId; - scheduleSaveSoon(mapId); - sendToRoom(mapId, { type: "plugin:maps:propPatched", mapId, prop: next }); - }); - - api.registerWs("ttrpgPropRemove", (ws, msg) => { - const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); - const idx = customMapIndex(mapId); - if (idx < 0) return; - const map = customMaps[idx]; - if (!map.ttrpgEnabled) return; - if (!canManageMaps(ws, map)) return; - const propId = typeof msg?.propId === "string" ? msg.propId.trim() : ""; - if (!propId) return; - map.props = (Array.isArray(map.props) ? map.props : []).filter((p) => String(p?.id || "") !== propId); - scheduleSaveSoon(mapId); - sendToRoom(mapId, { type: "plugin:maps:propRemoved", mapId, propId }); - api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); - }); - - api.registerWs("deleteMap", (ws, msg) => { - const mapId = normId(msg?.id || ""); - const map = mapById(mapId); - if (!map || BUILTIN_MAPS.some((m) => m.id === mapId)) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); - return; - } - if (!canManageMaps(ws, map)) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); - return; - } - customMaps = customMaps.filter((m) => m.id !== mapId); - try { - saveCustomMapsToDisk(); - } catch (e) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Failed to delete map." })); - return; - } - api.broadcast({ type: "plugin:maps:mapsList", maps: listMapsPayload() }); - }); - - api.registerWs("join", (ws, msg) => { - const username = userIdentity(ws); - if (!username) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Sign in required." })); - return; - } - const mapId = normId(msg?.mapId || ""); - const map = mapById(mapId); - if (!map) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); - return; - } - - leaveAnyRoom(ws); - - const room = roomFor(mapId); - if (!room) return; - - const prof = api.getProfile(username) || {}; - const color = typeof prof.color === "string" ? prof.color : ""; - const image = typeof prof.image === "string" ? prof.image : ""; - room.users.set(username, { x: Math.random(), y: Math.random(), color, image, invisible: false, seq: 0 }); - ws.__mapsRoomId = mapId; - ws.__mapsInvisible = 0; - - ws.send( - JSON.stringify({ - type: "plugin:maps:joinOk", - map: { - id: map.id, - title: map.title, - owner: map.owner || "", - backgroundUrl: map.backgroundUrl, - world: map.world || null, - avatarSize: clampInt(map.avatarSize || 36, 18, 96), - cameraZoom: clampFloat(map.cameraZoom, 0.8, 5.0, 2.35), - collisions: Array.isArray(map.collisions) ? map.collisions : [], - masks: Array.isArray(map.masks) ? map.masks : [], - exits: Array.isArray(map.exits) ? map.exits : [], - hiddenMasks: Array.isArray(map.hiddenMasks) ? map.hiddenMasks : [], - occluders: Array.isArray(map.occluders) ? map.occluders : [], - fallThroughs: Array.isArray(map.fallThroughs) ? map.fallThroughs : [], - ttrpgEnabled: Boolean(map.ttrpgEnabled), - sprites: Array.isArray(map.sprites) ? map.sprites : [], - props: Array.isArray(map.props) ? map.props : [], - walkiesEnabled: Boolean(map.walkiesEnabled) - }, - selfInvisible: false - }) - ); - broadcastRoomState(mapId); - }); - - api.registerWs("leave", (ws) => { - leaveAnyRoom(ws); - ws.send(JSON.stringify({ type: "plugin:maps:left" })); - }); - - api.registerWs("move", (ws, msg) => { - const username = userIdentity(ws); - if (!username) return; - const mapId = normId(ws.__mapsRoomId || ""); - if (!mapId) return; - const room = rooms.get(mapId); - if (!room) return; - const u = room.users.get(username); - if (!u) return; - - const t = api.now(); - const last = Number(ws.__mapsLastMoveAt || 0) || 0; - if (t - last < 50) return; // ~20Hz - ws.__mapsLastMoveAt = t; - - const x = clamp01(msg?.x); - const y = clamp01(msg?.y); - const seq = clampSeq(msg?.seq); - const prevSeq = clampSeq(u?.seq || 0); - if (seq && seq < prevSeq) return; - const next = { ...u, x, y, seq: seq || prevSeq }; - room.users.set(username, next); - - const payload = { type: "plugin:maps:userMoved", mapId, username, x, y, seq: seq || prevSeq }; - if (next.invisible) { - api.sendToUsers([username], payload); - } else { - api.sendToUsers(usersInRoom(mapId), payload); - } - }); - - api.registerWs("chatHistoryReq", (ws, msg) => { - const username = userIdentity(ws); - if (!username) return; - const mapId = normId(msg?.mapId || ws.__mapsRoomId || ""); - if (!mapId) return; - const room = rooms.get(mapId); - if (!room) return; - if (!room.users.has(username)) return; - const list = Array.isArray(room.chatGlobal) ? room.chatGlobal : []; - ws.send(JSON.stringify({ type: "plugin:maps:chatHistory", mapId, scope: "global", messages: list.slice(-MAP_CHAT_GLOBAL_MAX) })); - }); - - api.registerWs("chatSend", (ws, msg) => { - const username = userIdentity(ws); - if (!username) return; - const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); - if (!mapId) return; - const room = rooms.get(mapId); - if (!room) return; - const u = room.users.get(username); - if (!u) return; - - const scopeRaw = typeof msg?.scope === "string" ? msg.scope.trim().toLowerCase() : "local"; - const scope = scopeRaw === "global" ? "global" : "local"; - const text = sanitizeMapChatText(msg?.text); - if (!text) return; - - const createdAt = api.now(); - const id = `${createdAt}_${Math.random().toString(16).slice(2)}`; - const message = { id, fromUser: username, text, createdAt }; - const payload = { type: "plugin:maps:chatMessage", mapId, scope, message }; - - // If invisible, only send to self (consistent with bubbles/movement). - if (u.invisible) { - api.sendToUsers([username], payload); - return; - } - - if (scope === "global") { - if (!Array.isArray(room.chatGlobal)) room.chatGlobal = []; - room.chatGlobal.push(message); - if (room.chatGlobal.length > MAP_CHAT_GLOBAL_MAX * 2) room.chatGlobal = room.chatGlobal.slice(-MAP_CHAT_GLOBAL_MAX); - api.sendToUsers(usersInRoom(mapId), payload); - return; - } - - // Local: deliver only to users within radius at send-time ("witnessing it"). - const recipients = []; - const all = Array.from(room.users.entries()); - for (const [otherName, other] of all) { - if (!other) continue; - const d = distance01(u.x, u.y, other.x, other.y); - if (d <= MAP_CHAT_LOCAL_RADIUS) recipients.push(otherName); - } - if (!recipients.includes(username)) recipients.push(username); - api.sendToUsers(recipients, payload); - }); - - api.registerWs("say", (ws, msg) => { - const username = userIdentity(ws); - if (!username) return; - const mapId = normId(ws.__mapsRoomId || ""); - if (!mapId) return; - const map = mapById(mapId); - if (!map) return; - const room = rooms.get(mapId); - if (!room) return; - const u = room.users.get(username); - if (!u) return; - - const text = typeof msg?.text === "string" ? msg.text.replace(/\s+/g, " ").trim().slice(0, 120) : ""; - if (!text) return; - let actorType = "user"; - let actorPropId = ""; - let displayName = `@${username}`; - const color = typeof u.color === "string" ? u.color : ""; - const requestedPropId = typeof msg?.actorPropId === "string" ? msg.actorPropId.trim() : ""; - if (requestedPropId && map.ttrpgEnabled && canManageMaps(ws, map)) { - const { prop } = propById(map, requestedPropId); - const sprite = prop ? spriteById(map, String(prop.spriteId || "")) : null; - if (prop && sprite && sprite.kind === "token") { - if (!prop.controlledBy || prop.controlledBy === username) { - actorType = "token"; - actorPropId = requestedPropId; - ws.__mapsSpeakAsPropId = requestedPropId; - displayName = String(prop.nickname || sprite.name || sprite.id || "token").slice(0, 40); - } - } - } - const payload = { type: "plugin:maps:bubble", mapId, username, actorType, actorPropId, displayName, color, text, createdAt: api.now() }; - if (u.invisible) { - api.sendToUsers([username], payload); - } else { - api.sendToUsers(usersInRoom(mapId), payload); - } - }); - - api.registerWs("setInvisible", (ws, msg) => { - const username = userIdentity(ws); - if (!username) return; - const mapId = normId(msg?.mapId || ws.__mapsRoomId || ""); - if (!mapId) return; - const map = mapById(mapId); - if (!map) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." })); - return; - } - if (!canManageMaps(ws, map)) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." })); - return; - } - const room = rooms.get(mapId); - if (!room) return; - const u = room.users.get(username); - if (!u) return; - const invisible = Boolean(msg?.invisible); - room.users.set(username, { ...u, invisible }); - ws.__mapsInvisible = invisible ? 1 : 0; - ws.send(JSON.stringify({ type: "plugin:maps:selfInvisible", mapId, invisible })); - broadcastRoomState(mapId); - }); - - api.registerWs("walkieSend", (ws, msg) => { - const username = userIdentity(ws); - if (!username) return; - const mapId = normId(ws.__mapsRoomId || ""); - if (!mapId) return; - const map = mapById(mapId); - if (!map) return; - if (!map.walkiesEnabled) { - ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Walkies are disabled for this map." })); - return; - } - const room = roomFor(mapId); - if (!room) return; - if (!room.users.has(username)) return; - - const idRaw = typeof msg?.id === "string" ? msg.id.trim() : ""; - const id = idRaw && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,80}$/.test(idRaw) ? idRaw : `${api.now()}_${Math.random().toString(16).slice(2)}`; - const url = typeof msg?.url === "string" ? msg.url.trim() : ""; - if (!isSafeUploadUrl(url)) return; - const x = clamp01(msg?.x); - const y = clamp01(msg?.y); - - const createdAt = api.now(); - const pending = new Set(usersInRoom(mapId)); - if (!room.walkies) room.walkies = new Map(); - room.walkies.set(id, { url, pending, createdAt, mapId }); - - api.sendToUsers(usersInRoom(mapId), { type: "plugin:maps:walkie", mapId, id, username, url, x, y, createdAt }); - - // Hard timeout to ensure cleanup even if clients never ack. - setTimeout(() => { - const r = rooms.get(mapId); - const entry = r?.walkies?.get(id); - if (!entry) return; - r.walkies.delete(id); - tryDeleteUploadSoon(url, createdAt); - }, 2 * 60 * 1000); - }); - - api.registerWs("walkiePlayed", (ws, msg) => { - const username = userIdentity(ws); - if (!username) return; - const mapId = normId(ws.__mapsRoomId || ""); - if (!mapId) return; - const room = rooms.get(mapId); - if (!room || !room.walkies) return; - const id = typeof msg?.id === "string" ? msg.id.trim() : ""; - if (!id) return; - const entry = room.walkies.get(id); - if (!entry) return; - entry.pending.delete(username); - if (entry.pending.size === 0) { - room.walkies.delete(id); - tryDeleteUploadSoon(entry.url, entry.createdAt); - } - }); -}; diff --git a/data.bak.20260219-051337/posts.json b/data.bak.20260219-051337/posts.json @@ -1,7 +0,0 @@ -{ - "version": 1, - "savedAt": 1771502611683, - "posts": [], - "postReactions": {}, - "chatReactions": {} -} diff --git a/data.bak.20260219-051337/reports.json b/data.bak.20260219-051337/reports.json @@ -1,4 +0,0 @@ -{ - "version": 1, - "reports": [] -} diff --git a/data.bak.20260219-051337/roles.json b/data.bak.20260219-051337/roles.json @@ -1,32 +0,0 @@ -{ - "version": 1, - "roles": [ - { - "key": "musicfan", - "label": "musicfan", - "color": "#ff3ea5", - "order": 1, - "createdAt": 1770963302907, - "createdBy": "azakaela", - "archived": true - }, - { - "key": "test", - "label": "test", - "color": "#ffffff", - "order": 2, - "createdAt": 1771107956093, - "createdBy": "azakaela", - "archived": true - }, - { - "key": "readers", - "label": "readers", - "color": "#ff299b", - "order": 3, - "createdAt": 1771113577746, - "createdBy": "brookemmmz", - "archived": true - } - ] -} diff --git a/data.bak.20260219-051337/sessions.json b/data.bak.20260219-051337/sessions.json @@ -1,141 +0,0 @@ -{ - "version": 1, - "sessions": [ - { - "id": "96659849-01c7-43a9-9c26-5bde237e5c2a", - "username": "dokkaecat", - "secretHash": "0f68244687b6a88988c36e67a00cf3176a1bc2f095bfcf4681a7698c81b1075b", - "createdAt": 1770938742778, - "expiresAt": 1773530742778, - "lastSeenAt": 1770938742778 - }, - { - "id": "d5c4d8eb-a0d5-4829-a42e-5893cebf3d27", - "username": "gchahn", - "secretHash": "8a5ddb18d85cfc8f4eb758ab6db0c780d6c10d438165b8a0431aa9d99dc6c538", - "createdAt": 1771006676901, - "expiresAt": 1773599935103, - "lastSeenAt": 1771007935103 - }, - { - "id": "1bf0e349-8b10-4572-8e3a-7231d7096c8c", - "username": "zipbomb", - "secretHash": "897fb8143f3676e4f01fb3fc0a6008ebe520afde77f3266e47d712a3d0de67b4", - "createdAt": 1771037095981, - "expiresAt": 1773629095981, - "lastSeenAt": 1771037095981 - }, - { - "id": "2e2ec1dd-de1d-4bad-8482-4e71c8d26e4f", - "username": "azakaela", - "secretHash": "f2b62d74fd3922f423027a3fd5f88cbba7a5108df28113a25f647032b69e33ce", - "createdAt": 1771121960968, - "expiresAt": 1773713960968, - "lastSeenAt": 1771121960968 - }, - { - "id": "cfe79b7c-834a-45c1-aef3-dc6487b32c49", - "username": "metalizanagi", - "secretHash": "8ec07a80f9c4e653c25dece9c2d27616ae3fd92070db58c7220104d985b7e90c", - "createdAt": 1771131746084, - "expiresAt": 1773723746084, - "lastSeenAt": 1771131746084 - }, - { - "id": "cd7a3384-1fe3-4dea-bcda-34726d522e06", - "username": "edward", - "secretHash": "c83d87c32cc2ea0a8a13f5d8fc80f650c187c8de0f1051d7c4a8dd332a84dc7b", - "createdAt": 1771186995467, - "expiresAt": 1773778995467, - "lastSeenAt": 1771186995467 - }, - { - "id": "a26dfe50-0940-48e9-a9bb-89c21b5d5c6e", - "username": "azakaela", - "secretHash": "8405515bfc08063f4219118953f68b4917455baa73dae467ba7c96af061c2128", - "createdAt": 1771188306554, - "expiresAt": 1773780306554, - "lastSeenAt": 1771188306554 - }, - { - "id": "ed6b332c-a30e-442f-8a31-88fd8ebb1ec0", - "username": "brookemmmz", - "secretHash": "f2048dd8588e8c64ac9de3b4e9dd082ceca125df7caf3e6cb6cf80a005d76c11", - "createdAt": 1771206927506, - "expiresAt": 1773798927506, - "lastSeenAt": 1771206927506 - }, - { - "id": "82769d05-0197-4baf-9348-9d6f1e7951f5", - "username": "unianetwork", - "secretHash": "f91ac129565f33855a8fbf4bdf4ad193bbd91023763e11deedb26a86af8ca43f", - "createdAt": 1771207506920, - "expiresAt": 1773799506920, - "lastSeenAt": 1771207506920 - }, - { - "id": "e5d5fef4-2d43-4ee8-bd4b-d5f2c945faff", - "username": "azakaela", - "secretHash": "2801a38453124baa4b2ac3e61cdb9fbb58eb2119d3e1fbe8bdab76dd82abffd0", - "createdAt": 1771286583864, - "expiresAt": 1773878583864, - "lastSeenAt": 1771286583864 - }, - { - "id": "8f710f32-eb16-407a-aa4f-6f1a6d4328fc", - "username": "azakaela", - "secretHash": "c08fc43f58d39b03005e34e3419007038cfe9509827119c4b1c25892394dfe55", - "createdAt": 1771363312324, - "expiresAt": 1773955312324, - "lastSeenAt": 1771363312324 - }, - { - "id": "40efe9d2-8dac-46f6-87be-a536f535b9ab", - "username": "azakaela", - "secretHash": "13d15b61b1bbe880e8a240f6dfb16b0133c1a6ae84233a9c7be86409f179a88d", - "createdAt": 1771366079233, - "expiresAt": 1773958079233, - "lastSeenAt": 1771366079233 - }, - { - "id": "591af00a-c4fc-4a24-92bc-aed178529eb9", - "username": "azakaela", - "secretHash": "5ab00f438df157895a891a56c1fbf1b408f93c6789ac6a682fd1c7b4573c2a0f", - "createdAt": 1771374558915, - "expiresAt": 1773966558915, - "lastSeenAt": 1771374558915 - }, - { - "id": "e6315b32-18ef-4f45-af7c-b22afdcbce2a", - "username": "azakaela", - "secretHash": "70c02db15286aff2aeae0bfa1b8fe967c1a842be822031d8871c2ecba9a4fb25", - "createdAt": 1771379904174, - "expiresAt": 1773971904174, - "lastSeenAt": 1771379904174 - }, - { - "id": "2afc5370-d2e4-416f-9991-45fd0b34844b", - "username": "azakaela", - "secretHash": "5d4a5d5b2de7171fba7baa7f0c3a281c81ac8743b0e1e54dc64caed4c17ed03d", - "createdAt": 1771384968279, - "expiresAt": 1773976968279, - "lastSeenAt": 1771384968279 - }, - { - "id": "2f4ab916-c03d-45d5-a245-b75ffce3372d", - "username": "zipbomb", - "secretHash": "9ed8778e6fa55d3c330bea1ead52a5a2c6b94fa8b9e9e65425c140b7db644d4c", - "createdAt": 1771386080000, - "expiresAt": 1773978080000, - "lastSeenAt": 1771386080000 - }, - { - "id": "a806ecab-a448-48f2-b809-ee23963706e1", - "username": "azakaela", - "secretHash": "29b4bbc654370d0894b1870eee91b89de6050a0fb2c0a6cb3ea0926023a5d2b0", - "createdAt": 1771387891107, - "lastSeenAt": 1771387891107, - "expiresAt": 1773979891107 - } - ] -} diff --git a/data.bak.20260219-051337/uploads/1770950130363-8834c8d2dcdd0d4dba86.gif b/data.bak.20260219-051337/uploads/1770950130363-8834c8d2dcdd0d4dba86.gif Binary files differ. diff --git a/data.bak.20260219-051337/uploads/1771103212934-a391f54ca9f718623b24.png b/data.bak.20260219-051337/uploads/1771103212934-a391f54ca9f718623b24.png Binary files differ. diff --git a/data.bak.20260219-051337/uploads/1771112006330-2be059809a3daf517e00.png b/data.bak.20260219-051337/uploads/1771112006330-2be059809a3daf517e00.png Binary files differ. diff --git a/data.bak.20260219-051337/uploads/1771132138431-69d9972f45f58bf88df6.png b/data.bak.20260219-051337/uploads/1771132138431-69d9972f45f58bf88df6.png Binary files differ. diff --git a/data.bak.20260219-051337/uploads/1771207189932-1298f4c42c2fadfca656.png b/data.bak.20260219-051337/uploads/1771207189932-1298f4c42c2fadfca656.png Binary files differ. diff --git a/data.bak.20260219-051337/uploads/1771361684484-4f732dc6356adcf10c1c.mp3 b/data.bak.20260219-051337/uploads/1771361684484-4f732dc6356adcf10c1c.mp3 Binary files differ. diff --git a/data.bak.20260219-051337/uploads/1771361698390-33aa47d698c92d911a6f.mp3 b/data.bak.20260219-051337/uploads/1771361698390-33aa47d698c92d911a6f.mp3 Binary files differ. diff --git a/data.bak.20260219-051337/uploads/library/shadow-ocean.pdf-2026-02-15T23-20-21-366Z-a0cc9320.pdf b/data.bak.20260219-051337/uploads/library/shadow-ocean.pdf-2026-02-15T23-20-21-366Z-a0cc9320.pdf Binary files differ. diff --git a/data.bak.20260219-051337/uploads/library/tawkys-pitch-deck-email-only.pdf-2026-02-16T00-37-25-191Z-79c29696.pdf b/data.bak.20260219-051337/uploads/library/tawkys-pitch-deck-email-only.pdf-2026-02-16T00-37-25-191Z-79c29696.pdf Binary files differ. diff --git a/data.bak.20260219-051337/uploads/library/tmp/40070a72fefd1bad1b1a455b.part b/data.bak.20260219-051337/uploads/library/tmp/40070a72fefd1bad1b1a455b.part diff --git a/data.bak.20260219-051337/uploads/library/tmp/5777b8905dab1affe9e683a5.part b/data.bak.20260219-051337/uploads/library/tmp/5777b8905dab1affe9e683a5.part diff --git a/data.bak.20260219-051337/uploads/library/tmp/8c769db5adab5550e68e539f.part b/data.bak.20260219-051337/uploads/library/tmp/8c769db5adab5550e68e539f.part diff --git a/data.bak.20260219-051337/uploads/library/tmp/ae86e22cc1d744fb9098972b.part b/data.bak.20260219-051337/uploads/library/tmp/ae86e22cc1d744fb9098972b.part diff --git a/data.bak.20260219-051337/uploads/library/tmp/cf72aef73464126acd0dda93.part b/data.bak.20260219-051337/uploads/library/tmp/cf72aef73464126acd0dda93.part diff --git a/data.bak.20260219-051337/uploads/library/tmp/d4f2fa55f999b9636017b7c5.part b/data.bak.20260219-051337/uploads/library/tmp/d4f2fa55f999b9636017b7c5.part diff --git a/data.bak.20260219-051337/uploads/library/tmp/f52f36f263be11824ffd6019.part b/data.bak.20260219-051337/uploads/library/tmp/f52f36f263be11824ffd6019.part diff --git a/data.bak.20260219-051337/uploads/library/tri-axis-of-sense.pdf-2026-02-16T00-42-32-306Z-9dbf5045.pdf b/data.bak.20260219-051337/uploads/library/tri-axis-of-sense.pdf-2026-02-16T00-42-32-306Z-9dbf5045.pdf Binary files differ. diff --git a/data.bak.20260219-051337/users.json b/data.bak.20260219-051337/users.json @@ -1,183 +0,0 @@ -{ - "version": 1, - "users": [ - { - "username": "azakaela", - "salt": "0900d8ebf0044d76c3387b411d62f8bb", - "hash": "9289e9c3defd64351b180fef8a2c1a7a20b73e53f09920b3930dd226075223d0c011441c03f5c59510a12f432dc148710a2ac865775d8481e34b5056e73a5d1f", - "createdAt": 1770790888406, - "avatar": "Azakaela", - "color": "#ff006f", - "image": "data:image/webp;base64,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", - "pronouns": "She/Her or She/They", - "bioHtml": "I am aza the great uwu<img src=\"/uploads/1770950130363-8834c8d2dcdd0d4dba86.gif\" alt=\"\" />", - "themeSongUrl": "/uploads/1771361684484-4f732dc6356adcf10c1c.mp3", - "links": [ - { - "label": "bandcamp", - "url": "https://azakaela.bandcamp.com/" - } - ], - "starredPostIds": [ - "01a7462b-2d45-476c-8f6c-78a073b3637d", - "2efa4271-49d1-45b9-a709-03f29ccf67b4", - "44d0f1cd-1e36-492b-a695-aabb8cc00c20" - ], - "hiddenPostIds": [ - "1ddfad2c-33c0-4cb8-bacf-e695346fe68e", - "01a7462b-2d45-476c-8f6c-78a073b3637d", - "f9f15a0d-3f6f-4723-9e88-d01e2da51942", - "7ba6841e-3b5b-4f74-b624-7b6936d5c93c", - "0393ca7c-9246-47b1-a201-94a2758dd57b", - "63ecb2ef-317c-4937-b97b-faf17be50519", - "790298db-1830-4227-9f43-0b252856deee", - "9d231710-0177-48a0-ba1a-73f1731bcde2", - "968a7e4e-1c08-4717-a632-d767f1169be0", - "f6e682de-3f9c-42e7-840f-0cabc8c77a5d" - ], - "customRoles": [], - "ignoredUsers": [], - "blockedUsers": [] - }, - { - "username": "brookemmmz", - "salt": "d587fe2919d7f79435057230002a0f1f", - "hash": "d570249e502da25445e929237e8651d414cc669b10b0c88a94bd7fee8fa7871f7719a94195c36d98e635bcd06a6b24e6b9bcae042e85efb65705bd4de5b25a43", - "createdAt": 1770792057738, - "image": "data:image/jpeg;base64,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", - "color": "#ff3ea5", - "role": "moderator", - "customRoles": [], - "suspendedUntil": 0, - "banned": false, - "mutedUntil": 0 - }, - { - "username": "tyresidon", - "salt": "5402f83abb5f7989e1cda9ae0367f102", - "hash": "168e25560c2cc2a007af9eca2d5f56fd17d9d9952273f25e046687be968b6ce41517e015f139ec7dbc452b46ae3ed6f34d8bbc4aecc991c9fa40e02779313f0e", - "createdAt": 1770792647922, - "customRoles": [] - }, - { - "username": "adanior", - "salt": "e8d1460bcfdbb0078c1359ce54f318dc", - "hash": "e529771b84eb7919ab298a0aa78671637ea2a103d3bd5ee2a0798749df3f458cce8b848d107f238b4bcb68eae0a80a7405ef2387d99c2c57792b40a771aadc27", - "createdAt": 1770847742275, - "customRoles": [] - }, - { - "username": "stolas", - "salt": "733f0ee17e3fb173c098de61cefb3316", - "hash": "e8101fe1a42bb7a92f61e59b9e3e08b9bc74fab0d48609c61316e2d5323936e179fa09dee4d9ef9b9259c5ac0124e9bd9d00ffed6ba7382bcd8db49b1d92b2b4", - "role": "member", - "mutedUntil": 0, - "suspendedUntil": 0, - "banned": false, - "createdAt": 1770927301917, - "image": "data:image/jpeg;base64,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", - "color": "#b52170", - "customRoles": [] - }, - { - "username": "dokkaecat", - "salt": "89b27680d19153e2e63e761b996f216a", - "hash": "e92c2f6d6c4478bbb954c26fee33cda0a8580aa71e3d8c168ce9fc7440e8e45211427c0d4035774c4fdf2143f3eb1ff99b8e6d3780c859a9a0a634bfd994abe1", - "role": "member", - "mutedUntil": 0, - "suspendedUntil": 0, - "banned": false, - "createdAt": 1770927690766, - "image": "data:image/jpeg;base64,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", - "color": "#5b2067", - "customRoles": [] - }, - { - "username": "unianetwork", - "salt": "d304be713b24a7336f0daca133ff5113", - "hash": "fabac257c637e014602cb5abea7e0279bd3572594aba1b93566226cd159b7bac93ce7ec7c50f6d12f15b8b98e3312aac8f43cf5c8432f26c9b1bc7306d11b985", - "role": "member", - "mutedUntil": 0, - "suspendedUntil": 0, - "banned": false, - "createdAt": 1770928693690, - "image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAQDAwQDAwQEBAQFBQQFBwsHBwYGBw4KCggLEA4RERAOEA8SFBoWEhMYEw8QFh8XGBsbHR0dERYgIh8cIhocHRz/2wBDAQUFBQcGBw0HBw0cEhASHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBz/wAARCABgAGADASIAAhEBAxEB/8QAHQAAAgMAAwEBAAAAAAAAAAAABgcEBQgCAwkBAP/EAD0QAAIBAgQEBQAHBgQHAAAAAAECAwQRAAUSIQYTMUEHIlFhcQgUFTKBkaEjYrHBwvAWUpLxJDNCQ1PR4f/EABsBAAMBAQEBAQAAAAAAAAAAAAQFBgMBBwAC/8QAMBEAAgEDAgMFCAIDAAAAAAAAAQIDAAQRITEFEkETUWGB8AYiMnGRocHRseEUI/H/2gAMAwEAAhEDEQA/AMaWkrauChpofrU0kmiOOEXMjGwAFtzfbbD1y36FXiXmmTx15p8uo5ZAXFNPUWdR2BIBAOBb6L31JPHThBMwkjigepIR5FBAk0No69LtYfjj1lKrJTvCWBU7XXtih4jeyQuFUVtI5Jrxhz7hWp4RzutyPNqSSmzGhdo5VkI82+xHaxHcE3xVERB7LbGgfptQ5dTeLkEdDKHqly+P63bs+prA++m36YzjThhIGt+eGVvL2kSsRvWwlwmgqVOywrqttjSWRfQ34hr8tgrM44hy3LHkQPyFjadkHXcggfkTjN2ZNGAkUJ5gsrFytjcqLra/Y3GN5eEcHGVL4UUlJxrCIqqnlRcvilOqWeGw5aSqOm52ubgAFhsbzvtLf3FnEr27hST1xnyz960tB2pw4pRRfRSVValfxApDRtIHkeLL5JIwQCBc6woIuerd8Bnib9HLOOCFhqsnmHEGXTC4emi0zLtf/lhmLLYXut/e2HtnXiJxLT5zm0EeYwUtHSVDwooiQgKpIB1HuQL/AKYuvBfjWDiHMK6eozGPMK4sYnlBW6AG6gBQBZlv26xn1xMRcd4nE3ayOGA6YGD9AP5po/D0C/OsAmVEa4NmU/BBxKmqXzaukqayZ3knk1yynzMbnc++Hn9KPwmp+FeIv8VZJHfIs1m01McY8tLUlQxX2Dg6h73HoMIvnU8bSckNyrnRq627X98Xthdx3sKzpsft4VPXBeMlcVVVMSxzMEJZATYkWuMSaenSZACrXvvb0x9mqA8pYBVubgDtiXTzRRAbg7DcY3CDNCs7AaCpUYlpvq9XA7RzK+uN0JDKVsQw/HofbDiyz6W/ixldE1JHn8chCaRPPTI8gt+9bc+5woQEjuAWKx+Uhjcr/Lrjg7RXPQ/GCZIY5QOcA0wC4OMZrjnGb5jxNnFVmubVklXmNZIZJqidrs7HuTj5yxIS6RsF/wClSb6Rc7e+OMwjaeTlqUjF2VXbcDsL9ziRBPaO5B0jrbcgbb2/EY6AorR0YjQVEiqpaGup6pArSwSrKocXBKm4v7bY9DuJOIl4y8MY+KMvkkgircpnmi0SEPBMIme+ob7aGX/fHnkYjUSaUOp2YKqgElifQf31xqzinPajwt+j3kXD9W2jNquMwCEnzWkfXMLeixsY7/5nPpvIe09stw0ITV84A8P6oq3zHvQPT5bmQbMMtrVesy5KXnRLPEGLyFgyksbFiCD179zgv8PaepyTjignEsXIKhZJj5QwCl2vcA7AHrt1t7x8ikjz4VNS2dZXleXBVmmrczlEQhj0+UKLkyE7kBAxud8R6zMtME+bUNQZssaN1pZJIDGWUoy6wh8xL6/KDbYKSL3GJp1cqSy4Bp2HiyY1YkjXp5Z008sUd8RceUmZ+GfGcee00EtA6FIIlBBqGfUsAtfeUMkRBAFgp9CTnLhTw9y+lRJuJYKgVEhGmnN0jQHsxG5P4jB3xPLFXV+XRZGs01Hl5571lQulXqCgIIjPRUVgF99XW9z30tbPxdzYC1JT00dC0tRV1DBR5dIYkd7lwLAXvf4xpbTywxmGE45jk49ef/KDFvFzmWQZA2z6/ql14m8DZPlVDT1+TxPASbSRDUyW9bm9rdfgH0wrOQ4laLSWZb3CG/T4xqXPOFMlz3IKeHKql6qu5l5kohpNRGwJMaJY6imi9yLlbm5uMZu4lyf7CzqookZniXzRuykXU9B89tvTFLwe6cgwTEk7gmll/ApPaxgAGusXN99j1x9C476enadwiAmRiAoHUm+LrMcrh4c4kq6DMYGljgYRzLqIYeUHUD2O9/TFE8/KcYyd635eXTFUUZMUiOApKkGzC4PyMco4Jah1jiV3diFVVF7k9Bg8zTw3f7MXNckrBXULfeiawmi+QNmHuPywFvFNRzujBo5UJVh0I7HHVfnQOuxohYSUEmNDtRRwNxlD4f14rxkFBmWbQ7wz1bsVhJHUICAWF+p6YrOLuMM147zt80zyq1y6OXGkaWjhQA6UReirf+JO+K+moKiepp40SzzeZC42I339xscES8D6k1PWMH7hVAGBOwhSUzBct37/AM7eVcMRxoKmcFcU5dRUSxZtGnMolYU1Q1Gk7CNzdlUuDoNxswsR2Iwcy8YIzrWPFvEStNA55cUXbUzEbtbsBbt74W0GVpUz09IXOmAsrX3YoLED8yR8EYMENPTUpqnEYkku5kIF9I2G/pYYQzcMSe4Izgbnu17vnTiwjWG1N3cDIJCgA4zgaknXQaDp86lZFn8tVnktJXRST0lbMJZJEuh0gKGAHUCy2BHt84LeK6mn4aqoTlFdRVMBa8NQkwjmiQjzCRRZlNid9r+oOE6ueVseaNVxxMKJ42hNgdSqSDrHvsNsG8jTZ/w/ZXaoiJsZacBmt0sV63+O47YWX9qttMAo93ofH10oQzi4Lsmm+nTHhnp4609+FIo8tpqGto8lzFa+SNoAk9/q1GQTqJYKzaTb7wVybj1JxmDxuijizilR2V65JqoTupJL3cSAm6qb/tD1UfA6YbsvGHFPDnDZp8tzOolkiCCmVY4WEagjUhDISfLcg39BbCozR63xJz6Q53Vzz5nPByYCIlQrKtyoKBR1FxbY3t2xtaXi9osja4P4xihrtXKlW309faqOlyumyvivJYEnNZH9YgeQwjUGUsCdNt+l9iAcN08D5Zxznec8QV0qNFBOrTRfWFRnVyAqqvV7EkbdsdnF3h3QeGp+1aOCeF7yQPBUNqYxFbMelgdxYg/zxAoeJMlzimdPtBafSW/4QMIXHnci12CqqqF3sdzhgOLLzCfkLpjGfHyr7hdxaTjtG97TGun5pvR09DwjTZhR5NDSP9aompJaZqbmsgboVPRG9zt09MZO4s+rjiHOUhCaFqAsZF7bDzW9ixJwVZz4xZrw9lslPS1E9bA85JYOCqK25vJpu9zex9QeuwwvKZhWkFJRIzjmcxm3a/r79cMrS5eUhDoANBX4hvi5EMrYVdqsMid5M3og7Mwjuqgm9hYmw/XBxUVIRgi36XOA7h2MfbMYNjoVmJBuOlv54IMwlMdzHvLIQiD3P93wazBcselbM4ccsOpJwMVHD66moljsGb9gjDqT3P4f04mug5SqTpjTYL7DoMQlAhk0rutONA92I3P9+px8zCqaOkaWxJjQm3vbAcB91pWGra+VEcXdYitmpyIhj5sdWP108q/V2a8tuRDGZX/yIP4ntjjk1RX0Wd0dRGywM0qq8Ue4db737Hb2xOSlipMuhCANI9mkfuzY6qEhs6oha68y3xsbfrbHL1c2z82uRtS5QwkUse6mnUQDNc4o6NowUqpQLDaxCMxI9CAD+WKamoayh4gWtp4EnqqRuW8IbltKBJy5AG9C+iw/eB7WMvw6rZMwzJqp3MjwM7XJvpLkgAfCg/6sMLLOG/rM2eVVbSkQ1dXHRwrIttaGRTIbehIG/wC7hRa8KP8AjoXHvcx+mP2BTSYLJHzL19fuqbx5gzfMJZknpndAEmg0VAVT1D39rWG4/jjL8qF21vGSxPU9L49AOJqfIsyq1hrIYK6Gq0xpqF73Nhe/Xffv0wG8ReBmV0tPB9m06Lpk52k9zuD0H7x/TCvhHtDFbRCF1/VeX2XEVhXkYVjNiKqHlSwlqdRaRFFtSn3/AF/DFJTQtk2Ytls7/sZTrppj91r9PwPT2Ixsmp8G6KXLK92pQ1ax1RF2uFJH+XuO++M9+JHh5UZWrUlQ0fkdmppkWyqb+ZLdh6f/ADDtOJwXcgMRw4+/hTu1vIrj/Wu42/Iqj4bQRZlK4JGmJlIPa7L/AOji9jfn1M1T/wBqmU2Pq1t/yH8cS8t4SmqsmFXk9SczrFj5Uw5YQzFCw1R2JLAG63axNri/QRZQtNQwwgPylkHNlKEDUD0/FtsbterdryRbk6+FXPAYUty13cEARAsBn4mPw478b6baV3PAaemjA++fMxPdjuf1JxDmbnRyo2wKnEmSvhmdQJFk9lN7fgMdNWssULvHS1Du33Q0TKCfkjphkVHQ6CkczmRi1S6KT63kdO3Vl8jfI2/ljoooFqcwjgZmVZAy6kNmW6ncHsRiDlktXllLLFUQSFJH1g3G1+u1/XfFlk7F85pfUuf4HHY8F0U66ivkPMyZ8Kb/AIa0CGeuaNFVI5liAAtayBv68NTNKkx/YsA35tWNQ9kjd7/mq/ngH8OKPl0E0xPmnqJGI9NNk/gmLzOHqa3OBBRy8qeGhmVZP/G8joquPcBJCMHzgdB1/NUQXES6es0M8JZ5MJ1iMzQzGRWDqurvuOuHZTcTwPKITUI0yoNa7/7YSNDl0QoZq2OsgSRFGgMQCWubgXI6Bb9/vAd8TZKCFJKiabiKlJSLmRrEWd3ewspA2A363NrY8da35hXjjQlhinjXzUmaUqrSzRGT7vkexH5YzX4j5I0eX5klWwKoeYshBuHBIUg/31tgs4FqkSvf6/VGOIKdLajufjAP4tcX0+YVhyfLNLxRy6pHUbSy22Hwv3j2Bt3GCOFWrGZVVttSe4CjOD2Ek1ykEOSSRS0oc+zXh/I8wpMrijkMNniJG4kAfoOjbvqse+INN4gRcUU9RV5gkMVa0QinjjUjWwv5vYm4H4YuhTR0tMkK9BcsT1J7n88LfO6OfJ82nzbLh5Ua00QGxBUEn9cVduUW5Myr317B7Q8EfhNpDKr5OzDcZOTkfLbxFHVLm/IiVA6Xt3OOUmamXYuCPnFFQ11LmcCVEIVo37EC6nuDgm4TVJK6pouRHIJ01gFAbFetvkN+mGl3ctBAZl94Coq5uniiLjUCquabmte/5HFlwlTis4jpY13MaPKfj7v9WDufLKCjyhQ9HR611M4enW4HyRv07YjZBkaVDyzLBHSVNeBSxiJAvJWRgurbv0Y/GBuDX0l7OWEeFXUnP9bmsuF3T3c2Qmg1Jz63pvcEUXKymja2zRc3/V5v544NMH4qq9H3UhijPz5m/qwU0NOEpp3RAkYFlAHQDALkJbMc8zGdSSr1bqPhAEP6ocUe5ANWyD4Qen6r/9k=", - "color": "#ff3ea5", - "customRoles": [] - }, - { - "username": "zipbomb", - "salt": "2e9d8fbbb96cb12dfd5347cc16474ef4", - "hash": "ae1af5f9104789d182ef96148eb3149a99bbe60fdeda4ef908d7331b2e195de2a1c486305fabddd2067c309270d8447da266bbf9060b44e7ebc4e103d5a24adb", - "role": "member", - "mutedUntil": 0, - "suspendedUntil": 0, - "banned": false, - "createdAt": 1770928702598, - "image": "data:image/webp;base64,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", - "color": "#3584e4", - "pronouns": "", - "bioHtml": "I exist", - "themeSongUrl": "", - "links": [], - "customRoles": [] - }, - { - "username": "gchahn", - "salt": "d83dd7499a2f8c35454552ecf3f18bae", - "hash": "3aacd58a792dfd834d4ae91de251e76b8ff62197e17fcad0d622f344ba9bab629b0ab6f660b22ba6377a09cde01b15d55ecd2bb09bf908e80483469eeb805df3", - "role": "member", - "customRoles": [], - "mutedUntil": 0, - "suspendedUntil": 0, - "banned": false, - "pronouns": "", - "bioHtml": "", - "themeSongUrl": "", - "links": [], - "starredPostIds": [], - "hiddenPostIds": [], - "createdAt": 1771006676898, - "image": "", - "color": "#e78d36" - }, - { - "username": "metalizanagi", - "salt": "e39888349725779e4ea92511058bb782", - "hash": "0bb69f05eb4c02e8bc1ae410cb96c70df8caf2161db40db8167fc12a6b34e778b2b3100d9ae9f9e3a1d6f144007d7e4549da75bb8791e30f24928949b123a11e", - "role": "member", - "customRoles": [], - "mutedUntil": 0, - "suspendedUntil": 0, - "banned": false, - "pronouns": "", - "bioHtml": "", - "themeSongUrl": "", - "links": [], - "starredPostIds": [], - "hiddenPostIds": [], - "createdAt": 1771131648274, - "image": "data:image/webp;base64,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", - "color": "#ff3ea5" - }, - { - "username": "edward", - "salt": "39d342584cd550cb8c52970deb55f099", - "hash": "6a72de59db8e84b82066c9280ea05932e8d16ec3f62353a6a8a0302accc833feb0c04345359c3ac5acb3d9dfcdc4838b007b8d1a78d12d208fecb201bdfb3b1b", - "role": "member", - "customRoles": [], - "mutedUntil": 0, - "suspendedUntil": 0, - "banned": false, - "pronouns": "", - "bioHtml": "", - "themeSongUrl": "", - "links": [], - "starredPostIds": [], - "hiddenPostIds": [], - "createdAt": 1771185994683, - "image": "data:image/webp;base64,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", - "color": "#ff3ea5" - } - ] -}