commit 2181055f737bb6e4d56f8efec897607c64f91950
parent 3d59ddc6648cc9d98f7f216c02c150e18b018a74
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date: Sun, 22 Feb 2026 11:52:00 -0700
map plugin update -- slight UX changes to panel hotbar
Diffstat:
8 files changed, 3507 insertions(+), 172 deletions(-)
diff --git a/docs/MAPS_PLUGIN_CURRENT_BEHAVIOR.md b/docs/MAPS_PLUGIN_CURRENT_BEHAVIOR.md
@@ -0,0 +1,131 @@
+# Maps Plugin: Current Behavior (As Implemented)
+
+Last updated: 2026-02-22
+Source of truth: `plugins_dev/maps/plugin.json`, `plugins_dev/maps/server.js`, `plugins_dev/maps/client.js`
+
+## What it is
+
+`maps` is a first-party plugin that adds:
+- A **Maps panel** (rack mode) or Maps tab flow (legacy mode)
+- Multi-user map rooms with avatar movement
+- Local/global map chat
+- Optional walkie (push-to-talk short audio clips)
+- Optional TTRPG tooling (sprites, props/tokens, possession, polygon editors)
+
+Manifest: `plugins_dev/maps/plugin.json`
+
+## Core data model
+
+Maps come from:
+1. Built-in demo map(s) in code (`BUILTIN_MAPS`)
+2. Custom maps persisted to disk at:
+ - `data/plugin-data/maps.json`
+
+Per-map fields include:
+- `id`, `title`, `owner`
+- `backgroundUrl`, `thumbUrl` (restricted to `/uploads/...` or `/assets/...`)
+- `world`, `avatarSize`, `cameraZoom`
+- `collisions`, `masks`, `exits`, `hiddenMasks`, `occluders`, `fallThroughs`
+- `ttrpgEnabled`, `sprites`, `props`, `walkiesEnabled`
+
+Runtime-only room state is in memory (not persisted):
+- Active users/positions
+- Global map chat buffer
+- Pending walkie playback acknowledgements
+
+## Permissions (current)
+
+Current checks in plugin server code:
+- **Create map**: `owner` or `moderator` only
+- **Update/delete map**: `owner`, `moderator`, or map `owner`
+- **TTRPG edits** (sprites/props/toggles): same map-management path
+- Built-in maps are not editable/deletable via `updateMap`/`deleteMap`
+
+Note: this plugin currently checks `owner/moderator` directly and does not yet include the new `admin` role in those permission gates.
+
+## Networking / WS events
+
+The plugin uses WS events under `plugin:maps:*`.
+
+Main inbound actions:
+- `list`, `createMap`, `updateMap`, `deleteMap`
+- `join`, `leave`, `move`
+- `chatSend`, `chatHistoryReq`, `say`
+- `setInvisible`
+- `walkieSend`, `walkiePlayed`
+- TTRPG: `ttrpgSetEnabled`, `ttrpgSpriteAdd`, `ttrpgSpriteRemove`, `ttrpgPropAdd`, `ttrpgPropMove`, `ttrpgPropPatch`, `ttrpgPropRemove`, `ttrpgTokenPossess`
+
+Main outbound messages:
+- `plugin:maps:mapsList`
+- `plugin:maps:joinOk`, `plugin:maps:left`
+- `plugin:maps:roomState`, `plugin:maps:userMoved`
+- `plugin:maps:chatMessage`, `plugin:maps:chatHistory`
+- `plugin:maps:bubble`
+- `plugin:maps:walkie`
+- `plugin:maps:mapPatched`, `plugin:maps:ttrpgEnabled`
+- TTRPG delta messages for sprite/prop add/move/patch/remove
+
+## Chat behavior
+
+Map chat has two scopes:
+- `local` (default): delivered only to users within a normalized radius
+- `global`: delivered to everyone in the room
+
+Current server constants:
+- `MAP_CHAT_LOCAL_RADIUS` default: `0.12` (env override `MAP_CHAT_LOCAL_RADIUS`, clamped `0.01..1.0`)
+- `MAP_CHAT_GLOBAL_MAX` default history window: `200` messages
+
+Global chat history is in-memory only (clears on server/plugin restart).
+
+## Movement, collisions, exits
+
+- Movement is client-driven, with periodic position sends (`move`)
+- Coordinates are normalized (`0..1`)
+- Collision polygons block movement
+- Exit polygons can:
+ - return user to Maps list (`toMaps`)
+ - transfer user to another map (`toMap`, optional target exit name)
+
+## TTRPG mode (current)
+
+When enabled per map:
+- Sprite library (uploaded images as `prop` or `token`)
+- Placeable props/tokens with transform and z-order
+- Token metadata: nickname, HP current/max, controller
+- Token possession flow (`controlledBy`)
+- Overlay/polygon editing for:
+ - collisions
+ - y-sort masks
+ - exits
+ - hidden masks (fog areas)
+ - fall-through zones
+ - occluders
+
+## Walkie mode (current)
+
+- Push-to-talk UX (`Backquote` / hold-to-talk)
+- Client records short audio with `MediaRecorder`, uploads to `/uploads/...`
+- Server relays playback event with sender position for spatial mix
+- Cleanup model:
+ - each walkie clip tracks pending listeners
+ - deleted when all listeners ack (`walkiePlayed`) or timeout (~2 min)
+ - server attempts to delete fresh upload file after cleanup
+
+## UX integration notes
+
+- In rack layout, plugin registers panel id `maps`
+- In legacy mode, maps view toggles within the main workspace flow
+- On mobile, Maps is usually treated as secondary/optional due to viewport constraints
+
+## Current limitations / known behavior
+
+- No built-in turn/initiative/combat engine; tools are positioning + token control only
+- Chat in map context is text+bubble (plus optional walkie clips), not full RTC voice rooms
+- Global map chat and room presence are ephemeral in memory
+- Permission model is still owner/mod oriented (not yet fully aligned with owner/admin/mod hierarchy)
+
+## Build & packaging
+
+- Dev source: `plugins_dev/maps/`
+- Build script: `scripts/build-maps-plugin.js`
+- Zip output: `dist/plugins/maps.zip`
diff --git a/docs/MAPS_V2_IMPLEMENTATION_SPEC.md b/docs/MAPS_V2_IMPLEMENTATION_SPEC.md
@@ -0,0 +1,600 @@
+# Maps V2 Implementation Spec
+
+Last updated: 2026-02-22
+Status: Draft (implementation-ready)
+
+## 1) Goal
+
+Upgrade Maps from a good prototype into a first-class social-world system that supports:
+- immersive fullscreen play
+- in-world GM authoring overlays
+- stronger avatar systems (token/image/frame-animation + advanced spritesheet import)
+- reliable spatial audio (walkie + stream audio)
+- robust TTRPG authoring and runtime operations
+
+This spec is intentionally scoped to be shippable in phases without breaking current maps.
+
+## 2) Product outcomes
+
+### Primary outcomes
+- Players can enter a map and feel “present” immediately.
+- GMs can author while playing (without modal-heavy context switching).
+- Chat/map “open chat” flows are reliable and predictable.
+- Audio features are reliable before adding complexity.
+
+### Success metrics
+- <1% map join failure rate (client-side measured)
+- <1% walkie send failure rate after retry
+- 95th percentile map interaction latency <150ms on same region
+- 0 known panel-layout breakages in rack mode + mobile for Maps panel
+
+## 3) Scope / non-scope
+
+### In scope
+- Fullscreen focus mode
+- GM in-map tool overlays
+- avatar mode system
+- walkie reliability and directional audio
+- opt-in stream audio with spatial routing
+- stronger TTRPG editing workflow
+
+### Not in scope (V2 baseline)
+- full MMO combat/quest economy engine
+- persistent server-authoritative physics
+- cross-instance shared map worlds
+
+## 4) Design principles
+
+- **Play-first UI**: map gets screen priority, tools stay accessible.
+- **Author in context**: no forced exit from gameplay to edit geometry/props.
+- **Backward compatibility**: old maps load and work.
+- **Capability flags**: client adapts by features supported by server/plugin version.
+- **Fail-soft networking**: local prediction + clear recovery paths.
+
+## 4.1 Design locks (locked decisions)
+
+1. **Focus mode defaults to panel-focus, not browser fullscreen**
+ - Primary behavior is map-focused panel mode.
+ - Browser fullscreen (`requestFullscreen`) is optional and explicit.
+
+2. **GM overlay is mode-driven**
+ - One active mode at a time (initial set: `play`, `select`, `place`, `polygon`).
+ - Hotbar binds to current mode actions, not free-floating UI state.
+ - Default mode is always `play`.
+ - Entering focus mode sets mode to `play`.
+ - Exiting any tool returns mode to `play`.
+
+3. **Capabilities handshake is mandatory on map join**
+ - Server emits `plugin:maps:capabilities` immediately after `joinOk`.
+ - Client does not guess feature support.
+
+4. **Avatar state is user-owned**
+ - Avatar configuration is stored in user prefs/plugin prefs, not map documents.
+ - Maps may apply runtime display overrides only (e.g., possession/speak-as-token).
+ - Default avatar animation pipeline is frame-based “Quick Mode”; spritesheets are advanced/optional.
+
+5. **Walkie V2 ships before stream audio**
+ - Walkie reliability is a hard prerequisite for stream audio mode.
+
+6. **Undo/redo envelope defined early**
+ - `toolCommand` command envelope exists in Phase 1, even with a small command set.
+
+7. **GM tools remain in-map overlays**
+ - No required separate “GM tools panel” in V2 baseline.
+
+8. **ACL starts minimal**
+ - Begin with `editors[]` + one/two policy toggles.
+ - Defer role-group ACL complexity until usage evidence exists.
+
+9. **Input priority is explicit**
+ - Input arbitration order is defined up front: text inputs > drag/edit > movement.
+ - Prevents WASD/overlay/tool conflicts.
+ - Movement is never blocked silently; blocked-state reason must be visible in UI.
+
+## 5) UX architecture
+
+## 5.1 Map Focus mode
+
+Add `Map Focus` state in Maps panel:
+- hides rack clutter and non-essential panel controls
+- uses full panel canvas area
+- toggle from corner button or `F`
+- `Esc` exits focus mode
+- preserve previous layout state after exiting
+
+### Desktop behavior
+- “Focus” expands map panel to workspace emphasis
+- optional true browser fullscreen (`requestFullscreen`) behind user action
+
+### Mobile behavior
+- map screen takes full app viewport
+- overlays collapse to compact hotbar + slide-up drawers
+- safe-area insets respected
+
+## 5.2 GM overlays
+
+Replace right-side heavy tool density with in-map overlays:
+- top-left: mode + selected tool + map name
+- bottom: hotbar (`1-9`) and quick actions
+- right drawer: inspector (contextual, collapsible)
+- command palette (`/` or `Ctrl/Cmd+K`) for advanced actions
+
+Authoring must allow movement + editing without mode break.
+
+### Movement feedback rule
+- If movement is blocked, the user must get immediate visible feedback.
+- Required cues:
+ - drag/edit: pointer/cursor state
+ - typing: obvious focused input state
+ - tool lock: highlighted active tool/mode
+
+## 5.3 Panel strategy
+
+- Keep Maps as a standalone panel id `maps`.
+- Do not embed maps inside hives panel.
+- TTRPG/GM controls belong to map overlays/drawers, not separate mandatory panel.
+
+## 6) Avatar system v2
+
+Introduce `avatarMode` per user per map session:
+- `profile_token`
+- `image_token`
+- `frame_animation` (default Quick Mode)
+- `spritesheet`
+
+## 6.1 Profile token
+- circular token using profile image
+- optional display name label
+
+## 6.2 Image token
+- uploaded image + metadata:
+ - pivot/foot anchor
+ - collision radius
+ - facing direction support (flip/rotate)
+
+## 6.3 Frame-based avatar animation (Quick Mode, default)
+
+Users define animation states as ordered frame lists (individual uploaded images), without spritesheet slicing.
+
+Baseline states:
+- `idle`
+- `walk_vertical`
+- `walk_horizontal`
+
+Optional states:
+- directional idles/walks (`idle_up/down/left/right`, `walk_up/down/left/right`)
+- emotes (`wave`, `dance`, etc.)
+
+Quick Mode behavior:
+- upload frame images one-by-one
+- drag/reorder frames within each state
+- instant preview in editor
+- per-state loop toggle
+- movement-driven state switching (`idle` vs walk states)
+- directional horizontal flip by default (for left/right when dedicated states are not supplied)
+
+Emote behavior:
+- hotkey-triggered animation override
+- emote plays then auto-returns to prior locomotion state
+- emote can be non-looping (default) or looping with cancel
+
+## 6.4 Spritesheet mode (advanced import)
+- upload sheet + metadata:
+ - frame width/height
+ - rows/columns
+ - animation sets by direction (`idle_up/down/left/right`, `walk_*`)
+ - frame rate
+
+## 6.5 Server avatar presets (staff-authored library)
+
+Add a per-instance avatar preset library that can be authored by `owner`/`admin`/`moderator`, then selected by members.
+
+Preset goals:
+- remove setup friction for regular users
+- let server team define coherent visual style packs
+- keep user choice simple (`pick preset`, optional display name override)
+
+Preset behavior:
+- staff can create, update, delete, and publish presets
+- users can browse and apply any published preset
+- applying a preset copies its avatar config into the user avatar state (not a hard live link)
+- optional server setting can enforce `preset-only` mode for members
+
+Preset scope:
+- supports `profile_token`, `image_token`, `frame_animation`, and `spritesheet`
+- supports starter emotes/hotkeys bundled by staff
+- supports tag/category metadata for browsing (e.g., fantasy, sci-fi, cozy)
+
+### Data model (client/server)
+```ts
+type AvatarMode = "profile_token" | "image_token" | "frame_animation" | "spritesheet";
+
+type AvatarState = {
+ mode: AvatarMode;
+ displayName?: string;
+ showUsername?: boolean;
+ frameAnimation?: {
+ defaultFps: number;
+ states: Record<
+ string,
+ {
+ frames: Array<{ url: string }>;
+ fps?: number;
+ loop?: boolean;
+ flipXWithDirection?: boolean;
+ }
+ >;
+ movementMap?: {
+ idle?: string;
+ walkVertical?: string;
+ walkHorizontal?: string;
+ walkUp?: string;
+ walkDown?: string;
+ walkLeft?: string;
+ walkRight?: string;
+ };
+ emotes?: Array<{
+ name: string;
+ state: string;
+ hotkey?: string; // e.g. "Digit1", "KeyZ"
+ loop?: boolean;
+ interruptible?: boolean;
+ }>;
+ };
+ imageToken?: {
+ url: string;
+ collisionRadius: number; // normalized / map scale aware
+ pivotX: number;
+ pivotY: number;
+ facing: "left" | "right" | "up" | "down";
+ flipWithDirection: boolean;
+ };
+ spritesheet?: {
+ url: string;
+ frameW: number;
+ frameH: number;
+ rows: number;
+ cols: number;
+ fps: number;
+ animations: Record<string, number[]>; // key -> frame indices
+ };
+};
+
+type AvatarPreset = {
+ id: string;
+ name: string;
+ description?: string;
+ tags?: string[];
+ mode: AvatarMode;
+ avatar: Omit<AvatarState, "displayName" | "showUsername">;
+ createdBy: string;
+ updatedBy: string;
+ createdAt: number;
+ updatedAt: number;
+ published: boolean;
+};
+```
+
+## 7) Audio systems
+
+## 7.1 Walkie reliability pass (before stream mode)
+
+Current walkie exists but requires hardening:
+- explicit state machine: `idle -> recording -> encoding -> uploading -> sent -> played/timeout`
+- retry policy for upload failure
+- client-visible status + error toasts
+- server ack tracking improvements + cleanup guarantees
+- telemetry events for each failure stage
+
+## 7.2 Directional spatial audio abstraction
+
+Create shared spatial engine for all short/long audio:
+- distance attenuation
+- stereo pan
+- configurable falloff curve
+- future occlusion hook (not required in first pass)
+
+## 7.3 Stream audio mode (opt-in)
+
+Add low-latency stream channels per user/source:
+- listeners opt-in per source
+- source appears in map with directional spatialization
+- per-source mute + quick volume controls
+
+Implementation target:
+- RTC-based media for live stream audio
+- keep walkie upload model as fallback mode
+
+## 8) TTRPG tool overhaul
+
+## 8.1 Editing workflow
+
+- drag/drop sprite placement
+- transform gizmos (move/rotate/scale)
+- layer controls (z-order, lock, hide)
+- snap options (grid + vertex snapping)
+- multi-select for batch actions
+- undo/redo command stack
+
+## 8.2 Geometry authoring improvements
+
+- polygon tools unified in one editor rail:
+ - collisions
+ - masks
+ - exits
+ - hidden masks/fog
+ - fall-through
+ - occluders
+- inline inspector for selected polygon metadata
+- validity checks before save
+
+## 8.3 Runtime token controls
+
+- possession states
+- token cards (hp/name/owner)
+- token context menu
+- optional “speak as token” clearly surfaced
+
+## 9) Role and permissions model
+
+Align maps plugin with core role hierarchy:
+- `owner`, `admin`, `moderator`, `member`
+
+Recommended matrix:
+- create/delete/update map: owner/admin/moderator (+ map owner)
+- ttrpg structural edits: owner/admin/moderator (+ delegated map editors)
+- runtime token move/possess: configurable per map policy
+- avatar preset create/update/delete/publish: owner/admin/moderator
+- avatar preset apply: all authenticated users (published presets)
+
+Add map-level ACL field:
+```ts
+type MapAcl = {
+ editors: string[]; // usernames
+ canMembersPlaceProps: boolean;
+ canMembersUseTokens: boolean;
+};
+```
+
+## 10) Networking contracts (v2 additions)
+
+Keep existing events; add versioned extensions:
+- `plugin:maps:capabilities` (server -> client)
+- `plugin:maps:getCapabilities` (client -> server, on-demand refresh/debug)
+- `plugin:maps:setAvatar`
+- `plugin:maps:listAvatarPresets`
+- `plugin:maps:upsertAvatarPreset` (staff only)
+- `plugin:maps:deleteAvatarPreset` (staff only)
+- `plugin:maps:applyAvatarPreset`
+- `plugin:maps:walkieState`
+- `plugin:maps:typing` (throttled presence signal)
+- `plugin:maps:presence` (room activity snapshot)
+- `plugin:maps:streamOffer` / `streamAnswer` / `streamIce` (if RTC in plugin WS channel)
+- `plugin:maps:toolCommand` (for undoable commands)
+
+## 10.1 Handshake timing contract
+
+- On successful join:
+ 1. `plugin:maps:joinOk`
+ 2. `plugin:maps:capabilities`
+- Client may request re-send with `plugin:maps:getCapabilities` after reconnects or plugin reload.
+
+Server capability payload example:
+```json
+{
+ "type": "plugin:maps:capabilities",
+ "version": "2.0.0",
+ "features": {
+ "focusMode": true,
+ "gmOverlay": true,
+ "avatarModes": ["profile_token", "image_token", "frame_animation", "spritesheet"],
+ "walkieV2": true,
+ "spatialStreamAudio": true,
+ "undoRedo": true
+ }
+}
+```
+
+### 10.2 Safe fallback contract
+
+For capability mismatches, clients must degrade gracefully:
+- unsupported feature UI is hidden (not shown as broken/disabled unless needed for explanation)
+- unsupported avatar modes fallback to `profile_token`
+- unsupported advanced tools fallback to `play` mode + baseline map interaction
+- no capability mismatch should block join/movement/chat basics
+
+## 11) Persistence and migration
+
+Current map data lives at `data/plugin-data/maps.json`.
+
+Migration strategy:
+1. Add `schemaVersion` to map objects.
+2. On load, migrate in memory to latest schema.
+3. Persist migrated schema safely (write temp + atomic rename pattern).
+4. Keep compatibility reader for at least 1 major cycle.
+
+Key new persisted fields:
+- map ACL
+- optional map settings for focus defaults and tool preferences
+- avatar metadata references (per user state can remain session/prefs scoped)
+
+## 12) Performance constraints
+
+- target 60fps render loop on mid-range desktop
+- stable 30fps minimum on typical mobile
+- throttle non-critical broadcasts
+- avoid full-map payload rebroadcast for small edits (delta-based)
+- lazy load spritesheet textures; cap memory per map
+- texture guardrails:
+ - max texture dimension: default 4096 (optional stricter deploy default 2048)
+ - max decoded spritesheet memory budget per map (configurable hard cap)
+ - reject/downscale oversized assets with clear user-visible error
+
+## 12.1 Presence signal
+
+Maps should expose an explicit activity signal to support discoverability:
+- room user count
+- active/live boolean (e.g., recent movement/chat/audio window)
+- optional freshness timestamp in map list payloads
+
+## 13) Security constraints
+
+- enforce upload URL/path validation (already present, keep strict)
+- sanitize all user-provided labels/display names
+- rate limit audio/message actions
+- avoid trusting client movement for restricted zones when game rules matter
+- map ACL checks must be server-side authoritative
+
+## 14) Rollout plan
+
+## Phase 1: UX shell + reliability
+- Must ship:
+ - role update to include `admin` parity in maps permission gates
+ - Focus mode (panel-focus first, Esc exit)
+ - walkie reliability state machine + retry + deterministic cleanup
+ - capabilities handshake (`joinOk` + `capabilities`)
+- Scaffold (acceptable as minimal stubs):
+ - GM overlay shell + hotbar
+ - command palette stub
+ - profile-token avatar UI stub
+
+Exit criteria:
+- no panel/layout regressions
+- walkie failure rate materially reduced
+
+## Phase 2: Avatar + TTRPG ergonomics
+- profile/image token modes
+- frame-based avatar animation Quick Mode (default)
+- emote animation overrides + hotkeys
+- server avatar preset library + user preset picker
+- drag/drop props, gizmos, inspector improvements
+- undo/redo command stack
+
+Exit criteria:
+- GM can build a playable scene without leaving map panel
+- users can animate avatars in Quick Mode without spritesheet tooling
+
+## Phase 3: Spritesheet + stream audio
+- advanced spritesheet import mode
+- opt-in spatial stream audio
+- per-source controls + metrics
+
+Exit criteria:
+- stable multi-user session with directional stream audio in production
+
+## 15) QA checklist
+
+- rack mode + legacy mode + mobile behavior
+- map focus enter/exit with preserved layout
+- map editing permissions across roles
+- chat open reliability from hives/maps/streams
+- walkie send/play cleanup and timeout behavior
+- migration on old `maps.json` files
+- frame-state upload/reorder/preview flow (Quick Mode)
+- emote override start/finish/return-to-idle behavior
+
+## 16) Open decisions
+
+- Stream audio stack: **reuse shared stream-pack/LiveKit infra**; maps plugin stays UI/routing layer.
+- ACL grouping: **per-map ACL first**, role groups deferred.
+- Spritesheet asset location: **per-user profile library first** (portable identity).
+- Token movement authority: keep client-driven baseline; optional server-authoritative mode may be introduced later under a map flag.
+
+## 17) Immediate implementation backlog (ready to build)
+
+1. Add maps role checks for `admin` parity.
+2. Add `Map Focus` mode state + UI toggle + `Esc` exit.
+3. Move GM controls into overlay hotbar/drawer shell.
+4. Instrument walkie state transitions and failures.
+5. Introduce capability handshake event.
+6. Add avatar mode schema + `profile_token` first.
+7. Add `frame_animation` Quick Mode editor + runtime playback.
+8. Add emote override/hotkey handling with auto-return behavior.
+9. Add advanced spritesheet import path (after Quick Mode ships).
+10. Add avatar preset CRUD (staff) + preset picker (users).
+
+## 17.1 Phase 1 build checklist (file-by-file)
+
+### A) Admin parity (server)
+- File: `plugins_dev/maps/server.js`
+- Update role checks for:
+ - `createMap`
+ - `updateMap`
+ - `deleteMap`
+ - `ttrpgSetEnabled`
+ - all `ttrpg*` edit actions
+ - other destructive map actions
+- Target role policy: owner/admin/moderator (+ map owner where already supported).
+
+### B) Focus mode (client)
+- File: `plugins_dev/maps/client.js`
+- Add state:
+ - `isFocusMode: boolean`
+- Add controls:
+ - corner button
+ - keyboard: `F` toggle, `Esc` exit
+- Persist optional preference per-user (local preference key).
+- Ensure safe-area and rack-mode resize behavior.
+
+### C) GM overlay shell (client)
+- File: `plugins_dev/maps/client.js`
+- Add overlay frame:
+ - top-left mode pill
+ - bottom hotbar (`1-9`)
+ - right inspector drawer
+ - command palette stub (`/`, `Ctrl/Cmd+K`)
+- Keep movement available while overlay is present.
+- Enforce mode defaulting:
+ - maps load in `play`
+ - focus enter sets `play`
+ - exiting tools returns `play`
+
+### D) Walkie V2 reliability (client + server)
+- Files:
+ - `plugins_dev/maps/client.js`
+ - `plugins_dev/maps/server.js`
+- Client:
+ - explicit state machine
+ - one retry with backoff minimum
+ - status UI (recording/uploading/sent/failed)
+- Server:
+ - deterministic cleanup on ack/timeout/disconnect
+ - telemetry counters/logs for failure stages
+
+### E) Capabilities handshake
+- Files:
+ - `plugins_dev/maps/server.js`
+ - `plugins_dev/maps/client.js`
+- Add:
+ - server emit `plugin:maps:capabilities` after `joinOk`
+ - server support `plugin:maps:getCapabilities` refresh
+ - server handler `plugin:maps:getCapabilities`
+ - client gating for feature-dependent UI elements
+ - safe fallback rendering for unsupported capabilities
+
+### F) Profile-token avatar (minimal)
+- Files:
+ - `plugins_dev/maps/client.js`
+ - `plugins_dev/maps/server.js`
+ - core prefs surface if needed (`server.js` / `public/app.js`) for plugin-pref persistence
+- Add:
+ - `setAvatar` event shape
+ - profile-token render path
+ - optional `displayName` + `showUsername` toggle
+
+### G) Typing + presence baseline (low-cost, high-impact)
+- Files:
+ - `plugins_dev/maps/client.js`
+ - `plugins_dev/maps/server.js`
+- Add:
+ - throttled `plugin:maps:typing`
+ - lightweight `plugin:maps:presence` publish/update in map list + room state
+
+## 17.2 Phase 1 edge cases (must test)
+
+- Rack mode resize while in focus mode
+- Switching away from maps panel and back while focused
+- Mobile safe-area + virtual keyboard interactions
+- Drag/edit interactions while movement keys are pressed
+- Reconnect flow (`joinOk` + `capabilities`) consistency
+- Walkie clip cleanup if sender/receiver disconnects mid-play
diff --git a/plugins_dev/maps/client.js b/plugins_dev/maps/client.js
@@ -110,13 +110,71 @@
.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 {
+ position:absolute;
+ left: 12px;
+ bottom: 12px;
+ width: min(560px, calc(100% - 24px));
+ display:flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid rgba(246,240,255,0.16);
+ border-radius: 12px;
+ background: rgba(10,10,18,var(--maps-chat-overlay-alpha,0.92));
+ backdrop-filter: blur(6px);
+ z-index: 9;
+ }
+ .mapChatOverlay.raiseForWalkie { bottom: 68px; }
+ .mapChatOverlay.raiseForHotbar { bottom: 126px; }
.mapChatOverlay input { flex:1; }
+ .mapChatToolbar { display:flex; align-items:center; gap: 8px; flex-wrap: wrap; }
+ .mapChatDragHandle { cursor: move; user-select: none; touch-action: none; }
+ .mapChatScopeRow { display:flex; gap: 6px; }
+ .mapChatOpacity { display:flex; align-items:center; gap: 6px; margin-left: auto; min-width: 132px; }
+ .mapChatOpacity input[type="range"] { flex: 1; }
+ .mapChatFeed { min-height: 120px; max-height: min(38vh, 320px); overflow: auto; border: 1px solid rgba(246,240,255,0.12); border-radius: 10px; padding: 8px; background: rgba(0,0,0,0.16); display:flex; flex-direction:column; gap: 6px; }
+ .mapChatFeedItem { border: 1px solid rgba(246,240,255,0.10); border-radius: 9px; padding: 6px 8px; background: rgba(255,255,255,0.03); }
+ .mapChatFeedMeta { display:flex; justify-content: space-between; gap: 8px; font-size: 11px; color: rgba(246,240,255,0.70); margin-bottom: 2px; }
+ .mapChatFeedText { font-size: 13px; line-height: 1.25rem; color: rgba(246,240,255,0.92); white-space: pre-wrap; word-break: break-word; }
.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; }
+ .mapCornerTools { position:absolute; top: 12px; right: 12px; display:flex; gap: 8px; z-index: 5; }
+ .mapGmTopLeft { position:absolute; top: 12px; left: 12px; z-index: 5; display:flex; flex-direction:column; gap: 8px; }
+ .mapModePill { display:inline-flex; align-items:center; gap: 8px; padding: 6px 10px; border-radius: 999px; border:1px solid rgba(246,240,255,0.18); background: rgba(10,10,18,0.72); font-size: 12px; }
+ .mapGmHotbar { position:absolute; left: 12px; right: 12px; bottom: 12px; z-index: 5; display:flex; justify-content:center; pointer-events:none; }
+ .mapGmHotbar.raiseForWalkie { bottom: 68px; }
+ .mapGmHotbarInner { pointer-events:auto; display:flex; gap: 8px; padding: 8px; border:1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(10,10,18,0.72); }
+ .mapGmHotbarInner .smallBtn { min-width: 84px; }
+ .mapInspectorDrawer { position:absolute; top: 56px; right: 12px; z-index: 5; width: min(320px, calc(100% - 24px)); border:1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(10,10,18,0.92); padding: 10px; }
+ .mapInspectorDrawer.hidden { display:none; }
+ .mapInspectorTitle { font-weight: 900; margin-bottom: 6px; }
+ .mapsPanel.focusMode .mapView { gap: 0; padding: 0; }
+ .mapsPanel.focusMode .mapCanvasWrap { border-radius: 0; border-left: 0; border-right: 0; }
+ .mapsPanel.focusMode .mapHud { display: none; }
+ .mapsPanel.focusMode .mapDock { display: none; }
+ .mapsPanel.cinematicMode .mapView { gap: 0; padding: 0; }
+ .mapsPanel.cinematicMode .mapCanvasWrap { border-radius: 0; border-left: 0; border-right: 0; }
+ .mapsPanel.cinematicMode .mapHud,
+ .mapsPanel.cinematicMode .mapDock,
+ .mapsPanel.cinematicMode .mapGmTopLeft,
+ .mapsPanel.cinematicMode .mapCornerTools,
+ .mapsPanel.cinematicMode .mapInspectorDrawer,
+ .mapsPanel.cinematicMode .mapGmHotbar,
+ .mapsPanel.cinematicMode .mapWalkieBar,
+ .mapsPanel.cinematicMode .mapChatOverlay { display: none !important; }
+ .mapsAvatarEditorModal { position: fixed; inset: 0; z-index: 1500; background: rgba(6,5,12,0.68); display:flex; align-items:center; justify-content:center; padding: 16px; }
+ .mapsAvatarEditorCard { width: min(920px, 96vw); max-height: 90vh; overflow:auto; border: 1px solid rgba(246,240,255,0.18); border-radius: 16px; background: linear-gradient(180deg, rgba(20,16,32,0.98), rgba(10,9,16,0.98)); padding: 14px; }
+ .mapsAvatarEditorGrid { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+ .mapsAvatarStates { border:1px solid rgba(246,240,255,0.14); border-radius: 12px; padding: 10px; background: rgba(255,255,255,0.02); }
+ .mapsAvatarFrames { border:1px solid rgba(246,240,255,0.14); border-radius: 12px; padding: 10px; background: rgba(255,255,255,0.02); min-height: 180px; }
+ .mapsAvatarFrameRow { display:flex; align-items:center; gap: 8px; border:1px solid rgba(246,240,255,0.10); border-radius: 10px; padding: 6px; margin-bottom: 6px; }
+ .mapsAvatarFrameThumb { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; border:1px solid rgba(246,240,255,0.15); }
+ .mapsAvatarFrameName { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size: 12px; color: rgba(246,240,255,0.82); }
+ .mapsAvatarSheetGrid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 8px; }
.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; }
@@ -223,6 +281,9 @@
let walkieChunks = [];
let walkieRecording = false;
let walkieStartAt = 0;
+ let walkieState = { phase: "idle", id: "", error: "", attempt: 0 };
+ const walkiePhases = new Set(["idle", "recording", "encoding", "uploading", "sent", "playing", "played", "timeout", "failed"]);
+ let walkieLastStateEmit = "";
const walkiePlaybacks = new Map(); // id -> {audio, gain, pan, filter?, interval?, ackTimer?}
const exitInside = new Map(); // idx -> boolean
let lastExitAt = 0;
@@ -242,12 +303,608 @@
let placeScale = 1.0;
let speakingAsPropId = "";
let ttrpgDockCollapsed = false;
+ let mapsCapabilities = null;
+ const typingUntil = new Map(); // username -> expiresAt(ms)
+ const emoteUntil = new Map(); // username -> {state:string, until:number, loop:boolean}
+ const avatarAnimRuntime = new Map(); // username -> {lastX,lastY,lastState,lastFrameAt,lastFacing:number,lastMoveAt:number}
+ const frameAvatarCache = new Map(); // url -> {img,status,failedAt}
+ let typingLastSentAt = 0;
+ let typingOpen = false;
+ let mapChatScope = "local";
+ let mapChatOverlayOpacity = 0.92;
+ let mapChatOverlayPos = null;
+ let mapChatOverlayDrag = null;
+ let mapChatFeed = []; // Array<{id:string,scope:"local"|"global",fromUser:string,text:string,createdAt:number}>
+ let isFocusMode = false;
+ let cinematicMode = false;
+ let gmMode = "play"; // "play" | "select" | "place" | "polygon"
+ let inspectorOpen = false;
+ let lastPaletteToastAt = 0;
+ let gmOverlayVisible = false;
+ let avatarEditorOpen = false;
+ let avatarEditorDraft = null;
+ let avatarPresets = [];
+ let avatarPresetsCanManage = false;
+ let avatarPresetSelectedId = "";
+ let capabilitiesRetryTimer = 0;
+ let capabilitiesRetries = 0;
+
+ function clearCapabilitiesRetry() {
+ if (capabilitiesRetryTimer) {
+ try {
+ clearTimeout(capabilitiesRetryTimer);
+ } catch {
+ // ignore
+ }
+ }
+ capabilitiesRetryTimer = 0;
+ capabilitiesRetries = 0;
+ }
+
+ function requestCapabilitiesWithRetry(reason = "manual") {
+ if (!activeMap?.id) return;
+ const maxRetries = 3;
+ const attemptDelay = 500;
+ const attempt = () => {
+ if (!activeMap?.id) return;
+ ctx.send("getCapabilities", { mapId: activeMap.id, reason });
+ if (mapsCapabilities?.features && typeof mapsCapabilities.features === "object") {
+ clearCapabilitiesRetry();
+ return;
+ }
+ if (capabilitiesRetries >= maxRetries) {
+ clearCapabilitiesRetry();
+ return;
+ }
+ capabilitiesRetries += 1;
+ capabilitiesRetryTimer = setTimeout(attempt, attemptDelay * capabilitiesRetries);
+ };
+ clearCapabilitiesRetry();
+ attempt();
+ }
+
+ function isMapStaffRole(role) {
+ const r = String(role || "").toLowerCase();
+ return r === "owner" || r === "admin" || r === "moderator";
+ }
+
+ function focusModePrefKey() {
+ return "bzl_maps_focusMode";
+ }
+
+ function mapChatOverlayPrefKey() {
+ return "bzl_maps_chatOverlay";
+ }
+
+ function readFocusModePref() {
+ try {
+ return localStorage.getItem(focusModePrefKey()) === "1";
+ } catch {
+ return false;
+ }
+ }
+
+ function writeFocusModePref(on) {
+ try {
+ localStorage.setItem(focusModePrefKey(), on ? "1" : "0");
+ } catch {
+ // ignore
+ }
+ }
+
+ function readMapChatOverlayPrefs() {
+ try {
+ const raw = localStorage.getItem(mapChatOverlayPrefKey());
+ if (!raw) return;
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === "object") {
+ mapChatScope = String(parsed.scope || "local") === "global" ? "global" : "local";
+ mapChatOverlayOpacity = clamp(Number(parsed.opacity || 0.92), 0.25, 1);
+ const x = Number(parsed?.pos?.x);
+ const y = Number(parsed?.pos?.y);
+ mapChatOverlayPos = Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
+ }
+ } catch {
+ // ignore
+ }
+ }
+
+ function writeMapChatOverlayPrefs() {
+ try {
+ localStorage.setItem(
+ mapChatOverlayPrefKey(),
+ JSON.stringify({
+ scope: mapChatScope,
+ opacity: mapChatOverlayOpacity,
+ pos: mapChatOverlayPos && Number.isFinite(mapChatOverlayPos.x) && Number.isFinite(mapChatOverlayPos.y) ? { x: mapChatOverlayPos.x, y: mapChatOverlayPos.y } : null
+ })
+ );
+ } catch {
+ // ignore
+ }
+ }
+
+ function pushMapChatFeedEntry(scopeRaw, messageRaw) {
+ const scope = String(scopeRaw || "").trim().toLowerCase() === "global" ? "global" : "local";
+ const message = messageRaw && typeof messageRaw === "object" ? messageRaw : null;
+ if (!message) return;
+ const id = String(message.id || `${Date.now()}_${Math.random().toString(16).slice(2)}`).slice(0, 120);
+ const fromUser = String(message.fromUser || "").trim().toLowerCase().slice(0, 40);
+ const text = String(message.text || "").replace(/\s+/g, " ").trim().slice(0, 420);
+ const createdAt = Number(message.createdAt || Date.now()) || Date.now();
+ if (!text) return;
+ mapChatFeed.push({ id, scope, fromUser, text, createdAt });
+ if (mapChatFeed.length > 260) mapChatFeed = mapChatFeed.slice(-220);
+ }
+
+ function replaceMapChatGlobalHistory(messages) {
+ const list = Array.isArray(messages) ? messages : [];
+ const keepLocal = mapChatFeed.filter((entry) => entry.scope !== "global");
+ mapChatFeed = keepLocal;
+ for (const item of list) pushMapChatFeedEntry("global", item);
+ }
+
+ function renderMapChatFeedDom() {
+ const feedEl = document.getElementById("mapsChatFeed");
+ if (!feedEl) return;
+ const rows = mapChatFeed
+ .filter((entry) => entry.scope === mapChatScope)
+ .slice(-120);
+ if (!rows.length) {
+ feedEl.innerHTML = `<div class="small muted">No ${mapChatScope} messages yet.</div>`;
+ return;
+ }
+ const html = rows
+ .map((entry) => {
+ const t = new Date(Number(entry.createdAt || 0) || Date.now());
+ const hh = String(t.getHours()).padStart(2, "0");
+ const mm = String(t.getMinutes()).padStart(2, "0");
+ const user = entry.fromUser ? `@${entry.fromUser}` : "unknown";
+ return `<div class="mapChatFeedItem"><div class="mapChatFeedMeta"><span>${escapeHtml(user)}</span><span>${escapeHtml(`${hh}:${mm}`)}</span></div><div class="mapChatFeedText">${escapeHtml(entry.text)}</div></div>`;
+ })
+ .join("");
+ feedEl.innerHTML = html;
+ feedEl.scrollTop = feedEl.scrollHeight;
+ }
+
+ function applyFocusModeClass() {
+ if (!mapsPanel) return;
+ mapsPanel.classList.toggle("focusMode", Boolean(mode === "map" && isFocusMode));
+ mapsPanel.classList.toggle("cinematicMode", Boolean(mode === "map" && cinematicMode));
+ }
+
+ function setFocusMode(on, persist = true) {
+ if (Boolean(on) && !featureEnabled("focusMode")) return;
+ isFocusMode = Boolean(on) && featureEnabled("focusMode");
+ if (persist) writeFocusModePref(isFocusMode);
+ if (isFocusMode) {
+ gmMode = "play";
+ editMode = false;
+ }
+ applyFocusModeClass();
+ if (mode === "map") renderMapView();
+ }
+
+ function setCinematicMode(on) {
+ cinematicMode = Boolean(on);
+ if (cinematicMode) typingOpen = false;
+ applyFocusModeClass();
+ if (mode === "map") renderMapView();
+ }
+
+ isFocusMode = readFocusModePref();
+ readMapChatOverlayPrefs();
+
+ function setGmMode(next) {
+ const target = String(next || "").toLowerCase();
+ const canUseTools = Boolean(activeMap?.ttrpgEnabled && canManageTtrpg);
+ if (target === "play") {
+ gmMode = "play";
+ editMode = false;
+ ttrpgTool = "select";
+ renderMapView();
+ return;
+ }
+ if (!canUseTools) return;
+ if (target === "select") {
+ gmMode = "select";
+ editMode = false;
+ ttrpgTool = "select";
+ renderMapView();
+ return;
+ }
+ if (target === "place") {
+ gmMode = "place";
+ editMode = false;
+ ttrpgTool = "place";
+ renderMapView();
+ return;
+ }
+ if (target === "polygon") {
+ gmMode = "polygon";
+ editMode = true;
+ renderMapView();
+ }
+ }
+
+ function setGmOverlayVisible(on) {
+ gmOverlayVisible = Boolean(on);
+ if (!gmOverlayVisible) inspectorOpen = false;
+ if (mode === "map") renderMapView();
+ }
+
+ function featureEnabled(name, fallback = false) {
+ const features = mapsCapabilities?.features;
+ if (!features || typeof features !== "object") return fallback;
+ if (!Object.prototype.hasOwnProperty.call(features, name)) return fallback;
+ return Boolean(features[name]);
+ }
+
+ function avatarModesSupported() {
+ const features = mapsCapabilities?.features;
+ const list = Array.isArray(features?.avatarModes) ? features.avatarModes : ["profile_token"];
+ return new Set(list.map((x) => String(x || "").trim()).filter(Boolean));
+ }
+
+ function normalizeAvatarState(raw) {
+ const supported = avatarModesSupported();
+ let mode = String(raw?.mode || "profile_token");
+ if (!supported.has(mode)) mode = "profile_token";
+ const displayName = String(raw?.displayName || "").replace(/\s+/g, " ").trim().slice(0, 32);
+ const showUsername = raw && Object.prototype.hasOwnProperty.call(raw, "showUsername") ? Boolean(raw.showUsername) : true;
+ const frameAnimation = mode === "frame_animation" ? normalizeFrameAnimation(raw?.frameAnimation) : null;
+ return { mode: frameAnimation ? "frame_animation" : "profile_token", displayName, showUsername, frameAnimation };
+ }
+
+ function displayNameForUser(username, u) {
+ const avatar = normalizeAvatarState(u?.avatar || null);
+ if (!avatar.showUsername) return "";
+ return avatar.displayName || `@${String(username || "")}`;
+ }
+
+ function defaultFrameAnimationDraft() {
+ return {
+ defaultFps: 8,
+ renderScale: 1,
+ sheetImport: { cols: 4, rows: 4, limit: 24 },
+ selectedState: "idle_down",
+ states: {
+ idle_down: { frames: [], fps: 8, loop: true, flipXWithDirection: true },
+ idle_up: { frames: [], fps: 8, loop: true, flipXWithDirection: true },
+ walk_down: { frames: [], fps: 8, loop: true, flipXWithDirection: true },
+ walk_up: { frames: [], fps: 8, loop: true, flipXWithDirection: true },
+ walk_horizontal: { frames: [], fps: 8, loop: true, flipXWithDirection: true }
+ },
+ movementMap: {
+ idleDown: "idle_down",
+ idleUp: "idle_up",
+ walkDown: "walk_down",
+ walkUp: "walk_up",
+ walkHorizontal: "walk_horizontal"
+ },
+ emotes: []
+ };
+ }
+
+ function cloneAvatarForEditor(avatarRaw) {
+ const avatar = normalizeAvatarState(avatarRaw || null);
+ const frameBase = avatar.frameAnimation
+ ? {
+ defaultFps: clamp(avatar.frameAnimation.defaultFps || 8, 1, 24),
+ renderScale: clamp(avatar.frameAnimation.renderScale || 1, 0.25, 4.0),
+ sheetImport: {
+ cols: Math.floor(clamp(avatar.frameAnimation?.sheetImport?.cols || 4, 1, 32)),
+ rows: Math.floor(clamp(avatar.frameAnimation?.sheetImport?.rows || 4, 1, 32)),
+ limit: Math.floor(clamp(avatar.frameAnimation?.sheetImport?.limit || 24, 1, 96))
+ },
+ selectedState: "idle_down",
+ states: JSON.parse(JSON.stringify(avatar.frameAnimation.states || {})),
+ movementMap: { ...(avatar.frameAnimation.movementMap || {}) },
+ emotes: Array.isArray(avatar.frameAnimation.emotes) ? JSON.parse(JSON.stringify(avatar.frameAnimation.emotes)) : []
+ }
+ : defaultFrameAnimationDraft();
+ if (!frameBase.selectedState || !frameBase.states?.[frameBase.selectedState]) {
+ const first = Object.keys(frameBase.states || {})[0] || "idle_down";
+ frameBase.selectedState = first;
+ }
+ return {
+ mode: avatar.mode === "frame_animation" ? "frame_animation" : "profile_token",
+ displayName: avatar.displayName || "",
+ showUsername: avatar.showUsername !== false,
+ frameAnimation: frameBase
+ };
+ }
+
+ function ensureAvatarEditorDraft(currentAvatar) {
+ if (!avatarEditorDraft) avatarEditorDraft = cloneAvatarForEditor(currentAvatar);
+ return avatarEditorDraft;
+ }
+
+ function normalizeAvatarPresetList(list) {
+ const src = Array.isArray(list) ? list : [];
+ const out = [];
+ for (const raw of src) {
+ const id = String(raw?.id || "").trim().toLowerCase();
+ const name = String(raw?.name || "").trim();
+ if (!id || !name) continue;
+ out.push({
+ id,
+ name: name.slice(0, 40),
+ description: String(raw?.description || "").trim().slice(0, 140),
+ tags: Array.isArray(raw?.tags) ? raw.tags.map((x) => String(x || "").trim()).filter(Boolean).slice(0, 12) : [],
+ mode: String(raw?.mode || "profile_token"),
+ published: Boolean(raw?.published),
+ avatar: raw?.avatar && typeof raw.avatar === "object" ? raw.avatar : null
+ });
+ }
+ return out;
+ }
+
+ function selectedAvatarPresetById(id) {
+ const key = String(id || "").trim().toLowerCase();
+ if (!key) return null;
+ return avatarPresets.find((preset) => preset.id === key) || null;
+ }
+
+ function collectAvatarPayloadFromDraft() {
+ const draft = avatarEditorDraft || cloneAvatarForEditor(null);
+ const payload = {
+ mode: draft.mode === "frame_animation" ? "frame_animation" : "profile_token",
+ displayName: String(draft.displayName || "").replace(/\s+/g, " ").trim().slice(0, 32),
+ showUsername: Boolean(draft.showUsername)
+ };
+ if (payload.mode === "frame_animation") {
+ const fa = draft.frameAnimation || defaultFrameAnimationDraft();
+ const states = {};
+ for (const [stateName, stateDef] of Object.entries(fa.states || {})) {
+ const cleanState = normalizeFrameStateName(stateName);
+ if (!cleanState) continue;
+ const frames = (Array.isArray(stateDef?.frames) ? stateDef.frames : [])
+ .map((f) => {
+ const url = String(f?.url || "").trim();
+ const hasCrop =
+ Number.isFinite(Number(f?.sx)) &&
+ Number.isFinite(Number(f?.sy)) &&
+ Number.isFinite(Number(f?.sw)) &&
+ Number.isFinite(Number(f?.sh));
+ return hasCrop
+ ? {
+ url,
+ sx: clamp(Number(f.sx), 0, 8192),
+ sy: clamp(Number(f.sy), 0, 8192),
+ sw: clamp(Number(f.sw), 1, 8192),
+ sh: clamp(Number(f.sh), 1, 8192)
+ }
+ : { url };
+ })
+ .filter((f) => Boolean(f.url));
+ if (!frames.length) continue;
+ states[cleanState] = {
+ frames,
+ fps: clamp(stateDef?.fps || fa.defaultFps || 8, 1, 24),
+ loop: Object.prototype.hasOwnProperty.call(stateDef || {}, "loop") ? Boolean(stateDef.loop) : true,
+ flipXWithDirection: Object.prototype.hasOwnProperty.call(stateDef || {}, "flipXWithDirection") ? Boolean(stateDef.flipXWithDirection) : true
+ };
+ }
+ const hasStates = Object.keys(states).length > 0;
+ if (!hasStates) {
+ payload.mode = "profile_token";
+ } else {
+ const movementMap = {};
+ const moveRaw = fa.movementMap && typeof fa.movementMap === "object" ? fa.movementMap : {};
+ for (const key of ["idle", "idleUp", "idleDown", "walkVertical", "walkHorizontal", "walkUp", "walkDown", "walkLeft", "walkRight"]) {
+ const v = normalizeFrameStateName(moveRaw[key]);
+ if (v && states[v]) movementMap[key] = v;
+ }
+ payload.frameAnimation = {
+ defaultFps: clamp(fa.defaultFps || 8, 1, 24),
+ renderScale: clamp(fa.renderScale || 1, 0.25, 4.0),
+ states,
+ movementMap,
+ emotes: Array.isArray(fa.emotes) ? fa.emotes.map((e) => ({ ...e })) : []
+ };
+ }
+ }
+ return payload;
+ }
+
+ function normalizeFrameStateName(name) {
+ const raw = String(name || "").trim();
+ if (!raw) return "";
+ if (!/^[a-z][a-z0-9_]{0,31}$/i.test(raw)) return "";
+ return raw;
+ }
+
+ function normalizeFrameAnimation(raw) {
+ const input = raw && typeof raw === "object" ? raw : {};
+ const defaultFps = clamp(input.defaultFps, 1, 24);
+ const renderScale = clamp(input.renderScale, 0.25, 4.0);
+ const statesRaw = input.states && typeof input.states === "object" ? input.states : {};
+ const states = {};
+ let totalFrames = 0;
+ const MAX_STATES = 24;
+ const MAX_FRAMES_PER_STATE = 48;
+ const MAX_TOTAL_FRAMES = 220;
+ for (const [stateRaw, defRaw] of Object.entries(statesRaw).slice(0, MAX_STATES)) {
+ const state = normalizeFrameStateName(stateRaw);
+ if (!state) continue;
+ const def = defRaw && typeof defRaw === "object" ? defRaw : {};
+ const framesRaw = Array.isArray(def.frames) ? def.frames : [];
+ const frames = [];
+ for (const frameRaw of framesRaw.slice(0, MAX_FRAMES_PER_STATE)) {
+ const url = String(frameRaw?.url || "").trim();
+ if (!url || url.length > 240) continue;
+ if (!url) continue;
+ const sx = clamp(Number(frameRaw?.sx), 0, 8192);
+ const sy = clamp(Number(frameRaw?.sy), 0, 8192);
+ const sw = clamp(Number(frameRaw?.sw), 1, 8192);
+ const sh = clamp(Number(frameRaw?.sh), 1, 8192);
+ const hasCrop =
+ Number.isFinite(Number(frameRaw?.sx)) &&
+ Number.isFinite(Number(frameRaw?.sy)) &&
+ Number.isFinite(Number(frameRaw?.sw)) &&
+ Number.isFinite(Number(frameRaw?.sh));
+ frames.push(hasCrop ? { url, sx, sy, sw, sh } : { url });
+ totalFrames += 1;
+ if (totalFrames >= MAX_TOTAL_FRAMES) break;
+ }
+ if (!frames.length) continue;
+ states[state] = {
+ frames,
+ fps: clamp(def.fps || defaultFps, 1, 24),
+ loop: Object.prototype.hasOwnProperty.call(def, "loop") ? Boolean(def.loop) : true,
+ flipXWithDirection: Object.prototype.hasOwnProperty.call(def, "flipXWithDirection") ? Boolean(def.flipXWithDirection) : true
+ };
+ if (totalFrames >= MAX_TOTAL_FRAMES) break;
+ }
+ if (!Object.keys(states).length) return null;
+ const movementRaw = input.movementMap && typeof input.movementMap === "object" ? input.movementMap : {};
+ const movementMap = {};
+ const movementKeys = ["idle", "idleUp", "idleDown", "walkVertical", "walkHorizontal", "walkUp", "walkDown", "walkLeft", "walkRight"];
+ for (const key of movementKeys) {
+ const state = normalizeFrameStateName(movementRaw[key]);
+ if (state && states[state]) movementMap[key] = state;
+ }
+ const emotesRaw = Array.isArray(input.emotes) ? input.emotes : [];
+ const emotes = [];
+ for (const emoteRaw of emotesRaw.slice(0, 16)) {
+ const name = normalizeFrameStateName(emoteRaw?.name);
+ const state = normalizeFrameStateName(emoteRaw?.state);
+ if (!name || !state || !states[state]) continue;
+ emotes.push({
+ name,
+ state,
+ hotkey: String(emoteRaw?.hotkey || "").trim(),
+ loop: Boolean(emoteRaw?.loop),
+ interruptible: Object.prototype.hasOwnProperty.call(emoteRaw || {}, "interruptible") ? Boolean(emoteRaw.interruptible) : true
+ });
+ }
+ return { defaultFps, renderScale, states, movementMap, emotes };
+ }
+
+ function getFrameImage(url) {
+ const src = String(url || "").trim();
+ if (!src) return null;
+ const now = Date.now();
+ const cached = frameAvatarCache.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";
+ frameAvatarCache.set(src, { img: null, status: "loading", failedAt: 0 });
+ img.onload = () => frameAvatarCache.set(src, { img, status: "ok", failedAt: 0 });
+ img.onerror = () => frameAvatarCache.set(src, { img: null, status: "error", failedAt: Date.now() });
+ img.src = src;
+ return null;
+ }
+
+ function resolveAvatarFrame(username, u, nowMs) {
+ const avatar = normalizeAvatarState(u?.avatar || null);
+ if (avatar.mode !== "frame_animation" || !avatar.frameAnimation) return null;
+ const anim = avatar.frameAnimation;
+ const states = anim.states || {};
+ const x = Number(u?.x ?? u?.tx ?? 0);
+ const y = Number(u?.y ?? u?.ty ?? 0);
+ const rt = avatarAnimRuntime.get(username) || { lastX: x, lastY: y, lastState: "", lastFrameAt: nowMs, lastFacing: 1, lastDir: "down", lastMoveAt: 0 };
+ const dx = x - Number(rt.lastX || x);
+ const dy = y - Number(rt.lastY || y);
+ const movedDistance = Math.hypot(dx, dy);
+ if (movedDistance > 0.00008) {
+ rt.lastMoveAt = nowMs;
+ if (Math.abs(dx) >= Math.abs(dy)) {
+ rt.lastFacing = dx < 0 ? -1 : 1;
+ rt.lastDir = dx < 0 ? "left" : "right";
+ } else {
+ rt.lastDir = dy < 0 ? "up" : "down";
+ }
+ }
+ const isMoving = nowMs - Number(rt.lastMoveAt || 0) < 180;
+ const override = emoteUntil.get(username);
+ if (override && Number(override.until || 0) <= nowMs) emoteUntil.delete(username);
+ const liveOverride = emoteUntil.get(username);
+ let stateName = "";
+ if (liveOverride?.state && states[liveOverride.state]) {
+ stateName = liveOverride.state;
+ } else if (isMoving) {
+ if (Math.abs(dx) >= Math.abs(dy)) {
+ stateName = anim.movementMap?.walkHorizontal || anim.movementMap?.walkRight || anim.movementMap?.walkLeft || "walk_horizontal";
+ } else if (dy < 0) {
+ stateName = anim.movementMap?.walkUp || anim.movementMap?.walkVertical || "walk_vertical";
+ } else {
+ stateName = anim.movementMap?.walkDown || anim.movementMap?.walkVertical || "walk_vertical";
+ }
+ } else {
+ stateName =
+ rt.lastDir === "up"
+ ? anim.movementMap?.idleUp || anim.movementMap?.idle || "idle_up"
+ : anim.movementMap?.idleDown || anim.movementMap?.idle || "idle_down";
+ }
+ if (!states[stateName]) {
+ stateName = states.idle_down ? "idle_down" : states.idle ? "idle" : Object.keys(states)[0] || "";
+ }
+ const state = stateName ? states[stateName] : null;
+ if (!state) {
+ avatarAnimRuntime.set(username, { ...rt, lastX: x, lastY: y });
+ return null;
+ }
+ const frames = Array.isArray(state.frames) ? state.frames : [];
+ if (!frames.length) {
+ avatarAnimRuntime.set(username, { ...rt, lastX: x, lastY: y });
+ return null;
+ }
+ if (rt.lastState !== stateName) {
+ rt.lastState = stateName;
+ rt.lastFrameAt = nowMs;
+ }
+ const fps = clamp(state.fps || anim.defaultFps || 8, 1, 24);
+ const elapsed = Math.max(0, (nowMs - Number(rt.lastFrameAt || nowMs)) / 1000);
+ const rawIndex = Math.floor(elapsed * fps);
+ const loop = Boolean(state.loop);
+ const index = loop ? rawIndex % frames.length : Math.min(frames.length - 1, rawIndex);
+ const frame = frames[index] || {};
+ const frameUrl = String(frame?.url || "").trim();
+ const flipX = Boolean(state.flipXWithDirection) && Number(rt.lastFacing || 1) < 0;
+ rt.lastX = x;
+ rt.lastY = y;
+ avatarAnimRuntime.set(username, rt);
+ return {
+ frameUrl,
+ flipX,
+ renderScale: clamp(anim.renderScale || 1, 0.25, 4.0),
+ crop:
+ Number.isFinite(Number(frame?.sx)) &&
+ Number.isFinite(Number(frame?.sy)) &&
+ Number.isFinite(Number(frame?.sw)) &&
+ Number.isFinite(Number(frame?.sh))
+ ? {
+ sx: clamp(Number(frame.sx), 0, 8192),
+ sy: clamp(Number(frame.sy), 0, 8192),
+ sw: clamp(Number(frame.sw), 1, 8192),
+ sh: clamp(Number(frame.sh), 1, 8192)
+ }
+ : null
+ };
+ }
function setHidden(el, hidden) {
if (!el) return;
el.classList.toggle("hidden", Boolean(hidden));
}
+ function isTextEditingElement(el) {
+ const node = el instanceof HTMLElement ? el : null;
+ if (!node) return false;
+ if (node.isContentEditable) return true;
+ const tag = String(node.tagName || "").toLowerCase();
+ if (tag === "textarea") return true;
+ if (tag !== "input") return false;
+ const type = String(node.getAttribute("type") || "text").toLowerCase();
+ return !["button", "checkbox", "radio", "range", "file", "color", "submit"].includes(type);
+ }
+
function getSessionToken() {
try {
return localStorage.getItem("bzl_session_token") || "";
@@ -375,6 +1032,35 @@
return String(json.url);
}
+ function countDraftFrames(frameAnimation) {
+ const states = frameAnimation?.states && typeof frameAnimation.states === "object" ? frameAnimation.states : {};
+ let total = 0;
+ for (const state of Object.values(states)) {
+ total += Array.isArray(state?.frames) ? state.frames.length : 0;
+ }
+ return total;
+ }
+
+ async function readImageNaturalSizeFromFile(file) {
+ const objectUrl = URL.createObjectURL(file);
+ try {
+ const img = await new Promise((resolve, reject) => {
+ const el = new Image();
+ el.onload = () => resolve(el);
+ el.onerror = () => reject(new Error("Could not read image."));
+ el.src = objectUrl;
+ });
+ const width = Number(img?.naturalWidth || 0);
+ const height = Number(img?.naturalHeight || 0);
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1) {
+ throw new Error("Invalid image size.");
+ }
+ return { width, height };
+ } finally {
+ URL.revokeObjectURL(objectUrl);
+ }
+ }
+
async function uploadAudioBlob(blob, filenameHint = "walkie.webm") {
const token = getSessionToken();
if (!token) throw new Error("Sign in required.");
@@ -431,6 +1117,84 @@
return Math.max(a, Math.min(b, x));
}
+ function isWalkieTransitionAllowed(fromPhase, toPhase) {
+ if (fromPhase === toPhase) return true;
+ const allowed = {
+ idle: new Set(["recording", "playing", "failed"]),
+ recording: new Set(["encoding", "idle", "failed"]),
+ encoding: new Set(["uploading", "failed", "idle"]),
+ uploading: new Set(["sent", "failed", "idle"]),
+ sent: new Set(["idle", "playing", "failed"]),
+ playing: new Set(["played", "timeout", "idle", "failed"]),
+ played: new Set(["idle"]),
+ timeout: new Set(["idle"]),
+ failed: new Set(["idle", "recording"])
+ };
+ return Boolean(allowed[fromPhase]?.has(toPhase));
+ }
+
+ function emitWalkieState() {
+ if (!activeMap?.id) return;
+ const payload = {
+ mapId: activeMap.id,
+ id: walkieState.id || "",
+ phase: walkieState.phase || "idle",
+ attempt: Number(walkieState.attempt || 0) || 0,
+ error: walkieState.error || ""
+ };
+ const key = `${payload.mapId}|${payload.id}|${payload.phase}|${payload.attempt}|${payload.error}`;
+ if (key === walkieLastStateEmit) return;
+ walkieLastStateEmit = key;
+ ctx.send("walkieState", payload);
+ }
+
+ function setWalkieState(phase, patch = {}, options = {}) {
+ const nextPhase = String(phase || "idle");
+ const force = Boolean(options?.force);
+ if (!walkiePhases.has(nextPhase)) return;
+ if (!force && !isWalkieTransitionAllowed(String(walkieState.phase || "idle"), nextPhase)) return;
+ walkieState = {
+ phase: nextPhase,
+ id: typeof patch.id === "string" ? patch.id : walkieState.id,
+ error: typeof patch.error === "string" ? patch.error : "",
+ attempt: Number(patch.attempt || 0) || 0
+ };
+ emitWalkieState();
+ const btn = document.getElementById("mapsWalkieBtn");
+ const hint = document.getElementById("mapsWalkieHint");
+ const status = document.getElementById("mapsWalkieStatus");
+ if (btn) {
+ const ph = walkieState.phase;
+ btn.textContent =
+ ph === "recording"
+ ? "Recording…"
+ : ph === "encoding"
+ ? "Encoding…"
+ : ph === "uploading"
+ ? "Uploading…"
+ : ph === "playing"
+ ? "Playing…"
+ : ph === "failed"
+ ? "Retry walkie"
+ : "Hold to talk";
+ }
+ if (status) {
+ const ph = walkieState.phase;
+ let txt = "";
+ if (ph === "uploading") txt = walkieState.attempt > 1 ? `Uploading (retry ${walkieState.attempt - 1})…` : "Uploading…";
+ else if (ph === "sent") txt = "Sent";
+ else if (ph === "playing") txt = "Playing nearby";
+ else if (ph === "played") txt = "Played";
+ else if (ph === "timeout") txt = "Playback timeout";
+ else if (ph === "failed") txt = walkieState.error || "Walkie failed";
+ else if (ph === "recording") txt = "Recording";
+ status.textContent = txt;
+ }
+ if (hint) {
+ hint.textContent = walkieState.phase === "failed" ? "Press and hold to retry" : "or hold ~";
+ }
+ }
+
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;
@@ -475,23 +1239,54 @@
walkieRecorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
walkieRecording = true;
walkieStartAt = Date.now();
+ setWalkieState("recording");
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;
+ if (elapsed < 180) {
+ setWalkieState("idle", { id: "", error: "", attempt: 0 }, { force: true });
+ return;
+ }
+ if (!activeMap?.id) {
+ setWalkieState("failed", { id: "", error: "Not in a map.", attempt: 0 }, { force: true });
+ return;
+ }
+ setWalkieState("encoding", {}, { force: true });
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));
+ let uploadError = null;
+ const maxAttempts = 2;
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ setWalkieState("uploading", { id, attempt }, { force: true });
+ 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 });
+ setWalkieState("sent", { id, attempt }, { force: true });
+ setTimeout(() => {
+ if (walkieState.id === id && (walkieState.phase === "sent" || walkieState.phase === "idle")) {
+ setWalkieState("idle", { id: "", error: "", attempt: 0 }, { force: true });
+ }
+ }, 1200);
+ uploadError = null;
+ break;
+ } catch (e) {
+ uploadError = e;
+ if (attempt < maxAttempts) {
+ const backoff = 450 * (2 ** (attempt - 1)) + Math.floor(Math.random() * 180);
+ await new Promise((resolve) => setTimeout(resolve, backoff));
+ }
+ }
+ }
+ if (uploadError) {
+ const msg = String(uploadError?.message || uploadError);
+ setWalkieState("failed", { id, error: msg, attempt: maxAttempts }, { force: true });
+ ctx.toast("Walkie", msg);
}
};
walkieRecorder.start();
@@ -509,10 +1304,8 @@
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?.();
+ entry.ack?.("stop-all");
+ entry.cleanup?.("stop-all");
} catch {
// ignore
}
@@ -527,6 +1320,9 @@
if (!id || !url || !username) return;
if (walkiePlaybacks.has(id)) return;
if (!activeMap?.walkiesEnabled) return;
+ if (walkieState.phase !== "recording" && walkieState.phase !== "uploading" && walkieState.phase !== "encoding") {
+ setWalkieState("playing", { id });
+ }
const ok = await ensureAudioReady();
if (!ok) {
@@ -582,9 +1378,12 @@
return;
}
- const ack = () => {
+ let acked = false;
+ const ackOnce = (reason) => {
+ if (acked) return;
+ acked = true;
if (!activeMap?.id) return;
- ctx.send("walkiePlayed", { id });
+ ctx.send("walkiePlayed", { id, reason: String(reason || "played") });
};
const cleanup = () => {
@@ -607,14 +1406,23 @@
// ignore
}
walkiePlaybacks.delete(id);
+ if (walkieState.phase === "playing" && walkieState.id === id) {
+ setWalkieState("idle", { id: "", error: "", attempt: 0 }, { force: true });
+ }
};
a.onended = () => {
- ack();
+ ackOnce("ended");
+ setWalkieState("played", { id }, { force: true });
+ setTimeout(() => {
+ if (walkieState.id === id && walkieState.phase === "played") {
+ setWalkieState("idle", { id: "", error: "", attempt: 0 }, { force: true });
+ }
+ }, 700);
cleanup();
};
a.onerror = () => {
- ack();
+ ackOnce("error");
cleanup();
};
@@ -629,20 +1437,31 @@
}, 120);
const ackTimer = setTimeout(() => {
- ack();
+ ackOnce("timeout");
+ if (walkieState.id === id && walkieState.phase === "playing") {
+ setWalkieState("timeout", { id, error: "Playback timed out." }, { force: true });
+ setTimeout(() => {
+ if (walkieState.id === id && walkieState.phase === "timeout") {
+ setWalkieState("idle", { id: "", error: "", attempt: 0 }, { force: true });
+ }
+ }, 900);
+ }
+ cleanup();
}, 25_000);
- walkiePlaybacks.set(id, { audio: a, gain, pan, filter, interval, ackTimer });
+ walkiePlaybacks.set(id, { audio: a, gain, pan, filter, interval, ackTimer, ack: ackOnce, cleanup });
try {
await a.play();
} catch {
// If autoplay blocked, we'll just cleanup (owner can click to enable and retry later).
+ ackOnce("autoplay-blocked");
cleanup();
}
}
function enterMaps() {
mode = "maps";
+ applyFocusModeClass();
if (mapsBtn) {
mapsBtn.classList.add("primary");
mapsBtn.classList.remove("ghost");
@@ -661,6 +1480,7 @@
function exitMapsToHives() {
mode = "hives";
+ applyFocusModeClass();
if (mapsBtn) {
mapsBtn.classList.add("ghost");
mapsBtn.classList.remove("primary");
@@ -686,7 +1506,7 @@
function renderMapsList() {
if (!mapsPanel) return;
if (mode !== "maps") return;
- const canCreate = ["owner", "moderator"].includes(String(ctx.getRole() || "").toLowerCase());
+ const canCreate = isMapStaffRole(ctx.getRole());
const me = String(ctx.getUser() || "").trim().toLowerCase();
const role = String(ctx.getRole() || "").toLowerCase();
const createHtml = canCreate
@@ -695,7 +1515,7 @@
<div class="mapCreateCard">
<div class="mapsTop">
<div class="mapsTopTitle">Create map</div>
- <div class="small muted">Owner/mod only</div>
+ <div class="small muted">Owner/admin/mod only</div>
</div>
<div class="mapCreateGrid">
<label>
@@ -730,11 +1550,12 @@
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);
+ const canManage = isMapStaffRole(role) || (owner && me && owner === me);
+ const liveBadge = Boolean(m.live) ? `<span class="tag" style="background: rgba(0,255,150,0.16); border-color: rgba(0,255,150,0.45); color:#8fffd0;">LIVE</span>` : "";
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="mapMeta"><span>${escapeHtml(m.id)}</span><span>${count} in room ${liveBadge}</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>` : ""}
@@ -805,6 +1626,165 @@
}
}
+ function renderAvatarEditorModal(draft, canManagePresets = false) {
+ const mode = draft?.mode === "frame_animation" ? "frame_animation" : "profile_token";
+ const displayName = String(draft?.displayName || "");
+ const showUsername = draft?.showUsername !== false;
+ const fa = draft?.frameAnimation || defaultFrameAnimationDraft();
+ const sheetImport = fa?.sheetImport && typeof fa.sheetImport === "object" ? fa.sheetImport : { cols: 4, rows: 4, limit: 24 };
+ const sheetCols = Math.floor(clamp(sheetImport.cols || 4, 1, 32));
+ const sheetRows = Math.floor(clamp(sheetImport.rows || 4, 1, 32));
+ const sheetLimit = Math.floor(clamp(sheetImport.limit || 24, 1, 96));
+ const states = fa.states && typeof fa.states === "object" ? fa.states : {};
+ const stateNames = Object.keys(states);
+ const selectedState = states[fa.selectedState] ? fa.selectedState : stateNames[0] || "idle_down";
+ const selected = states[selectedState] || { frames: [], fps: fa.defaultFps || 8, loop: true, flipXWithDirection: true };
+ const presetOptions = avatarPresets
+ .map((preset) => `<option value="${escapeHtml(preset.id)}" ${avatarPresetSelectedId === preset.id ? "selected" : ""}>${escapeHtml(preset.name)}${preset.published ? "" : " (draft)"}</option>`)
+ .join("");
+ const frameRows = (Array.isArray(selected.frames) ? selected.frames : [])
+ .map((frame, idx) => {
+ const url = String(frame?.url || "");
+ const short = url.length > 54 ? `${url.slice(0, 54)}...` : url;
+ return `<div class="mapsAvatarFrameRow">
+ <img class="mapsAvatarFrameThumb" src="${escapeHtml(url)}" alt="" />
+ <div class="mapsAvatarFrameName">${escapeHtml(short)}</div>
+ <button type="button" class="ghost smallBtn" data-avatar-frame-up="${idx}">↑</button>
+ <button type="button" class="ghost smallBtn" data-avatar-frame-down="${idx}">↓</button>
+ <button type="button" class="ghost smallBtn" data-avatar-frame-remove="${idx}">✕</button>
+ </div>`;
+ })
+ .join("");
+ return `
+ <div class="mapsAvatarEditorModal" id="mapsAvatarEditorModal">
+ <div class="mapsAvatarEditorCard">
+ <div class="row" style="justify-content:space-between; gap:10px;">
+ <div>
+ <div class="dockTitle">Avatar editor</div>
+ <div class="small muted">Quick mode: upload frame images by state.</div>
+ </div>
+ <button type="button" class="ghost smallBtn" id="mapsAvatarEditorCloseBtn">Close</button>
+ </div>
+ <div class="mapsAvatarEditorGrid" style="margin-top:10px;">
+ <div class="mapsAvatarStates">
+ <div class="small muted">Profile</div>
+ <label style="margin-top:8px;">
+ <span class="small muted">Display name</span>
+ <input id="mapsAvatarEditorDisplayName" type="text" maxlength="32" value="${escapeHtml(displayName)}" />
+ </label>
+ <label class="checkRow" style="margin-top:8px;">
+ <span>Show username</span>
+ <input id="mapsAvatarEditorShowUsername" type="checkbox" ${showUsername ? "checked" : ""} />
+ </label>
+ <label style="margin-top:10px;">
+ <span class="small muted">Avatar mode</span>
+ <select id="mapsAvatarEditorMode">
+ <option value="profile_token" ${mode === "profile_token" ? "selected" : ""}>Profile token</option>
+ <option value="frame_animation" ${mode === "frame_animation" ? "selected" : ""}>Frame animation (Quick)</option>
+ </select>
+ </label>
+ <div class="panelDivider" style="margin-top:10px;"></div>
+ <div class="small muted">Server presets</div>
+ <label style="margin-top:8px;">
+ <span class="small muted">Preset</span>
+ <select id="mapsAvatarPresetSelect">
+ <option value="">Select preset...</option>
+ ${presetOptions}
+ </select>
+ </label>
+ <div class="row" style="margin-top:8px; gap:8px; flex-wrap:wrap;">
+ <button type="button" class="ghost smallBtn" id="mapsAvatarPresetApplyBtn">Apply preset</button>
+ <button type="button" class="ghost smallBtn" id="mapsAvatarPresetRefreshBtn">Refresh</button>
+ </div>
+ ${canManagePresets ? `
+ <label style="margin-top:8px;">
+ <span class="small muted">Preset name (staff)</span>
+ <input id="mapsAvatarPresetName" type="text" maxlength="40" placeholder="Example: Forest Ranger" />
+ </label>
+ <label class="checkRow" style="margin-top:8px;">
+ <span>Published</span>
+ <input id="mapsAvatarPresetPublished" type="checkbox" checked />
+ </label>
+ <div class="row" style="margin-top:8px; gap:8px; flex-wrap:wrap;">
+ <button type="button" class="ghost smallBtn" id="mapsAvatarPresetSaveBtn">Save preset</button>
+ <button type="button" class="ghost smallBtn" id="mapsAvatarPresetDeleteBtn">Delete preset</button>
+ </div>
+ ` : ""}
+ <div class="${mode === "frame_animation" ? "" : "hidden"}" id="mapsAvatarEditorFrameSettings">
+ <label style="margin-top:10px;">
+ <span class="small muted">Default FPS</span>
+ <input id="mapsAvatarEditorDefaultFps" type="number" min="1" max="24" value="${escapeHtml(String(clamp(fa.defaultFps || 8, 1, 24)))}" />
+ </label>
+ <label style="margin-top:10px;">
+ <span class="small muted">State</span>
+ <div class="row" style="gap:8px;">
+ <select id="mapsAvatarEditorStateSelect">${stateNames.map((stateName) => `<option value="${escapeHtml(stateName)}" ${stateName === selectedState ? "selected" : ""}>${escapeHtml(stateName)}</option>`).join("")}</select>
+ <button type="button" class="ghost smallBtn" id="mapsAvatarEditorAddStateBtn">+ State</button>
+ </div>
+ </label>
+ <label class="checkRow" style="margin-top:8px;">
+ <span>Loop selected state</span>
+ <input id="mapsAvatarEditorStateLoop" type="checkbox" ${selected.loop !== false ? "checked" : ""} />
+ </label>
+ <label class="checkRow" style="margin-top:8px;">
+ <span>Flip with direction</span>
+ <input id="mapsAvatarEditorStateFlip" type="checkbox" ${selected.flipXWithDirection !== false ? "checked" : ""} />
+ </label>
+ <label style="margin-top:10px;">
+ <span class="small muted">State FPS</span>
+ <input id="mapsAvatarEditorStateFps" type="number" min="1" max="24" value="${escapeHtml(String(clamp(selected.fps || fa.defaultFps || 8, 1, 24)))}" />
+ </label>
+ <label style="margin-top:10px;">
+ <span class="small muted">Render scale</span>
+ <input id="mapsAvatarEditorRenderScale" type="range" min="0.25" max="4" step="0.05" value="${escapeHtml(String(clamp(fa.renderScale || 1, 0.25, 4).toFixed(2)))}" />
+ <div class="small muted" id="mapsAvatarEditorRenderScaleVal">${escapeHtml(String(clamp(fa.renderScale || 1, 0.25, 4).toFixed(2)))}x</div>
+ </label>
+ </div>
+ </div>
+ <div class="mapsAvatarFrames">
+ <div class="row" style="justify-content:space-between; gap:8px;">
+ <div class="small muted">Frames (${escapeHtml(selectedState)})</div>
+ <div class="row" style="gap:8px;">
+ <label class="ghost smallBtn" style="cursor:pointer;">
+ Add frame
+ <input id="mapsAvatarEditorFrameInput" type="file" accept="image/png,image/webp,image/gif,image/jpeg" style="display:none;" />
+ </label>
+ <label class="ghost smallBtn" style="cursor:pointer;">
+ Import sheet
+ <input id="mapsAvatarEditorSheetInput" type="file" accept="image/png,image/webp" style="display:none;" />
+ </label>
+ </div>
+ </div>
+ <div class="mapsAvatarSheetGrid">
+ <label>
+ <span class="small muted">Cols</span>
+ <input id="mapsAvatarEditorSheetCols" type="number" min="1" max="32" value="${escapeHtml(String(sheetCols))}" />
+ </label>
+ <label>
+ <span class="small muted">Rows</span>
+ <input id="mapsAvatarEditorSheetRows" type="number" min="1" max="32" value="${escapeHtml(String(sheetRows))}" />
+ </label>
+ <label>
+ <span class="small muted">Import max</span>
+ <input id="mapsAvatarEditorSheetLimit" type="number" min="1" max="96" value="${escapeHtml(String(sheetLimit))}" />
+ </label>
+ </div>
+ <div class="small muted" style="margin-top:6px;">One upload, many frames: split by rows/cols into cropped frames for this state.</div>
+ <div style="margin-top:8px; max-height: 46vh; overflow:auto;">
+ ${frameRows || `<div class="small muted">No frames yet.</div>`}
+ </div>
+ </div>
+ </div>
+ <div class="row" style="justify-content:flex-end; gap:8px; margin-top:10px;">
+ <div class="small muted grow" id="mapsAvatarEditorStatus"></div>
+ <button type="button" class="ghost smallBtn" id="mapsAvatarEditorCancelBtn">Cancel</button>
+ <button type="button" class="primary smallBtn" id="mapsAvatarEditorSaveBtn">Save avatar</button>
+ </div>
+ </div>
+ </div>
+ `;
+ }
+
function renderMapView() {
if (!mapsPanel) return;
if (mode !== "map" || !activeMap) return;
@@ -812,14 +1792,20 @@
if (chatPanel) chatPanel.classList.add("hidden");
if (chatResizeHandle) chatResizeHandle.classList.add("hidden");
const title = escapeHtml(activeMap.title || activeMap.id);
+ const now = Date.now();
const list = Array.from(users.keys())
.sort((a, b) => a.localeCompare(b))
- .map((u) => `<div class="small">@${escapeHtml(u)}</div>`)
+ .map((u) => {
+ const typing = Number(typingUntil.get(u) || 0) > now;
+ const label = displayNameForUser(u, users.get(u));
+ const shown = label ? escapeHtml(label) : `<span class="muted">(hidden)</span>`;
+ return `<div class="small">${shown}${typing ? ` <span class="muted">(typing...)</span>` : ""}</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);
+ const canManage = isMapStaffRole(role) || (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;
@@ -834,16 +1820,25 @@
(Array.isArray(activeMap.fallThroughs) ? activeMap.fallThroughs.length : 0);
const walkiesEnabled = Boolean(activeMap.walkiesEnabled);
const ttrpgEnabled = Boolean(activeMap.ttrpgEnabled);
+ const focusSupported = featureEnabled("focusMode");
+ const gmOverlaySupported = featureEnabled("gmOverlay");
+ const walkieV2Supported = featureEnabled("walkieV2");
+ const canUseGmTools = Boolean(ttrpgEnabled && canManageTtrpg);
+ const meUser = users.get(me) || null;
+ const meAvatar = normalizeAvatarState(meUser?.avatar || null);
+ const avatarDraft = ensureAvatarEditorDraft(meAvatar);
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/>
+ Move: <b>WASD</b> / arrows, Chat: <b>T</b>${focusSupported ? `, Focus: <b>F</b>` : ""}, Cinematic: <b>C</b>${canManageTtrpg ? `, GM UI: <b>G</b>` : ""}<br/>
${walkiesEnabled ? `Walkie: hold <b>~</b><br/>` : ""}
+ Emotes: <b>Alt+1..5</b> (if configured)<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>
`;
+ const avatarEditorHtml = avatarEditorOpen ? renderAvatarEditorModal(avatarDraft, isMapStaffRole(role) || avatarPresetsCanManage) : "";
let polyModalHtml = "";
if (canEditMap && editMode) {
try {
@@ -918,19 +1913,69 @@
` : ""}
`
: "";
+ const modeLabel = gmMode === "polygon" ? "Polygon" : gmMode === "place" ? "Place" : gmMode === "select" ? "Select" : "Play";
+ const inspectorHtml = gmOverlaySupported && gmOverlayVisible && inspectorOpen
+ ? `<div class="mapInspectorDrawer" id="mapsInspectorDrawer">
+ <div class="mapInspectorTitle">Inspector</div>
+ <div class="small muted">Mode: <b>${escapeHtml(modeLabel)}</b></div>
+ <div class="small muted">Users: <b>${Number(activeMap.userCount || users.size)}</b>${activeMap.live ? ` • <span style="color:#8fffd0">LIVE</span>` : ""}</div>
+ <div class="small muted">Map: <b>${escapeHtml(activeMap.id || "")}</b></div>
+ <div class="small muted">Tools: ${canUseGmTools ? "enabled" : "waiting for GM/TTRPG mode"}</div>
+ <div class="small muted" style="margin-top:8px;">Command palette: <span class="tag">/</span> or <span class="tag">Ctrl/Cmd+K</span></div>
+ </div>`
+ : `<div class="mapInspectorDrawer hidden" id="mapsInspectorDrawer"></div>`;
+ const hotbarHtml = gmOverlaySupported && gmOverlayVisible
+ ? `<div class="mapGmHotbar ${walkiesEnabled ? "raiseForWalkie" : ""}">
+ <div class="mapGmHotbarInner">
+ <button type="button" class="${gmMode === "play" ? "primary" : "ghost"} smallBtn" data-gm-mode="play" title="1">Play</button>
+ <button type="button" class="${gmMode === "select" ? "primary" : "ghost"} smallBtn" data-gm-mode="select" ${canUseGmTools ? "" : "disabled"} title="2">Select</button>
+ <button type="button" class="${gmMode === "place" ? "primary" : "ghost"} smallBtn" data-gm-mode="place" ${canUseGmTools ? "" : "disabled"} title="3">Place</button>
+ <button type="button" class="${gmMode === "polygon" ? "primary" : "ghost"} smallBtn" data-gm-mode="polygon" ${canUseGmTools ? "" : "disabled"} title="4">Polygon</button>
+ <button type="button" class="${inspectorOpen ? "primary" : "ghost"} smallBtn" data-gm-inspector="1">Inspector</button>
+ </div>
+ </div>`
+ : "";
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 class="mapGmTopLeft">
+ <div class="mapModePill"><b>${escapeHtml(activeMap.title || activeMap.id || "Map")}</b><span class="tag">${escapeHtml(modeLabel)}</span></div>
+ </div>
+ <div class="mapCornerTools">
+ <button type="button" class="ghost smallBtn" data-mapback="1">Back</button>
+ <button type="button" class="${cinematicMode ? "primary" : "ghost"} smallBtn" data-mapcinematic="1">${cinematicMode ? "Exit cinematic" : "Cinematic"}</button>
+ ${gmOverlaySupported && canUseGmTools ? `<button type="button" class="${gmOverlayVisible ? "primary" : "ghost"} smallBtn" data-gm-overlay="1">${gmOverlayVisible ? "Hide GM" : "GM UI"}</button>` : ""}
+ ${focusSupported ? `<button type="button" class="${isFocusMode ? "primary" : "ghost"} smallBtn" data-mapfocus="1">${isFocusMode ? "Exit focus" : "Focus"}</button>` : ""}
+ </div>
+ ${inspectorHtml}
+ ${hotbarHtml}
+ <div class="mapChatOverlay hidden ${gmOverlaySupported && gmOverlayVisible ? "raiseForHotbar" : walkiesEnabled ? "raiseForWalkie" : ""}" id="mapsChatOverlay">
+ <div class="mapChatToolbar">
+ <button type="button" class="ghost smallBtn mapChatDragHandle" id="mapsChatDragHandle" title="Drag chat overlay">Drag</button>
+ <div class="mapChatScopeRow">
+ <button type="button" class="${mapChatScope === "local" ? "primary" : "ghost"} smallBtn" id="mapsChatScopeLocal">Local</button>
+ <button type="button" class="${mapChatScope === "global" ? "primary" : "ghost"} smallBtn" id="mapsChatScopeGlobal">Global</button>
+ </div>
+ <div class="mapChatOpacity">
+ <span class="small muted">Opacity</span>
+ <input id="mapsChatOpacity" type="range" min="0.25" max="1" step="0.05" value="${escapeHtml(String(clamp(mapChatOverlayOpacity, 0.25, 1).toFixed(2)))}" />
+ </div>
+ <button type="button" class="ghost smallBtn" id="mapsChatReset" title="Reset chat overlay position and opacity">Reset</button>
+ <button type="button" class="ghost smallBtn" id="mapsChatClose" title="Close">✕</button>
+ </div>
+ <div class="mapChatFeed" id="mapsChatFeed"><div class="small muted">No ${mapChatScope} messages yet.</div></div>
+ <div class="row" style="gap:8px;">
+ <input id="mapsChatInput" placeholder="Say something..." />
+ <button type="button" class="primary" id="mapsChatSend">Send</button>
+ </div>
</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 class="mapWalkieHint" id="mapsWalkieHint">or hold ~</div>
+ <div class="mapWalkieHint ${walkieV2Supported ? "" : "hidden"}" id="mapsWalkieStatus"></div>
</div>
</div>
</div>
@@ -939,7 +1984,7 @@
<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="small muted">${Number(activeMap.userCount || users.size)} in room${activeMap.live ? ` • <span style="color:#8fffd0">LIVE</span>` : ""}</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>
@@ -959,12 +2004,29 @@
: ""
}
${shortcutHintHtml}
+ <div class="panelDivider"></div>
+ <div class="small muted">Your avatar label</div>
+ <label style="margin-top:8px;">
+ <span class="small muted">Display name (optional)</span>
+ <input id="mapsDisplayNameInput" type="text" maxlength="32" placeholder="@${escapeHtml(me || "you")}" value="${escapeHtml(meAvatar.displayName || "")}" />
+ </label>
+ <label class="checkRow" style="margin-top:8px;">
+ <span>Show username label</span>
+ <input id="mapsShowUsernameToggle" type="checkbox" ${meAvatar.showUsername ? "checked" : ""} />
+ </label>
+ <div class="row" style="margin-top:8px; gap:8px;">
+ <button type="button" class="ghost smallBtn" id="mapsSaveAvatarBtn">Save</button>
+ <button type="button" class="ghost smallBtn" id="mapsOpenAvatarEditorBtn">Edit avatar...</button>
+ <div class="small muted" id="mapsAvatarStatus"></div>
+ </div>
${settingsHtml}
</div>
</div>
<div class="mapDock ${ttrpgEnabled ? "" : "hidden"}" id="mapsTtrpgDock"></div>
</div>
+ ${avatarEditorHtml}
`;
+ setWalkieState(walkieState.phase || "idle", walkieState);
if (editMode !== lastEditModeLogged) {
lastEditModeLogged = editMode;
@@ -990,6 +2052,377 @@
};
}
+ const avatarDisplayNameInput = document.getElementById("mapsDisplayNameInput");
+ const avatarShowUsernameToggle = document.getElementById("mapsShowUsernameToggle");
+ const avatarSaveBtn = document.getElementById("mapsSaveAvatarBtn");
+ const openAvatarEditorBtn = document.getElementById("mapsOpenAvatarEditorBtn");
+ const avatarStatus = document.getElementById("mapsAvatarStatus");
+ if (avatarSaveBtn && avatarDisplayNameInput && avatarShowUsernameToggle) {
+ avatarSaveBtn.onclick = () => {
+ const displayName = String(avatarDisplayNameInput.value || "").replace(/\s+/g, " ").trim().slice(0, 32);
+ const showUsername = Boolean(avatarShowUsernameToggle.checked);
+ if (avatarStatus) avatarStatus.textContent = "Saving...";
+ ctx.send("setAvatar", { mode: "profile_token", displayName, showUsername });
+ const mine = me ? users.get(me) : null;
+ if (mine) {
+ mine.avatar = { mode: "profile_token", displayName, showUsername };
+ users.set(me, mine);
+ }
+ avatarEditorDraft = cloneAvatarForEditor({ mode: "profile_token", displayName, showUsername });
+ if (avatarStatus) avatarStatus.textContent = "Saved.";
+ renderMapView();
+ };
+ }
+ if (openAvatarEditorBtn) {
+ openAvatarEditorBtn.onclick = () => {
+ avatarEditorOpen = true;
+ avatarEditorDraft = cloneAvatarForEditor(users.get(me)?.avatar || meAvatar);
+ ctx.send("listAvatarPresets", {});
+ renderMapView();
+ };
+ }
+
+ const avatarEditorCloseBtn = document.getElementById("mapsAvatarEditorCloseBtn");
+ const avatarEditorCancelBtn = document.getElementById("mapsAvatarEditorCancelBtn");
+ const avatarEditorSaveBtn = document.getElementById("mapsAvatarEditorSaveBtn");
+ const avatarEditorDisplayName = document.getElementById("mapsAvatarEditorDisplayName");
+ const avatarEditorShowUsername = document.getElementById("mapsAvatarEditorShowUsername");
+ const avatarEditorMode = document.getElementById("mapsAvatarEditorMode");
+ const avatarEditorFrameInput = document.getElementById("mapsAvatarEditorFrameInput");
+ const avatarEditorSheetInput = document.getElementById("mapsAvatarEditorSheetInput");
+ const avatarEditorSheetCols = document.getElementById("mapsAvatarEditorSheetCols");
+ const avatarEditorSheetRows = document.getElementById("mapsAvatarEditorSheetRows");
+ const avatarEditorSheetLimit = document.getElementById("mapsAvatarEditorSheetLimit");
+ const avatarEditorDefaultFps = document.getElementById("mapsAvatarEditorDefaultFps");
+ const avatarEditorStateSelect = document.getElementById("mapsAvatarEditorStateSelect");
+ const avatarEditorAddStateBtn = document.getElementById("mapsAvatarEditorAddStateBtn");
+ const avatarEditorStateLoop = document.getElementById("mapsAvatarEditorStateLoop");
+ const avatarEditorStateFlip = document.getElementById("mapsAvatarEditorStateFlip");
+ const avatarEditorStateFps = document.getElementById("mapsAvatarEditorStateFps");
+ const avatarEditorRenderScale = document.getElementById("mapsAvatarEditorRenderScale");
+ const avatarEditorRenderScaleVal = document.getElementById("mapsAvatarEditorRenderScaleVal");
+ const avatarPresetSelect = document.getElementById("mapsAvatarPresetSelect");
+ const avatarPresetApplyBtn = document.getElementById("mapsAvatarPresetApplyBtn");
+ const avatarPresetRefreshBtn = document.getElementById("mapsAvatarPresetRefreshBtn");
+ const avatarPresetName = document.getElementById("mapsAvatarPresetName");
+ const avatarPresetPublished = document.getElementById("mapsAvatarPresetPublished");
+ const avatarPresetSaveBtn = document.getElementById("mapsAvatarPresetSaveBtn");
+ const avatarPresetDeleteBtn = document.getElementById("mapsAvatarPresetDeleteBtn");
+ const avatarEditorStatus = document.getElementById("mapsAvatarEditorStatus");
+
+ const closeAvatarEditor = (resetDraft) => {
+ avatarEditorOpen = false;
+ if (resetDraft) avatarEditorDraft = cloneAvatarForEditor(users.get(me)?.avatar || meAvatar);
+ renderMapView();
+ };
+ if (avatarEditorCloseBtn) avatarEditorCloseBtn.onclick = () => closeAvatarEditor(false);
+ if (avatarEditorCancelBtn) avatarEditorCancelBtn.onclick = () => closeAvatarEditor(true);
+
+ if (avatarEditorDisplayName) {
+ avatarEditorDisplayName.oninput = () => {
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ draft.displayName = String(avatarEditorDisplayName.value || "").replace(/\s+/g, " ").trim().slice(0, 32);
+ };
+ }
+ if (avatarPresetSelect) {
+ avatarPresetSelect.onchange = () => {
+ avatarPresetSelectedId = String(avatarPresetSelect.value || "").trim().toLowerCase();
+ const selected = selectedAvatarPresetById(avatarPresetSelectedId);
+ if (avatarPresetName) avatarPresetName.value = selected?.name || "";
+ if (avatarPresetPublished) avatarPresetPublished.checked = selected ? Boolean(selected.published) : true;
+ };
+ }
+ if (avatarPresetRefreshBtn) {
+ avatarPresetRefreshBtn.onclick = () => {
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Refreshing presets...";
+ ctx.send("listAvatarPresets", {});
+ };
+ }
+ if (avatarPresetApplyBtn) {
+ avatarPresetApplyBtn.onclick = () => {
+ const id = String(avatarPresetSelect?.value || "").trim();
+ avatarPresetSelectedId = id.toLowerCase();
+ if (!id) {
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Select a preset first.";
+ return;
+ }
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Applying preset...";
+ ctx.send("applyAvatarPreset", { id });
+ };
+ }
+ if (avatarPresetSaveBtn) {
+ avatarPresetSaveBtn.onclick = () => {
+ const name = String(avatarPresetName?.value || "").replace(/\s+/g, " ").trim().slice(0, 40);
+ if (!name) {
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Preset name required.";
+ return;
+ }
+ const id = String(avatarPresetSelect?.value || "").trim();
+ avatarPresetSelectedId = id.toLowerCase();
+ const payload = collectAvatarPayloadFromDraft();
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Saving preset...";
+ ctx.send("upsertAvatarPreset", {
+ id,
+ name,
+ published: Boolean(avatarPresetPublished?.checked),
+ avatar: payload
+ });
+ };
+ }
+ if (avatarPresetDeleteBtn) {
+ avatarPresetDeleteBtn.onclick = () => {
+ const id = String(avatarPresetSelect?.value || "").trim();
+ avatarPresetSelectedId = id.toLowerCase();
+ if (!id) {
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Select a preset first.";
+ return;
+ }
+ if (!confirm("Delete this avatar preset?")) return;
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Deleting preset...";
+ ctx.send("deleteAvatarPreset", { id });
+ };
+ }
+ if (avatarEditorShowUsername) {
+ avatarEditorShowUsername.onchange = () => {
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ draft.showUsername = Boolean(avatarEditorShowUsername.checked);
+ };
+ }
+ if (avatarEditorMode) {
+ avatarEditorMode.onchange = () => {
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ draft.mode = String(avatarEditorMode.value || "") === "frame_animation" ? "frame_animation" : "profile_token";
+ renderMapView();
+ };
+ }
+ if (avatarEditorDefaultFps) {
+ avatarEditorDefaultFps.onchange = () => {
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ draft.frameAnimation = draft.frameAnimation || defaultFrameAnimationDraft();
+ draft.frameAnimation.defaultFps = clamp(Number(avatarEditorDefaultFps.value || 8), 1, 24);
+ };
+ }
+ if (avatarEditorStateSelect) {
+ avatarEditorStateSelect.onchange = () => {
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ draft.frameAnimation = draft.frameAnimation || defaultFrameAnimationDraft();
+ draft.frameAnimation.selectedState = String(avatarEditorStateSelect.value || "idle_down");
+ renderMapView();
+ };
+ }
+ if (avatarEditorAddStateBtn) {
+ avatarEditorAddStateBtn.onclick = () => {
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ draft.frameAnimation = draft.frameAnimation || defaultFrameAnimationDraft();
+ const states = draft.frameAnimation.states || {};
+ if (Object.keys(states).length >= 24) {
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Max 24 states.";
+ return;
+ }
+ let i = 1;
+ let key = `state_${i}`;
+ while (states[key]) {
+ i += 1;
+ key = `state_${i}`;
+ }
+ states[key] = { frames: [], fps: draft.frameAnimation.defaultFps || 8, loop: true, flipXWithDirection: true };
+ draft.frameAnimation.states = states;
+ draft.frameAnimation.selectedState = key;
+ renderMapView();
+ };
+ }
+ if (avatarEditorStateLoop) {
+ avatarEditorStateLoop.onchange = () => {
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ const stateName = draft.frameAnimation?.selectedState;
+ const state = stateName ? draft.frameAnimation?.states?.[stateName] : null;
+ if (!state) return;
+ state.loop = Boolean(avatarEditorStateLoop.checked);
+ };
+ }
+ if (avatarEditorStateFlip) {
+ avatarEditorStateFlip.onchange = () => {
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ const stateName = draft.frameAnimation?.selectedState;
+ const state = stateName ? draft.frameAnimation?.states?.[stateName] : null;
+ if (!state) return;
+ state.flipXWithDirection = Boolean(avatarEditorStateFlip.checked);
+ };
+ }
+ if (avatarEditorStateFps) {
+ avatarEditorStateFps.onchange = () => {
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ const stateName = draft.frameAnimation?.selectedState;
+ const state = stateName ? draft.frameAnimation?.states?.[stateName] : null;
+ if (!state) return;
+ state.fps = clamp(Number(avatarEditorStateFps.value || draft.frameAnimation.defaultFps || 8), 1, 24);
+ };
+ }
+ if (avatarEditorRenderScale) {
+ const apply = () => {
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ draft.frameAnimation = draft.frameAnimation || defaultFrameAnimationDraft();
+ const next = clamp(Number(avatarEditorRenderScale.value || 1), 0.25, 4);
+ draft.frameAnimation.renderScale = next;
+ if (avatarEditorRenderScaleVal) avatarEditorRenderScaleVal.textContent = `${next.toFixed(2)}x`;
+ };
+ avatarEditorRenderScale.oninput = apply;
+ avatarEditorRenderScale.onchange = apply;
+ }
+ const applySheetImportSettings = () => {
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ draft.frameAnimation = draft.frameAnimation || defaultFrameAnimationDraft();
+ draft.frameAnimation.sheetImport = {
+ cols: Math.floor(clamp(Number(avatarEditorSheetCols?.value || 4), 1, 32)),
+ rows: Math.floor(clamp(Number(avatarEditorSheetRows?.value || 4), 1, 32)),
+ limit: Math.floor(clamp(Number(avatarEditorSheetLimit?.value || 24), 1, 96))
+ };
+ if (avatarEditorSheetCols) avatarEditorSheetCols.value = String(draft.frameAnimation.sheetImport.cols);
+ if (avatarEditorSheetRows) avatarEditorSheetRows.value = String(draft.frameAnimation.sheetImport.rows);
+ if (avatarEditorSheetLimit) avatarEditorSheetLimit.value = String(draft.frameAnimation.sheetImport.limit);
+ };
+ if (avatarEditorSheetCols) {
+ avatarEditorSheetCols.oninput = applySheetImportSettings;
+ avatarEditorSheetCols.onchange = applySheetImportSettings;
+ }
+ if (avatarEditorSheetRows) {
+ avatarEditorSheetRows.oninput = applySheetImportSettings;
+ avatarEditorSheetRows.onchange = applySheetImportSettings;
+ }
+ if (avatarEditorSheetLimit) {
+ avatarEditorSheetLimit.oninput = applySheetImportSettings;
+ avatarEditorSheetLimit.onchange = applySheetImportSettings;
+ }
+ if (avatarEditorFrameInput) {
+ avatarEditorFrameInput.onchange = async () => {
+ const file = avatarEditorFrameInput.files && avatarEditorFrameInput.files[0];
+ if (!file) return;
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ draft.frameAnimation = draft.frameAnimation || defaultFrameAnimationDraft();
+ const stateName = draft.frameAnimation.selectedState || "idle_down";
+ const state = draft.frameAnimation.states[stateName] || { frames: [], fps: draft.frameAnimation.defaultFps || 8, loop: true, flipXWithDirection: true };
+ state.frames = Array.isArray(state.frames) ? state.frames : [];
+ if (state.frames.length >= 48) {
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Max 48 frames per state.";
+ avatarEditorFrameInput.value = "";
+ return;
+ }
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Uploading frame...";
+ try {
+ const url = await uploadSpriteImageFile(file);
+ state.frames.push({ url });
+ draft.frameAnimation.states[stateName] = state;
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Frame added.";
+ renderMapView();
+ } catch (err) {
+ if (avatarEditorStatus) avatarEditorStatus.textContent = String(err?.message || err);
+ } finally {
+ avatarEditorFrameInput.value = "";
+ }
+ };
+ }
+ if (avatarEditorSheetInput) {
+ avatarEditorSheetInput.onchange = async () => {
+ const file = avatarEditorSheetInput.files && avatarEditorSheetInput.files[0];
+ if (!file) return;
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ draft.frameAnimation = draft.frameAnimation || defaultFrameAnimationDraft();
+ const stateName = draft.frameAnimation.selectedState || "idle_down";
+ const state = draft.frameAnimation.states[stateName] || { frames: [], fps: draft.frameAnimation.defaultFps || 8, loop: true, flipXWithDirection: true };
+ state.frames = Array.isArray(state.frames) ? state.frames : [];
+ applySheetImportSettings();
+ const cols = Math.floor(clamp(Number(draft.frameAnimation.sheetImport?.cols || 4), 1, 32));
+ const rows = Math.floor(clamp(Number(draft.frameAnimation.sheetImport?.rows || 4), 1, 32));
+ const requestedLimit = Math.floor(clamp(Number(draft.frameAnimation.sheetImport?.limit || (cols * rows)), 1, 96));
+ const perStateRoom = Math.max(0, 48 - state.frames.length);
+ const totalRoom = Math.max(0, 220 - countDraftFrames(draft.frameAnimation));
+ const importLimit = Math.min(requestedLimit, perStateRoom, totalRoom);
+ if (importLimit < 1) {
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Frame limit reached.";
+ avatarEditorSheetInput.value = "";
+ return;
+ }
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Uploading sheet...";
+ try {
+ const [{ width, height }, url] = await Promise.all([readImageNaturalSizeFromFile(file), uploadSpriteImageFile(file)]);
+ const frameW = Math.floor(width / cols);
+ const frameH = Math.floor(height / rows);
+ if (frameW < 1 || frameH < 1) throw new Error("Sheet rows/cols are too large for this image.");
+ let added = 0;
+ for (let row = 0; row < rows && added < importLimit; row += 1) {
+ for (let col = 0; col < cols && added < importLimit; col += 1) {
+ state.frames.push({ url, sx: col * frameW, sy: row * frameH, sw: frameW, sh: frameH });
+ added += 1;
+ }
+ }
+ draft.frameAnimation.states[stateName] = state;
+ if (avatarEditorStatus) avatarEditorStatus.textContent = `Imported ${added} frame${added === 1 ? "" : "s"} from sheet.`;
+ renderMapView();
+ } catch (err) {
+ if (avatarEditorStatus) avatarEditorStatus.textContent = String(err?.message || err);
+ } finally {
+ avatarEditorSheetInput.value = "";
+ }
+ };
+ }
+ const frameUpBtns = mapsPanel.querySelectorAll("[data-avatar-frame-up]");
+ frameUpBtns.forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const idx = Number(btn.getAttribute("data-avatar-frame-up") || -1);
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ const stateName = draft.frameAnimation?.selectedState;
+ const state = stateName ? draft.frameAnimation?.states?.[stateName] : null;
+ if (!state || !Array.isArray(state.frames) || idx <= 0 || idx >= state.frames.length) return;
+ const tmp = state.frames[idx - 1];
+ state.frames[idx - 1] = state.frames[idx];
+ state.frames[idx] = tmp;
+ renderMapView();
+ });
+ });
+ const frameDownBtns = mapsPanel.querySelectorAll("[data-avatar-frame-down]");
+ frameDownBtns.forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const idx = Number(btn.getAttribute("data-avatar-frame-down") || -1);
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ const stateName = draft.frameAnimation?.selectedState;
+ const state = stateName ? draft.frameAnimation?.states?.[stateName] : null;
+ if (!state || !Array.isArray(state.frames) || idx < 0 || idx >= state.frames.length - 1) return;
+ const tmp = state.frames[idx + 1];
+ state.frames[idx + 1] = state.frames[idx];
+ state.frames[idx] = tmp;
+ renderMapView();
+ });
+ });
+ const frameRemoveBtns = mapsPanel.querySelectorAll("[data-avatar-frame-remove]");
+ frameRemoveBtns.forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const idx = Number(btn.getAttribute("data-avatar-frame-remove") || -1);
+ const draft = ensureAvatarEditorDraft(meAvatar);
+ const stateName = draft.frameAnimation?.selectedState;
+ const state = stateName ? draft.frameAnimation?.states?.[stateName] : null;
+ if (!state || !Array.isArray(state.frames) || idx < 0 || idx >= state.frames.length) return;
+ state.frames.splice(idx, 1);
+ renderMapView();
+ });
+ });
+ if (avatarEditorSaveBtn) {
+ avatarEditorSaveBtn.onclick = () => {
+ const payload = collectAvatarPayloadFromDraft();
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Saving...";
+ ctx.send("setAvatar", payload);
+ const mine = me ? users.get(me) : null;
+ if (mine) {
+ mine.avatar = payload;
+ users.set(me, mine);
+ }
+ if (avatarStatus) avatarStatus.textContent = "Saved.";
+ if (avatarEditorStatus) avatarEditorStatus.textContent = "Saved.";
+ avatarEditorOpen = false;
+ avatarEditorDraft = cloneAvatarForEditor(payload);
+ renderMapView();
+ };
+ }
+
const invToggle = document.getElementById("mapsInvisibleToggle");
if (invToggle && showSettings) {
invToggle.onchange = () => {
@@ -1064,10 +2497,12 @@
panning = false;
panStart = null;
if (editMode) {
+ gmMode = "polygon";
const list = polysForKind(activeMap, editKind);
editTool = list.length ? "select" : "draw";
devLog("info", "maps:editToggle on", { mapId: activeMap?.id || "", kind: editKind, tool: editTool });
} else {
+ gmMode = "play";
selectedPolyKind = "";
selectedPolyIndex = -1;
selectedVertexIndex = -1;
@@ -1331,7 +2766,6 @@
if (!activeMap?.walkiesEnabled) return;
try {
await startWalkie();
- walkieBtn.textContent = "Recording…";
} catch (err) {
ctx.toast("Walkie", String(err?.message || err));
}
@@ -1339,7 +2773,6 @@
const up = (e) => {
if (e) e.preventDefault();
stopWalkie();
- walkieBtn.textContent = "Hold to talk";
};
walkieBtn.onpointerdown = down;
walkieBtn.onpointerup = up;
@@ -2970,6 +4403,7 @@
}
// Players (draw in world coords -> screen coords)
+ const drawNowMs = Date.now();
for (const [username, u] of users.entries()) {
if (!u) continue;
const rx = typeof u.x === "number" ? u.x : Number(u.tx || 0);
@@ -2982,31 +4416,103 @@
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";
+ const frameRender = resolveAvatarFrame(username, u, drawNowMs);
+ const frameImg = frameRender?.frameUrl ? getFrameImage(frameRender.frameUrl) : null;
+ let avatarHalfHeight = radius;
- // Avatar circle
+ // Avatar render
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) {
+ if (frameRender && frameImg) {
+ const crop = frameRender?.crop || null;
+ const srcW = crop ? Number(crop.sw || 0) : Number(frameImg.naturalWidth || size);
+ const srcH = crop ? Number(crop.sh || 0) : Number(frameImg.naturalHeight || size);
+ const scale = clamp(Number(frameRender?.renderScale || 1), 0.25, 4.0);
+ const drawW = Math.max(1, Math.min(1024, Math.round(srcW * scale)));
+ const drawH = Math.max(1, Math.min(1024, Math.round(srcH * scale)));
+ avatarHalfHeight = Math.floor(drawH / 2);
+ g.save();
+ if (frameRender?.flipX) {
+ g.translate(px, py);
+ g.scale(-1, 1);
+ if (crop) g.drawImage(frameImg, crop.sx, crop.sy, crop.sw, crop.sh, -Math.floor(drawW / 2), -Math.floor(drawH / 2), drawW, drawH);
+ else g.drawImage(frameImg, -Math.floor(drawW / 2), -Math.floor(drawH / 2), drawW, drawH);
+ } else {
+ if (crop) g.drawImage(frameImg, crop.sx, crop.sy, crop.sw, crop.sh, px - Math.floor(drawW / 2), py - Math.floor(drawH / 2), drawW, drawH);
+ else g.drawImage(frameImg, px - Math.floor(drawW / 2), py - Math.floor(drawH / 2), drawW, drawH);
+ }
+ g.restore();
+ } else if (frameRender && !frameImg) {
+ const placeholder = Math.max(10, Math.floor(size * 0.8));
+ avatarHalfHeight = Math.floor(placeholder / 2);
+ g.fillStyle = "rgba(246,240,255,0.18)";
+ roundRect(g, px - placeholder / 2, py - placeholder / 2, placeholder, placeholder, 4);
+ g.fill();
+ } else if (img) {
+ g.save();
+ g.beginPath();
+ g.arc(px, py, radius, 0, Math.PI * 2);
+ g.closePath();
+ g.clip();
g.drawImage(img, px - radius, py - radius, size, size);
+ 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();
} else {
g.fillStyle = color;
g.beginPath();
g.arc(px, py, radius, 0, Math.PI * 2);
g.fill();
+ g.strokeStyle = "rgba(255,255,255,0.28)";
+ g.lineWidth = 2;
+ g.beginPath();
+ g.arc(px, py, radius, 0, Math.PI * 2);
+ g.stroke();
+ }
+
+ const isTypingNow = Number(typingUntil.get(username) || 0) > drawNowMs;
+ if (isTypingNow) {
+ const dots = ".".repeat((Math.floor(drawNowMs / 360) % 3) + 1);
+ const dotY = py - (avatarHalfHeight + 46);
+ g.font = "700 13px system-ui, -apple-system, Segoe UI, sans-serif";
+ g.textAlign = "center";
+ g.fillStyle = "rgba(246,240,255,0.96)";
+ g.shadowColor = "rgba(0,0,0,0.6)";
+ g.shadowBlur = 6;
+ g.shadowOffsetY = 2;
+ g.fillText(dots, px, Math.max(14, dotY));
+ g.shadowBlur = 0;
+ g.shadowOffsetY = 0;
}
- 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 nameText = displayNameForUser(username, u);
+ if (!nameText) {
+ const bNoLabel = bubbles.get(`user:${username}`);
+ if (bNoLabel && bNoLabel.text) {
+ const text = String(bNoLabel.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 - (avatarHalfHeight + 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;
+ }
+ continue;
+ }
const nameColor = normalizeReadableColor(color);
g.font = "700 15px system-ui, -apple-system, Segoe UI, sans-serif";
g.textAlign = "center";
@@ -3014,7 +4520,7 @@
const nameW = Math.ceil(nm.width) + 14;
const nameH = 22;
const nameX = px - nameW / 2;
- const nameY = py - (radius + 30);
+ const nameY = py - (avatarHalfHeight + 30);
const bg = chooseHighlightBg(nameColor);
g.fillStyle = bg;
g.strokeStyle = "rgba(255,255,255,0.10)";
@@ -3036,7 +4542,7 @@
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));
+ const by = Math.max(10, py - (avatarHalfHeight + 64));
g.fillStyle = "rgba(10,9,14,0.88)";
g.strokeStyle = "rgba(246,240,255,0.14)";
roundRect(g, bx, by, tw, th, 12);
@@ -3372,7 +4878,15 @@
function enterMap(mapId) {
mode = "map";
+ applyFocusModeClass();
+ gmMode = "play";
+ inspectorOpen = false;
+ gmOverlayVisible = false;
users.clear();
+ emoteUntil.clear();
+ avatarAnimRuntime.clear();
+ typingUntil.clear();
+ typingOpen = false;
bubbles.clear();
editMode = false;
draftPoly = [];
@@ -3389,6 +4903,7 @@
revealFog = getFogReveal(mapId);
// Seed a known-good local position (will be replaced once we get roomState).
localPos = { x: 0.5, y: 0.5 };
+ mapChatFeed = [];
exitInside.clear();
activeMap =
maps.find((m) => m.id === mapId) || {
@@ -3412,13 +4927,20 @@
walkiesEnabled: false
};
selectedPropId = "";
+ mapsCapabilities = null;
+ clearCapabilitiesRetry();
renderMapView();
ctx.send("join", { mapId });
+ requestCapabilitiesWithRetry("join-request");
}
function leaveMap() {
ctx.send("leave", {});
mode = "maps";
+ applyFocusModeClass();
+ gmMode = "play";
+ inspectorOpen = false;
+ gmOverlayVisible = false;
activeMap = null;
speakingAsPropId = "";
if (appRoot) appRoot.classList.remove("mapsRoom");
@@ -3427,6 +4949,12 @@
stopWalkie();
stopAllWalkies();
users.clear();
+ emoteUntil.clear();
+ avatarAnimRuntime.clear();
+ typingUntil.clear();
+ typingOpen = false;
+ clearCapabilitiesRetry();
+ mapChatFeed = [];
bubbles.clear();
keys.clear();
stopLoop();
@@ -3456,36 +4984,205 @@
ctx.send("deleteMap", { id });
return;
}
+ const focus = e.target.closest("[data-mapfocus]");
+ if (focus) {
+ if (!featureEnabled("focusMode")) return;
+ setFocusMode(!isFocusMode);
+ return;
+ }
+ const gmOverlayBtn = e.target.closest("[data-gm-overlay]");
+ if (gmOverlayBtn) {
+ if (!featureEnabled("gmOverlay")) return;
+ setGmOverlayVisible(!gmOverlayVisible);
+ return;
+ }
+ const gmModeBtn = e.target.closest("[data-gm-mode]");
+ if (gmModeBtn) {
+ const nextMode = String(gmModeBtn.getAttribute("data-gm-mode") || "").trim().toLowerCase();
+ if (nextMode) setGmMode(nextMode);
+ return;
+ }
+ const gmInspectorBtn = e.target.closest("[data-gm-inspector]");
+ if (gmInspectorBtn) {
+ inspectorOpen = !inspectorOpen;
+ renderMapView();
+ return;
+ }
const back = e.target.closest("[data-mapback]");
if (back) {
leaveMap();
return;
}
+ const cinematicBtn = e.target.closest("[data-mapcinematic]");
+ if (cinematicBtn) {
+ setCinematicMode(!cinematicMode);
+ return;
+ }
});
function setChatOverlayOpen(open) {
const overlay = document.getElementById("mapsChatOverlay");
const input = document.getElementById("mapsChatInput");
const send = document.getElementById("mapsChatSend");
+ const closeBtn = document.getElementById("mapsChatClose");
+ const scopeLocalBtn = document.getElementById("mapsChatScopeLocal");
+ const scopeGlobalBtn = document.getElementById("mapsChatScopeGlobal");
+ const opacityRange = document.getElementById("mapsChatOpacity");
+ const resetBtn = document.getElementById("mapsChatReset");
+ const dragHandle = document.getElementById("mapsChatDragHandle");
+ const canvasWrap = document.querySelector(".mapCanvasWrap");
const walkieBar = document.getElementById("mapsWalkieBar");
if (!overlay || !input || !send) return;
+ const clampOverlayToCanvas = () => {
+ if (!overlay || !canvasWrap) return;
+ const parentRect = canvasWrap.getBoundingClientRect();
+ if (parentRect.width <= 0 || parentRect.height <= 0) return;
+ const width = Math.max(180, Math.min(parentRect.width - 16, overlay.offsetWidth || 320));
+ const height = Math.max(56, Math.min(parentRect.height - 16, overlay.offsetHeight || 120));
+ if (overlay.style.right !== "auto") overlay.style.right = "auto";
+ if (!overlay.style.left || overlay.style.left === "auto") overlay.style.left = "12px";
+ if (!overlay.style.top || overlay.style.top === "auto") {
+ const baseBottom = overlay.classList.contains("raiseForHotbar") ? 126 : overlay.classList.contains("raiseForWalkie") ? 68 : 12;
+ overlay.style.top = `${Math.max(8, parentRect.height - height - baseBottom)}px`;
+ }
+ const currentLeft = Number.parseFloat(overlay.style.left || "12");
+ const currentTop = Number.parseFloat(overlay.style.top || "12");
+ const maxLeft = Math.max(8, parentRect.width - width - 8);
+ const maxTop = Math.max(8, parentRect.height - height - 8);
+ const clampedLeft = Math.max(8, Math.min(maxLeft, Number.isFinite(currentLeft) ? currentLeft : 12));
+ const clampedTop = Math.max(8, Math.min(maxTop, Number.isFinite(currentTop) ? currentTop : 12));
+ overlay.style.left = `${Math.round(clampedLeft)}px`;
+ overlay.style.top = `${Math.round(clampedTop)}px`;
+ overlay.style.bottom = "auto";
+ mapChatOverlayPos = { x: Math.round(clampedLeft), y: Math.round(clampedTop) };
+ };
overlay.classList.toggle("hidden", !open);
+ typingOpen = Boolean(open);
if (walkieBar) walkieBar.classList.toggle("hidden", Boolean(open) || !Boolean(activeMap?.walkiesEnabled));
if (open) {
input.value = "";
input.focus();
+ typingLastSentAt = 0;
+ overlay.style.setProperty("--maps-chat-overlay-alpha", String(clamp(mapChatOverlayOpacity, 0.25, 1)));
+ if (mapChatOverlayPos && Number.isFinite(mapChatOverlayPos.x) && Number.isFinite(mapChatOverlayPos.y)) {
+ overlay.style.left = `${Math.round(mapChatOverlayPos.x)}px`;
+ overlay.style.top = `${Math.round(mapChatOverlayPos.y)}px`;
+ overlay.style.right = "auto";
+ overlay.style.bottom = "auto";
+ } else {
+ overlay.style.left = "12px";
+ overlay.style.right = "auto";
+ overlay.style.top = "";
+ overlay.style.bottom = "";
+ }
+ requestAnimationFrame(clampOverlayToCanvas);
+ renderMapChatFeedDom();
} else {
+ if (activeMap?.id) ctx.send("typing", { mapId: activeMap.id, isTyping: false });
input.blur();
+ mapChatOverlayDrag = null;
+ writeMapChatOverlayPrefs();
+ }
+ const renderScopeButtons = () => {
+ if (scopeLocalBtn) scopeLocalBtn.classList.toggle("primary", mapChatScope === "local");
+ if (scopeLocalBtn) scopeLocalBtn.classList.toggle("ghost", mapChatScope !== "local");
+ if (scopeGlobalBtn) scopeGlobalBtn.classList.toggle("primary", mapChatScope === "global");
+ if (scopeGlobalBtn) scopeGlobalBtn.classList.toggle("ghost", mapChatScope !== "global");
+ };
+ renderScopeButtons();
+ if (scopeLocalBtn) {
+ scopeLocalBtn.onclick = () => {
+ mapChatScope = "local";
+ writeMapChatOverlayPrefs();
+ renderScopeButtons();
+ renderMapChatFeedDom();
+ };
}
- send.onclick = () => {
+ if (scopeGlobalBtn) {
+ scopeGlobalBtn.onclick = () => {
+ mapChatScope = "global";
+ writeMapChatOverlayPrefs();
+ renderScopeButtons();
+ renderMapChatFeedDom();
+ };
+ }
+ if (opacityRange) {
+ const applyOpacity = () => {
+ mapChatOverlayOpacity = clamp(Number(opacityRange.value || mapChatOverlayOpacity), 0.25, 1);
+ overlay.style.setProperty("--maps-chat-overlay-alpha", String(mapChatOverlayOpacity));
+ writeMapChatOverlayPrefs();
+ };
+ opacityRange.oninput = applyOpacity;
+ opacityRange.onchange = applyOpacity;
+ }
+ if (resetBtn) {
+ resetBtn.onclick = () => {
+ mapChatOverlayOpacity = 0.92;
+ mapChatOverlayPos = null;
+ if (opacityRange) opacityRange.value = mapChatOverlayOpacity.toFixed(2);
+ overlay.style.setProperty("--maps-chat-overlay-alpha", String(mapChatOverlayOpacity));
+ overlay.style.left = "12px";
+ overlay.style.right = "auto";
+ overlay.style.top = "";
+ overlay.style.bottom = "";
+ requestAnimationFrame(clampOverlayToCanvas);
+ writeMapChatOverlayPrefs();
+ };
+ }
+ if (closeBtn) closeBtn.onclick = () => setChatOverlayOpen(false);
+ if (dragHandle) {
+ const onPointerMove = (ev) => {
+ if (!mapChatOverlayDrag) return;
+ const parentRect = mapChatOverlayDrag.parentRect;
+ const overlayRect = overlay.getBoundingClientRect();
+ const maxLeft = Math.max(8, parentRect.width - overlayRect.width - 8);
+ const maxTop = Math.max(8, parentRect.height - overlayRect.height - 8);
+ const nx = Math.max(8, Math.min(maxLeft, mapChatOverlayDrag.startLeft + (ev.clientX - mapChatOverlayDrag.startX)));
+ const ny = Math.max(8, Math.min(maxTop, mapChatOverlayDrag.startTop + (ev.clientY - mapChatOverlayDrag.startY)));
+ overlay.style.left = `${Math.round(nx)}px`;
+ overlay.style.right = "auto";
+ overlay.style.top = `${Math.round(ny)}px`;
+ overlay.style.bottom = "auto";
+ mapChatOverlayPos = { x: Math.round(nx), y: Math.round(ny) };
+ };
+ const onPointerUp = () => {
+ if (!mapChatOverlayDrag) return;
+ mapChatOverlayDrag = null;
+ clampOverlayToCanvas();
+ writeMapChatOverlayPrefs();
+ window.removeEventListener("pointermove", onPointerMove);
+ window.removeEventListener("pointerup", onPointerUp);
+ };
+ dragHandle.onpointerdown = (ev) => {
+ if (!canvasWrap) return;
+ const rect = overlay.getBoundingClientRect();
+ const parentRect = canvasWrap.getBoundingClientRect();
+ mapChatOverlayDrag = {
+ startX: ev.clientX,
+ startY: ev.clientY,
+ startLeft: rect.left - parentRect.left,
+ startTop: rect.top - parentRect.top,
+ parentRect
+ };
+ window.addEventListener("pointermove", onPointerMove);
+ window.addEventListener("pointerup", onPointerUp);
+ };
+ }
+ const submitChat = () => {
const text = String(input.value || "").trim();
if (!text) return;
+ if (activeMap?.id) ctx.send("typing", { mapId: activeMap.id, isTyping: false });
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);
+ ctx.send("chatSend", { mapId: activeMap?.id || "", text, scope: mapChatScope });
+ ctx.send("say", { text, actorPropId, scope: mapChatScope });
+ input.value = "";
+ input.focus();
+ };
+ send.onclick = () => {
+ submitChat();
};
input.onkeydown = (ev) => {
if (ev.key === "Escape") {
@@ -3494,24 +5191,97 @@
}
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);
+ submitChat();
+ }
+ };
+ input.oninput = () => {
+ if (!activeMap?.id || !typingOpen) return;
+ const text = String(input.value || "");
+ const now = Date.now();
+ const wantsTyping = text.trim().length > 0;
+ if (!wantsTyping) {
+ ctx.send("typing", { mapId: activeMap.id, isTyping: false });
+ typingLastSentAt = now;
+ return;
}
+ if (now - typingLastSentAt < 600) return;
+ typingLastSentAt = now;
+ ctx.send("typing", { mapId: activeMap.id, isTyping: true });
};
}
+ function shouldDeferMapRerenderForChat() {
+ if (!typingOpen) return false;
+ const overlay = document.getElementById("mapsChatOverlay");
+ return Boolean(overlay && !overlay.classList.contains("hidden"));
+ }
+
window.addEventListener("keydown", (e) => {
if (mode !== "map") return;
// This is a user gesture; try to unlock audio playback early.
ensureAudioReady();
+ const focusSupported = featureEnabled("focusMode");
+ const gmOverlaySupported = featureEnabled("gmOverlay");
const overlay = document.getElementById("mapsChatOverlay");
const overlayOpen = overlay && !overlay.classList.contains("hidden");
+ const editingText = isTextEditingElement(document.activeElement);
+ if (editingText) return;
+ if (!overlayOpen && !editMode && e.altKey && !e.ctrlKey && !e.metaKey && /^Digit[1-5]$/.test(e.code)) {
+ e.preventDefault();
+ const idx = Number(String(e.code).replace("Digit", "")) - 1;
+ if (idx >= 0) ctx.send("avatarEmote", { mapId: activeMap?.id || "", index: idx });
+ return;
+ }
+ if (!editingText && !overlayOpen && !e.altKey && !e.ctrlKey && !e.metaKey) {
+ if (!gmOverlaySupported || !gmOverlayVisible) {
+ // With overlay hidden, reserve number keys for normal typing/navigation.
+ } else if (e.code === "Digit1") {
+ e.preventDefault();
+ setGmMode("play");
+ return;
+ } else if (e.code === "Digit2") {
+ e.preventDefault();
+ setGmMode("select");
+ return;
+ } else if (e.code === "Digit3") {
+ e.preventDefault();
+ setGmMode("place");
+ return;
+ } else if (e.code === "Digit4") {
+ e.preventDefault();
+ setGmMode("polygon");
+ return;
+ }
+ }
+ if (!overlayOpen && gmOverlaySupported && gmOverlayVisible && (e.key === "/" || ((e.ctrlKey || e.metaKey) && e.code === "KeyK"))) {
+ e.preventDefault();
+ const now = Date.now();
+ if (now - lastPaletteToastAt > 1200) {
+ lastPaletteToastAt = now;
+ ctx.toast("Maps", "Command palette coming soon.");
+ }
+ return;
+ }
+ if (focusSupported && e.code === "KeyF") {
+ e.preventDefault();
+ setFocusMode(!isFocusMode);
+ return;
+ }
+ if (!overlayOpen && !editMode && !e.altKey && !e.ctrlKey && !e.metaKey && e.code === "KeyC") {
+ e.preventDefault();
+ setCinematicMode(!cinematicMode);
+ return;
+ }
+ if (!overlayOpen && !editMode && !e.altKey && !e.ctrlKey && !e.metaKey && e.code === "KeyG" && gmOverlaySupported && canManageTtrpg) {
+ e.preventDefault();
+ setGmOverlayVisible(!gmOverlayVisible);
+ return;
+ }
+ if (focusSupported && !overlayOpen && !editMode && e.key === "Escape" && isFocusMode) {
+ e.preventDefault();
+ setFocusMode(false);
+ return;
+ }
if (editMode) {
if (e.key === "Escape") {
draftPoly = [];
@@ -3591,36 +5361,37 @@
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) {
+ if (e.code === "KeyT" && !overlayOpen && !cinematicMode) {
e.preventDefault();
setChatOverlayOpen(true);
return;
}
- if (!overlayOpen && !editMode && activeMap?.ttrpgEnabled && canManageTtrpg) {
+ if (!overlayOpen && !editMode && gmOverlaySupported && gmOverlayVisible && activeMap?.ttrpgEnabled && canManageTtrpg) {
if (e.code === "KeyV") {
e.preventDefault();
ttrpgTool = "select";
+ gmMode = "select";
renderMapView();
return;
}
if (e.code === "KeyP") {
e.preventDefault();
ttrpgTool = "place";
+ gmMode = "place";
renderMapView();
return;
}
if (e.code === "Space") {
e.preventDefault();
ttrpgTool = "pan";
+ gmMode = "play";
renderMapView();
return;
}
}
- if (!overlayOpen && !editMode && activeMap?.ttrpgEnabled && canManageTtrpg && (e.code === "KeyQ" || e.code === "KeyE")) {
+ if (!overlayOpen && !editMode && gmOverlaySupported && gmOverlayVisible && activeMap?.ttrpgEnabled && canManageTtrpg && (e.code === "KeyQ" || e.code === "KeyE")) {
e.preventDefault();
const step = e.shiftKey ? 45 : 15;
const dir = e.code === "KeyQ" ? -1 : 1;
@@ -3647,7 +5418,7 @@
return;
}
}
- if (!overlayOpen && !editMode && activeMap?.ttrpgEnabled && canManageTtrpg && (e.code === "KeyZ" || e.code === "KeyX")) {
+ if (!overlayOpen && !editMode && gmOverlaySupported && gmOverlayVisible && 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;
@@ -3676,14 +5447,14 @@
window.addEventListener("keyup", (e) => {
if (mode !== "map") return;
keys.delete(e.code);
+ if (isTextEditingElement(document.activeElement)) return;
if (activeMap?.ttrpgEnabled && canManageTtrpg && e.code === "Space" && ttrpgTool === "pan") {
ttrpgTool = "select";
+ gmMode = "select";
renderMapView();
}
if (activeMap?.walkiesEnabled && e.code === "Backquote") {
stopWalkie();
- const btn = document.getElementById("mapsWalkieBtn");
- if (btn) btn.textContent = "Hold to talk";
}
});
@@ -3699,10 +5470,65 @@
if (type === "plugin:maps:mapsList") {
maps = Array.isArray(msg.maps) ? msg.maps : [];
+ if (mode === "map" && activeMap?.id) {
+ const current = maps.find((m) => String(m.id || "").trim().toLowerCase() === String(activeMap.id || ""));
+ if (current) {
+ activeMap.userCount = Number(current.userCount || 0) || 0;
+ activeMap.live = Boolean(current.live);
+ activeMap.lastActiveAt = Number(current.lastActiveAt || 0) || 0;
+ }
+ }
if (mode === "maps") renderMapsList();
return;
}
+ if (type === "plugin:maps:capabilities") {
+ mapsCapabilities = msg && typeof msg === "object" ? msg : null;
+ clearCapabilitiesRetry();
+ if (!featureEnabled("gmOverlay")) {
+ gmOverlayVisible = false;
+ inspectorOpen = false;
+ gmMode = "play";
+ editMode = false;
+ }
+ if (!featureEnabled("focusMode")) {
+ isFocusMode = false;
+ applyFocusModeClass();
+ }
+ devLog("info", "maps:capabilities", mapsCapabilities);
+ if (mode === "map" && activeMap) renderMapView();
+ return;
+ }
+
+ if (type === "plugin:maps:avatarPresets") {
+ avatarPresets = normalizeAvatarPresetList(msg?.presets);
+ avatarPresetsCanManage = Boolean(msg?.canManage);
+ if (!avatarPresets.some((preset) => preset.id === avatarPresetSelectedId)) {
+ avatarPresetSelectedId = "";
+ }
+ if (mode === "map" && avatarEditorOpen) renderMapView();
+ return;
+ }
+
+ if (type === "plugin:maps:avatarPresetsUpdated") {
+ ctx.send("listAvatarPresets", {});
+ return;
+ }
+
+ if (type === "plugin:maps:avatarSet") {
+ const me = String(ctx.getUser() || "").trim().toLowerCase();
+ if (!me) return;
+ const mine = users.get(me);
+ const normalized = normalizeAvatarState(msg?.avatar || null);
+ if (mine) {
+ mine.avatar = normalized;
+ users.set(me, mine);
+ }
+ avatarEditorDraft = cloneAvatarForEditor(normalized);
+ if (mode === "map") renderMapView();
+ return;
+ }
+
if (type === "plugin:maps:joinOk") {
self = String(ctx.getUser() || "").trim().toLowerCase();
selfInvisible = Boolean(msg.selfInvisible);
@@ -3724,7 +5550,10 @@
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)
+ walkiesEnabled: Boolean(msg.map.walkiesEnabled),
+ userCount: Number(msg?.presence?.userCount || 0) || 0,
+ live: Boolean(msg?.presence?.live),
+ lastActiveAt: Number(msg?.presence?.lastActiveAt || 0) || 0
};
ttrpgDockCollapsed = readDockCollapsed(activeMap.id);
revealFog = getFogReveal(activeMap.id);
@@ -3746,6 +5575,8 @@
}
renderMapView();
}
+ if (activeMap?.id) ctx.send("chatHistoryReq", { mapId: activeMap.id });
+ requestCapabilitiesWithRetry("join-ok");
return;
}
@@ -3764,7 +5595,7 @@
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();
+ if (!shouldDeferMapRerenderForChat()) renderMapView();
return;
}
@@ -3773,7 +5604,7 @@
const mapId = String(msg.mapId || "").trim().toLowerCase();
if (!activeMap || mapId !== String(activeMap.id || "")) return;
selfInvisible = Boolean(msg.invisible);
- renderMapView();
+ if (!shouldDeferMapRerenderForChat()) renderMapView();
return;
}
@@ -3786,7 +5617,7 @@
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: "" };
+ const prev = users.get(name) || { x: tx, y: ty, tx, ty, color: "", image: "", avatar: null };
prev.tx = tx;
prev.ty = ty;
// Initialize render position on first sight.
@@ -3796,9 +5627,21 @@
}
prev.color = raw?.color || prev.color || "";
prev.image = raw?.image || prev.image || "";
+ prev.avatar = normalizeAvatarState(raw?.avatar || prev.avatar || null);
next.set(name, prev);
}
users = next;
+ typingUntil.clear();
+ for (const rawName of Array.isArray(msg.typingUsers) ? msg.typingUsers : []) {
+ const name = String(rawName || "").trim().toLowerCase();
+ if (!name) continue;
+ typingUntil.set(name, Date.now() + 4500);
+ }
+ if (activeMap && msg.presence && typeof msg.presence === "object") {
+ activeMap.userCount = Number(msg.presence.userCount || users.size) || users.size;
+ activeMap.live = Boolean(msg.presence.live);
+ activeMap.lastActiveAt = Number(msg.presence.lastActiveAt || 0) || 0;
+ }
const me = (self || String(ctx.getUser() || "")).trim().toLowerCase();
const mine = me ? users.get(me) : null;
if (mine) {
@@ -3809,6 +5652,69 @@
return;
}
+ if (type === "plugin:maps:chatHistory") {
+ if (mode !== "map") return;
+ const mapId = String(msg.mapId || "").trim().toLowerCase();
+ if (!activeMap || mapId !== String(activeMap.id || "")) return;
+ const scope = String(msg.scope || "local").trim().toLowerCase();
+ if (scope === "global") {
+ replaceMapChatGlobalHistory(Array.isArray(msg.messages) ? msg.messages : []);
+ renderMapChatFeedDom();
+ }
+ return;
+ }
+
+ if (type === "plugin:maps:chatMessage") {
+ if (mode !== "map") return;
+ const mapId = String(msg.mapId || "").trim().toLowerCase();
+ if (!activeMap || mapId !== String(activeMap.id || "")) return;
+ pushMapChatFeedEntry(msg.scope, msg.message);
+ renderMapChatFeedDom();
+ return;
+ }
+
+ if (type === "plugin:maps:typing") {
+ if (mode !== "map") return;
+ const mapId = String(msg.mapId || "").trim().toLowerCase();
+ if (!activeMap || mapId !== String(activeMap.id || "")) return;
+ const username = String(msg.username || "").trim().toLowerCase();
+ if (!username) return;
+ if (Boolean(msg.isTyping)) {
+ const expiresAt = Number(msg.expiresAt || 0) || Date.now() + 4500;
+ typingUntil.set(username, expiresAt);
+ } else {
+ typingUntil.delete(username);
+ }
+ renderMapView();
+ return;
+ }
+
+ if (type === "plugin:maps:avatarEmote") {
+ if (mode !== "map") return;
+ const mapId = String(msg.mapId || "").trim().toLowerCase();
+ if (!activeMap || mapId !== String(activeMap.id || "")) return;
+ const username = String(msg.username || "").trim().toLowerCase();
+ const state = String(msg.state || "").trim();
+ const until = Number(msg.until || 0) || 0;
+ if (!username || !state || !until) return;
+ emoteUntil.set(username, { state, until, loop: Boolean(msg.loop) });
+ renderMapView();
+ return;
+ }
+
+ if (type === "plugin:maps:avatarChanged") {
+ if (mode !== "map") return;
+ const mapId = String(msg.mapId || "").trim().toLowerCase();
+ if (!activeMap || mapId !== String(activeMap.id || "")) return;
+ const username = String(msg.username || "").trim().toLowerCase();
+ if (!username) return;
+ const prev = users.get(username) || { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, color: "", image: "", avatar: null };
+ prev.avatar = normalizeAvatarState(msg?.avatar || prev.avatar || null);
+ users.set(username, prev);
+ renderMapView();
+ return;
+ }
+
if (type === "plugin:maps:userMoved") {
if (mode !== "map") return;
const username = String(msg.username || "").toLowerCase();
@@ -3818,7 +5724,7 @@
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: "" };
+ const prev = users.get(username) || { x: tx, y: ty, tx, ty, color: "", image: "", avatar: null };
prev.tx = tx;
prev.ty = ty;
if (typeof prev.x !== "number" || typeof prev.y !== "number") {
@@ -3993,3 +5899,4 @@
});
})();
+
diff --git a/plugins_dev/maps/server.js b/plugins_dev/maps/server.js
@@ -32,12 +32,18 @@ module.exports = function init(api) {
const DATA_DIR = path.join(process.cwd(), "data", "plugin-data");
const MAPS_FILE = path.join(DATA_DIR, "maps.json");
+ const AVATAR_PREFS_FILE = path.join(DATA_DIR, "maps-avatar-prefs.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 {Array<{id:string,name:string,description:string,tags:string[],mode:string,avatar:any,createdBy:string,updatedBy:string,createdAt:number,updatedAt:number,published:boolean}>} */
+ let avatarPresets = [];
+ /** @type {Map<string, {mode:"profile_token",displayName:string,showUsername:boolean}>} */
+ let avatarPrefsByUser = new Map();
- /** @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}>}>} */
+ /** @type {Map<string, {users: Map<string, {x:number,y:number,color:string,image:string,invisible?:boolean,seq?:number}>, lastListAt:number, lastActiveAt:number, typing?: Map<string, number>, walkies?: Map<string, {url:string, pending:Set<string>, createdAt:number, mapId:string, timeout?:NodeJS.Timeout}>, chatGlobal?: Array<{id:string,fromUser:string,text:string,createdAt:number}>}>} */
const rooms = new Map();
+ const avatarSnapshotNeededByUser = new Set();
function normId(raw) {
const s = typeof raw === "string" ? raw.trim().toLowerCase() : "";
@@ -91,6 +97,63 @@ module.exports = function init(api) {
}
}
+ const walkieTelemetry = {
+ counters: new Map(),
+ lastFlushAt: 0
+ };
+
+ function walkieMetricKey(stage, mapId) {
+ return `${String(stage || "unknown")}:${String(mapId || "_")}`;
+ }
+
+ function noteWalkie(stage, mapId, extra) {
+ const key = walkieMetricKey(stage, mapId);
+ walkieTelemetry.counters.set(key, Number(walkieTelemetry.counters.get(key) || 0) + 1);
+ const now = api.now();
+ if (now - walkieTelemetry.lastFlushAt < 60_000) return;
+ walkieTelemetry.lastFlushAt = now;
+ const snapshot = {};
+ for (const [k, v] of walkieTelemetry.counters.entries()) snapshot[k] = v;
+ if (extra && typeof extra === "object") {
+ console.info("[maps/walkie]", stage, { mapId, ...extra, counters: snapshot });
+ return;
+ }
+ console.info("[maps/walkie]", stage, { mapId, counters: snapshot });
+ }
+
+ function dropWalkiePendingForUser(room, username, reason) {
+ if (!room || !room.walkies || !username) return;
+ for (const [walkieId, entry] of room.walkies.entries()) {
+ if (!entry?.pending || !entry.pending.has(username)) continue;
+ entry.pending.delete(username);
+ noteWalkie("pending-drop", entry.mapId || "", { walkieId, reason });
+ if (entry.pending.size === 0) {
+ cleanupWalkieEntry(room, walkieId, "cleanup-all-acked", { reason });
+ }
+ }
+ }
+
+ function clearRoomWalkies(room, reason) {
+ if (!room || !room.walkies) return;
+ for (const walkieId of room.walkies.keys()) cleanupWalkieEntry(room, walkieId, "cleanup-room-clear", { reason });
+ }
+
+ function cleanupWalkieEntry(room, walkieId, stage, extra) {
+ if (!room?.walkies) return;
+ const entry = room.walkies.get(walkieId);
+ if (!entry) return;
+ if (entry.timeout) {
+ try {
+ clearTimeout(entry.timeout);
+ } catch {
+ // ignore
+ }
+ }
+ room.walkies.delete(walkieId);
+ tryDeleteUploadSoon(entry.url, entry.createdAt);
+ noteWalkie(stage || "cleanup", entry.mapId || "", { walkieId, ...(extra || {}) });
+ }
+
function normalizePolyList(list) {
const input = Array.isArray(list) ? list : [];
@@ -239,11 +302,37 @@ module.exports = function init(api) {
function canManageMaps(ws, map) {
const role = String(ws?.user?.role || "").toLowerCase();
const username = userIdentity(ws);
- if (role === "owner" || role === "moderator") return true;
+ if (role === "owner" || role === "admin" || role === "moderator") return true;
if (map && username && map.owner && username === map.owner) return true;
return false;
}
+ function canManageAvatarPresets(ws) {
+ const role = String(ws?.user?.role || "").toLowerCase();
+ return role === "owner" || role === "admin" || role === "moderator";
+ }
+
+ function normalizePresetName(value) {
+ return String(value || "").replace(/\s+/g, " ").trim().slice(0, 40);
+ }
+
+ function normalizePresetDescription(value) {
+ return String(value || "").replace(/\s+/g, " ").trim().slice(0, 140);
+ }
+
+ function normalizePresetTags(list) {
+ const src = Array.isArray(list) ? list : [];
+ const out = [];
+ const seen = new Set();
+ for (const raw of src.slice(0, 12)) {
+ const tag = String(raw || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "").slice(0, 24);
+ if (!tag || seen.has(tag)) continue;
+ seen.add(tag);
+ out.push(tag);
+ }
+ return out;
+ }
+
function clamp01(n) {
const x = Number(n);
if (!Number.isFinite(x)) return 0;
@@ -309,10 +398,254 @@ module.exports = function init(api) {
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: [] });
+ if (!rooms.has(mid)) rooms.set(mid, { users: new Map(), lastListAt: 0, lastActiveAt: 0, typing: new Map(), walkies: new Map(), chatGlobal: [] });
return rooms.get(mid) || null;
}
+ function touchRoomActivity(mapId) {
+ const room = roomFor(mapId);
+ if (!room) return;
+ room.lastActiveAt = api.now();
+ broadcastMapsListThrottled();
+ }
+
+ function mapsCapabilities(ws = null) {
+ return {
+ type: "plugin:maps:capabilities",
+ version: "0.4.0",
+ emittedAt: api.now(),
+ mapId: normId(ws?.__mapsRoomId || ""),
+ features: {
+ focusMode: true,
+ gmOverlay: true,
+ avatarModes: ["profile_token", "frame_animation"],
+ avatarPresets: true,
+ walkieV2: true,
+ spatialStreamAudio: false,
+ undoRedo: false
+ }
+ };
+ }
+
+ function sanitizeDisplayName(name) {
+ const raw = typeof name === "string" ? name : "";
+ return raw.replace(/\s+/g, " ").trim().slice(0, 32);
+ }
+
+ function sanitizeFrameStateName(name) {
+ const raw = typeof name === "string" ? name.trim() : "";
+ if (!raw) return "";
+ if (!/^[a-z][a-z0-9_]{0,31}$/i.test(raw)) return "";
+ return raw;
+ }
+
+ function sanitizeHotkeyName(name) {
+ const raw = typeof name === "string" ? name.trim() : "";
+ if (!raw) return "";
+ if (!/^(Digit[0-9]|Key[A-Z])$/.test(raw)) return "";
+ return raw;
+ }
+
+ function normalizeFrameAnimation(raw) {
+ const input = raw && typeof raw === "object" ? raw : {};
+ const defaultFps = clampInt(input.defaultFps, 1, 24);
+ const renderScale = clampFloat(input.renderScale, 0.25, 4.0, 1.0);
+ const statesIn = input.states && typeof input.states === "object" ? input.states : {};
+ const states = {};
+ let totalFrames = 0;
+ const MAX_STATES = 24;
+ const MAX_FRAMES_PER_STATE = 48;
+ const MAX_TOTAL_FRAMES = 220;
+ for (const [stateRaw, defRaw] of Object.entries(statesIn).slice(0, MAX_STATES)) {
+ const state = sanitizeFrameStateName(stateRaw);
+ if (!state) continue;
+ const def = defRaw && typeof defRaw === "object" ? defRaw : {};
+ const framesIn = Array.isArray(def.frames) ? def.frames : [];
+ const frames = [];
+ for (const frameRaw of framesIn.slice(0, MAX_FRAMES_PER_STATE)) {
+ const frameUrl = typeof frameRaw?.url === "string" ? frameRaw.url.trim() : "";
+ if (!frameUrl || frameUrl.length > 240) continue;
+ if (!isSafeImageUrl(frameUrl)) continue;
+ const sx = clampInt(frameRaw?.sx, 0, 8192);
+ const sy = clampInt(frameRaw?.sy, 0, 8192);
+ const sw = clampInt(frameRaw?.sw, 1, 8192);
+ const sh = clampInt(frameRaw?.sh, 1, 8192);
+ const hasCrop =
+ Number.isFinite(Number(frameRaw?.sx)) &&
+ Number.isFinite(Number(frameRaw?.sy)) &&
+ Number.isFinite(Number(frameRaw?.sw)) &&
+ Number.isFinite(Number(frameRaw?.sh));
+ frames.push(hasCrop ? { url: frameUrl, sx, sy, sw, sh } : { url: frameUrl });
+ totalFrames += 1;
+ if (totalFrames >= MAX_TOTAL_FRAMES) break;
+ }
+ if (!frames.length) continue;
+ states[state] = {
+ frames,
+ fps: clampInt(def.fps, 1, 24),
+ loop: Object.prototype.hasOwnProperty.call(def, "loop") ? Boolean(def.loop) : true,
+ flipXWithDirection: Object.prototype.hasOwnProperty.call(def, "flipXWithDirection") ? Boolean(def.flipXWithDirection) : true
+ };
+ if (totalFrames >= MAX_TOTAL_FRAMES) break;
+ }
+ const movementMapIn = input.movementMap && typeof input.movementMap === "object" ? input.movementMap : {};
+ const movementMap = {};
+ const moveKeys = ["idle", "idleUp", "idleDown", "walkVertical", "walkHorizontal", "walkUp", "walkDown", "walkLeft", "walkRight"];
+ for (const key of moveKeys) {
+ const state = sanitizeFrameStateName(movementMapIn[key]);
+ if (state && states[state]) movementMap[key] = state;
+ }
+ const emotesIn = Array.isArray(input.emotes) ? input.emotes : [];
+ const emotes = [];
+ for (const emoteRaw of emotesIn.slice(0, 16)) {
+ const emote = emoteRaw && typeof emoteRaw === "object" ? emoteRaw : {};
+ const name = sanitizeFrameStateName(emote.name);
+ const state = sanitizeFrameStateName(emote.state);
+ if (!name || !state || !states[state]) continue;
+ emotes.push({
+ name,
+ state,
+ hotkey: sanitizeHotkeyName(emote.hotkey),
+ loop: Object.prototype.hasOwnProperty.call(emote, "loop") ? Boolean(emote.loop) : false,
+ interruptible: Object.prototype.hasOwnProperty.call(emote, "interruptible") ? Boolean(emote.interruptible) : true
+ });
+ }
+ if (!Object.keys(states).length) return null;
+ return { defaultFps, renderScale, states, movementMap, emotes };
+ }
+
+ function estimateEmoteDurationMs(frameAnimation, emoteState) {
+ const anim = frameAnimation && typeof frameAnimation === "object" ? frameAnimation : null;
+ if (!anim) return 1000;
+ const states = anim.states && typeof anim.states === "object" ? anim.states : {};
+ const state = states[emoteState] && typeof states[emoteState] === "object" ? states[emoteState] : null;
+ if (!state) return 1000;
+ if (state.loop) return 1200;
+ const frames = Array.isArray(state.frames) ? state.frames.length : 0;
+ const fps = clampInt(state.fps || anim.defaultFps || 8, 1, 24);
+ const raw = Math.round((Math.max(1, frames) / Math.max(1, fps)) * 1000);
+ return Math.max(320, Math.min(4000, raw));
+ }
+
+ function resolveAvatarEmote(pref, msg) {
+ if (!pref || pref.mode !== "frame_animation" || !pref.frameAnimation) return null;
+ const anim = pref.frameAnimation;
+ const emotes = Array.isArray(anim.emotes) ? anim.emotes : [];
+ if (!emotes.length) return null;
+ const nameRaw = typeof msg?.name === "string" ? msg.name.trim().toLowerCase() : "";
+ const idxRaw = Number(msg?.index);
+ let emote = null;
+ if (nameRaw) {
+ emote = emotes.find((e) => String(e?.name || "").toLowerCase() === nameRaw) || null;
+ } else if (Number.isFinite(idxRaw) && idxRaw >= 0 && idxRaw < emotes.length) {
+ emote = emotes[Math.floor(idxRaw)] || null;
+ }
+ if (!emote) return null;
+ const state = sanitizeFrameStateName(emote.state);
+ if (!state) return null;
+ const durationMs = estimateEmoteDurationMs(anim, state);
+ return { name: emote.name, state, loop: Boolean(emote.loop), durationMs };
+ }
+
+ function normalizeAvatarPref(raw) {
+ const rawMode = String(raw?.mode || "profile_token").trim();
+ const mode = rawMode === "frame_animation" ? "frame_animation" : "profile_token";
+ const displayName = sanitizeDisplayName(raw?.displayName);
+ const showUsername = raw && Object.prototype.hasOwnProperty.call(raw, "showUsername") ? Boolean(raw.showUsername) : true;
+ const frameAnimation = mode === "frame_animation" ? normalizeFrameAnimation(raw?.frameAnimation) : null;
+ return {
+ mode: frameAnimation ? "frame_animation" : "profile_token",
+ displayName,
+ showUsername,
+ frameAnimation: frameAnimation || null
+ };
+ }
+
+ function loadAvatarPrefsFromDisk() {
+ try {
+ fs.mkdirSync(DATA_DIR, { recursive: true });
+ if (!fs.existsSync(AVATAR_PREFS_FILE)) {
+ avatarPrefsByUser = new Map();
+ return;
+ }
+ const raw = fs.readFileSync(AVATAR_PREFS_FILE, "utf8");
+ const json = JSON.parse(raw);
+ const users = json && typeof json === "object" ? json.users : null;
+ const next = new Map();
+ if (users && typeof users === "object") {
+ for (const [usernameRaw, prefRaw] of Object.entries(users)) {
+ const username = normId(usernameRaw);
+ if (!username) continue;
+ next.set(username, normalizeAvatarPref(prefRaw));
+ }
+ }
+ avatarPrefsByUser = next;
+ } catch (e) {
+ console.warn("Maps plugin: failed to load avatar prefs:", e?.message || e);
+ avatarPrefsByUser = new Map();
+ }
+ }
+
+ function saveAvatarPrefsToDisk() {
+ fs.mkdirSync(DATA_DIR, { recursive: true });
+ const users = {};
+ for (const [username, pref] of avatarPrefsByUser.entries()) users[username] = normalizeAvatarPref(pref);
+ fs.writeFileSync(AVATAR_PREFS_FILE, JSON.stringify({ users }, null, 2));
+ }
+
+ function getAvatarPref(username) {
+ const key = normId(username);
+ if (!key) return normalizeAvatarPref(null);
+ return normalizeAvatarPref(avatarPrefsByUser.get(key));
+ }
+
+ function normalizeAvatarPreset(raw, actor = "") {
+ const name = normalizePresetName(raw?.name || "");
+ if (!name) return null;
+ const idRaw = typeof raw?.id === "string" ? normId(raw.id) : "";
+ const avatar = normalizeAvatarPref(raw?.avatar || {});
+ const now = api.now();
+ return {
+ id: idRaw || randId("preset"),
+ name,
+ description: normalizePresetDescription(raw?.description || ""),
+ tags: normalizePresetTags(raw?.tags),
+ mode: avatar.mode,
+ avatar: {
+ mode: avatar.mode,
+ frameAnimation: avatar.frameAnimation || null
+ },
+ createdBy: normId(raw?.createdBy || actor || ""),
+ updatedBy: normId(actor || raw?.updatedBy || ""),
+ createdAt: clampInt(raw?.createdAt || now, 0, now + 365 * 24 * 60 * 60 * 1000),
+ updatedAt: clampInt(now, 0, now + 365 * 24 * 60 * 60 * 1000),
+ published: Boolean(raw?.published)
+ };
+ }
+
+ function presetMetaPayload(preset) {
+ return {
+ id: preset.id,
+ name: preset.name,
+ description: preset.description || "",
+ tags: Array.isArray(preset.tags) ? preset.tags : [],
+ mode: preset.mode || "profile_token",
+ createdBy: preset.createdBy || "",
+ updatedBy: preset.updatedBy || "",
+ createdAt: Number(preset.createdAt || 0) || 0,
+ updatedAt: Number(preset.updatedAt || 0) || 0,
+ published: Boolean(preset.published)
+ };
+ }
+
+ function sendAvatarPresets(ws) {
+ const canManage = canManageAvatarPresets(ws);
+ const presets = avatarPresets
+ .filter((preset) => canManage || Boolean(preset.published))
+ .map((preset) => (canManage ? { ...presetMetaPayload(preset), avatar: preset.avatar } : presetMetaPayload(preset)));
+ ws.send(JSON.stringify({ type: "plugin:maps:avatarPresets", presets, canManage }));
+ }
+
function sanitizeMapChatText(text) {
const raw = typeof text === "string" ? text : "";
return raw.replace(/\s+/g, " ").trim().slice(0, 420);
@@ -330,10 +663,13 @@ module.exports = function init(api) {
}
function listMapsPayload() {
+ const t = api.now();
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;
+ const lastActiveAt = Number(room?.lastActiveAt || 0) || 0;
+ const live = count > 0 && t - lastActiveAt <= 60_000;
return {
id: m.id,
title: m.title,
@@ -350,7 +686,9 @@ module.exports = function init(api) {
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
+ userCount: count,
+ live,
+ lastActiveAt
};
});
}
@@ -384,16 +722,30 @@ module.exports = function init(api) {
const all = Array.from(room.users.entries());
const recipients = usersInRoom(mid);
for (const recipient of recipients) {
+ const includeAvatarSnapshot = avatarSnapshotNeededByUser.has(recipient);
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 });
+ .map(([username, u]) => {
+ const base = {
+ username,
+ x: u.x,
+ y: u.y,
+ color: u.color || "",
+ image: u.image || ""
+ };
+ if (includeAvatarSnapshot) {
+ base.avatar = normalizeAvatarPref(u?.avatar || getAvatarPref(username));
+ }
+ return base;
+ });
+ const now = api.now();
+ const typingUsers = Array.from(room.typing?.entries() || [])
+ .filter(([name, until]) => name !== recipient && Number(until || 0) > now)
+ .map(([name]) => name);
+ const visibleCount = all.filter(([, u]) => !u?.invisible).length;
+ const presence = { userCount: visibleCount, live: visibleCount > 0 && now - Number(room.lastActiveAt || 0) <= 60_000, lastActiveAt: Number(room.lastActiveAt || 0) || 0 };
+ api.sendToUsers([recipient], { type: "plugin:maps:roomState", mapId: mid, users, typingUsers, presence });
+ if (includeAvatarSnapshot) avatarSnapshotNeededByUser.delete(recipient);
}
broadcastMapsListThrottled();
}
@@ -410,11 +762,16 @@ module.exports = function init(api) {
ws.__mapsSpeakAsPropId = "";
return;
}
+ dropWalkiePendingForUser(room, username, "leave");
if (room.users.has(username)) room.users.delete(username);
+ if (room.typing && room.typing.has(username)) room.typing.delete(username);
ws.__mapsRoomId = "";
ws.__mapsInvisible = 0;
ws.__mapsSpeakAsPropId = "";
- if (room.users.size === 0) rooms.delete(current);
+ if (room.users.size === 0) {
+ clearRoomWalkies(room, "room-empty");
+ rooms.delete(current);
+ }
broadcastRoomState(current);
}
@@ -432,6 +789,7 @@ module.exports = function init(api) {
const raw = fs.readFileSync(MAPS_FILE, "utf8");
const json = JSON.parse(raw);
const list = Array.isArray(json?.maps) ? json.maps : [];
+ const presetList = Array.isArray(json?.avatarPresets) ? json.avatarPresets : [];
const next = [];
for (const m of list) {
const id = normId(m?.id || "");
@@ -481,21 +839,32 @@ module.exports = function init(api) {
});
}
customMaps = next;
+ const nextPresets = [];
+ for (const rawPreset of presetList) {
+ const preset = normalizeAvatarPreset(rawPreset || {}, rawPreset?.updatedBy || rawPreset?.createdBy || "");
+ if (!preset) continue;
+ nextPresets.push(preset);
+ }
+ avatarPresets = nextPresets;
} catch (e) {
console.warn("Maps plugin: failed to load custom maps:", e?.message || e);
customMaps = [];
+ avatarPresets = [];
}
}
function saveCustomMapsToDisk() {
fs.mkdirSync(DATA_DIR, { recursive: true });
- fs.writeFileSync(MAPS_FILE, JSON.stringify({ maps: customMaps }, null, 2));
+ fs.writeFileSync(MAPS_FILE, JSON.stringify({ maps: customMaps, avatarPresets }, null, 2));
}
loadCustomMapsFromDisk();
+ loadAvatarPrefsFromDisk();
api.registerWs("list", (ws) => {
ws.send(JSON.stringify({ type: "plugin:maps:mapsList", maps: listMapsPayload() }));
+ ws.send(JSON.stringify(mapsCapabilities(ws)));
+ sendAvatarPresets(ws);
});
api.registerWs("createMap", (ws, msg) => {
@@ -505,8 +874,8 @@ module.exports = function init(api) {
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." }));
+ if (role !== "owner" && role !== "admin" && role !== "moderator") {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Owner/admin/mod access required to create maps." }));
return;
}
@@ -760,10 +1129,19 @@ module.exports = function init(api) {
api.registerWs("ttrpgPropMove", (ws, msg) => {
const mapId = normId(ws.__mapsRoomId || msg?.mapId || "");
const idx = customMapIndex(mapId);
- if (idx < 0) return;
+ if (idx < 0) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." }));
+ return;
+ }
const map = customMaps[idx];
- if (!map.ttrpgEnabled) return;
- if (!canManageMaps(ws, map)) return;
+ if (!map.ttrpgEnabled) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "TTRPG mode is disabled for this map." }));
+ return;
+ }
+ if (!canManageMaps(ws, map)) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." }));
+ return;
+ }
const propId = typeof msg?.propId === "string" ? msg.propId.trim() : "";
if (!propId) return;
const list = Array.isArray(map.props) ? map.props : [];
@@ -784,10 +1162,19 @@ module.exports = function init(api) {
api.registerWs("ttrpgPropPatch", (ws, msg) => {
const mapId = normId(ws.__mapsRoomId || msg?.mapId || "");
const idx = customMapIndex(mapId);
- if (idx < 0) return;
+ if (idx < 0) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." }));
+ return;
+ }
const map = customMaps[idx];
- if (!map.ttrpgEnabled) return;
- if (!canManageMaps(ws, map)) return;
+ if (!map.ttrpgEnabled) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "TTRPG mode is disabled for this map." }));
+ return;
+ }
+ if (!canManageMaps(ws, map)) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." }));
+ return;
+ }
const propId = typeof msg?.propId === "string" ? msg.propId.trim() : "";
const { prop: prev, index: pidx } = propById(map, propId);
if (!prev || pidx < 0) return;
@@ -816,10 +1203,19 @@ module.exports = function init(api) {
if (!username) return;
const mapId = normId(ws.__mapsRoomId || msg?.mapId || "");
const idx = customMapIndex(mapId);
- if (idx < 0) return;
+ if (idx < 0) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." }));
+ return;
+ }
const map = customMaps[idx];
- if (!map.ttrpgEnabled) return;
- if (!canManageMaps(ws, map)) return;
+ if (!map.ttrpgEnabled) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "TTRPG mode is disabled for this map." }));
+ return;
+ }
+ if (!canManageMaps(ws, map)) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." }));
+ return;
+ }
const action = msg?.action === "release" ? "release" : "possess";
const props = Array.isArray(map.props) ? map.props : [];
@@ -874,10 +1270,19 @@ module.exports = function init(api) {
api.registerWs("ttrpgPropRemove", (ws, msg) => {
const mapId = normId(ws.__mapsRoomId || msg?.mapId || "");
const idx = customMapIndex(mapId);
- if (idx < 0) return;
+ if (idx < 0) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Map not found." }));
+ return;
+ }
const map = customMaps[idx];
- if (!map.ttrpgEnabled) return;
- if (!canManageMaps(ws, map)) return;
+ if (!map.ttrpgEnabled) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "TTRPG mode is disabled for this map." }));
+ return;
+ }
+ if (!canManageMaps(ws, map)) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Permission denied." }));
+ 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);
@@ -928,7 +1333,9 @@ module.exports = function init(api) {
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 });
+ room.users.set(username, { x: Math.random(), y: Math.random(), color, image, avatar: getAvatarPref(username), invisible: false, seq: 0 });
+ room.lastActiveAt = api.now();
+ avatarSnapshotNeededByUser.add(username);
ws.__mapsRoomId = mapId;
ws.__mapsInvisible = 0;
@@ -957,9 +1364,163 @@ module.exports = function init(api) {
selfInvisible: false
})
);
+ ws.send(JSON.stringify(mapsCapabilities(ws)));
+ sendAvatarPresets(ws);
broadcastRoomState(mapId);
});
+ api.registerWs("getCapabilities", (ws) => {
+ ws.send(JSON.stringify(mapsCapabilities(ws)));
+ });
+
+ api.registerWs("setAvatar", (ws, msg) => {
+ const username = userIdentity(ws);
+ if (!username) return;
+ const avatar = normalizeAvatarPref(msg || {});
+ avatarPrefsByUser.set(username, avatar);
+ try {
+ saveAvatarPrefsToDisk();
+ } catch (e) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Failed to save avatar settings." }));
+ return;
+ }
+ const mapId = normId(ws.__mapsRoomId || "");
+ const room = mapId ? rooms.get(mapId) : null;
+ if (room && room.users.has(username)) {
+ const current = room.users.get(username) || {};
+ room.users.set(username, { ...current, avatar });
+ api.sendToUsers(usersInRoom(mapId), { type: "plugin:maps:avatarChanged", mapId, username, avatar });
+ broadcastRoomState(mapId);
+ }
+ ws.send(JSON.stringify({ type: "plugin:maps:avatarSet", avatar }));
+ });
+
+ api.registerWs("listAvatarPresets", (ws) => {
+ sendAvatarPresets(ws);
+ });
+
+ api.registerWs("upsertAvatarPreset", (ws, msg) => {
+ const username = userIdentity(ws);
+ if (!username) return;
+ if (!canManageAvatarPresets(ws)) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Owner/admin/mod access required." }));
+ return;
+ }
+ const normalized = normalizeAvatarPreset(msg || {}, username);
+ if (!normalized) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Invalid avatar preset." }));
+ return;
+ }
+ const idx = avatarPresets.findIndex((preset) => preset.id === normalized.id);
+ if (idx >= 0) {
+ const prior = avatarPresets[idx];
+ avatarPresets[idx] = {
+ ...prior,
+ ...normalized,
+ id: prior.id,
+ createdAt: Number(prior.createdAt || api.now()),
+ createdBy: prior.createdBy || username,
+ updatedAt: api.now(),
+ updatedBy: username
+ };
+ } else {
+ avatarPresets.push({
+ ...normalized,
+ createdAt: api.now(),
+ updatedAt: api.now(),
+ createdBy: username,
+ updatedBy: username
+ });
+ }
+ try {
+ saveCustomMapsToDisk();
+ } catch {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Failed to save avatar presets." }));
+ return;
+ }
+ sendAvatarPresets(ws);
+ api.broadcast({ type: "plugin:maps:avatarPresetsUpdated" });
+ });
+
+ api.registerWs("deleteAvatarPreset", (ws, msg) => {
+ if (!canManageAvatarPresets(ws)) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Owner/admin/mod access required." }));
+ return;
+ }
+ const id = normId(msg?.id || "");
+ if (!id) return;
+ const before = avatarPresets.length;
+ avatarPresets = avatarPresets.filter((preset) => preset.id !== id);
+ if (avatarPresets.length === before) return;
+ try {
+ saveCustomMapsToDisk();
+ } catch {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Failed to save avatar presets." }));
+ return;
+ }
+ sendAvatarPresets(ws);
+ api.broadcast({ type: "plugin:maps:avatarPresetsUpdated" });
+ });
+
+ api.registerWs("applyAvatarPreset", (ws, msg) => {
+ const username = userIdentity(ws);
+ if (!username) return;
+ const id = normId(msg?.id || "");
+ if (!id) return;
+ const preset = avatarPresets.find((item) => item.id === id);
+ if (!preset) return;
+ if (!preset.published && !canManageAvatarPresets(ws)) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Preset unavailable." }));
+ return;
+ }
+ const current = getAvatarPref(username);
+ const avatar = normalizeAvatarPref({
+ ...preset.avatar,
+ displayName: current.displayName || "",
+ showUsername: current.showUsername !== false
+ });
+ avatarPrefsByUser.set(username, avatar);
+ try {
+ saveAvatarPrefsToDisk();
+ } catch {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Failed to apply avatar preset." }));
+ return;
+ }
+ const mapId = normId(ws.__mapsRoomId || "");
+ const room = mapId ? rooms.get(mapId) : null;
+ if (room && room.users.has(username)) {
+ const currentUser = room.users.get(username) || {};
+ room.users.set(username, { ...currentUser, avatar });
+ api.sendToUsers(usersInRoom(mapId), { type: "plugin:maps:avatarChanged", mapId, username, avatar });
+ broadcastRoomState(mapId);
+ }
+ ws.send(JSON.stringify({ type: "plugin:maps:avatarSet", avatar }));
+ });
+
+ api.registerWs("avatarEmote", (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 || !room.users.has(username)) return;
+ const pref = getAvatarPref(username);
+ const resolved = resolveAvatarEmote(pref, msg);
+ if (!resolved) return;
+ const until = api.now() + resolved.durationMs;
+ room.lastActiveAt = api.now();
+ api.sendToUsers(usersInRoom(mapId), {
+ type: "plugin:maps:avatarEmote",
+ mapId,
+ username,
+ state: resolved.state,
+ name: resolved.name,
+ loop: resolved.loop,
+ until
+ });
+ broadcastMapsListThrottled();
+ });
+
api.registerWs("leave", (ws) => {
leaveAnyRoom(ws);
ws.send(JSON.stringify({ type: "plugin:maps:left" }));
@@ -987,6 +1548,7 @@ module.exports = function init(api) {
if (seq && seq < prevSeq) return;
const next = { ...u, x, y, seq: seq || prevSeq };
room.users.set(username, next);
+ room.lastActiveAt = api.now();
const payload = { type: "plugin:maps:userMoved", mapId, username, x, y, seq: seq || prevSeq };
if (next.invisible) {
@@ -1024,6 +1586,7 @@ module.exports = function init(api) {
if (!text) return;
const createdAt = api.now();
+ room.lastActiveAt = createdAt;
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 };
@@ -1085,14 +1648,47 @@ module.exports = function init(api) {
}
}
}
- const payload = { type: "plugin:maps:bubble", mapId, username, actorType, actorPropId, displayName, color, text, createdAt: api.now() };
+ const createdAt = api.now();
+ room.lastActiveAt = createdAt;
+ const scopeRaw = typeof msg?.scope === "string" ? msg.scope.trim().toLowerCase() : "local";
+ const scope = scopeRaw === "global" ? "global" : "local";
+ const payload = { type: "plugin:maps:bubble", mapId, username, actorType, actorPropId, displayName, color, text, createdAt, scope };
if (u.invisible) {
api.sendToUsers([username], payload);
- } else {
+ } else if (scope === "global") {
api.sendToUsers(usersInRoom(mapId), payload);
+ } else {
+ 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("typing", (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 || !room.users.has(username)) return;
+ const isTyping = Boolean(msg?.isTyping);
+ if (!room.typing) room.typing = new Map();
+ if (isTyping) {
+ room.typing.set(username, api.now() + 4500);
+ room.lastActiveAt = api.now();
+ } else {
+ room.typing.delete(username);
+ }
+ api.sendToUsers(usersInRoom(mapId), { type: "plugin:maps:typing", mapId, username, isTyping, expiresAt: Number(room.typing.get(username) || 0) || 0 });
+ broadcastMapsListThrottled();
+ });
+
api.registerWs("setInvisible", (ws, msg) => {
const username = userIdentity(ws);
if (!username) return;
@@ -1126,6 +1722,7 @@ module.exports = function init(api) {
const map = mapById(mapId);
if (!map) return;
if (!map.walkiesEnabled) {
+ noteWalkie("send-denied-disabled", mapId, { username });
ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Walkies are disabled for this map." }));
return;
}
@@ -1134,27 +1731,30 @@ module.exports = function init(api) {
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)}`;
+ let id = idRaw && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,80}$/.test(idRaw) ? idRaw : `${api.now()}_${Math.random().toString(16).slice(2)}`;
+ while (room.walkies?.has(id)) id = `${id}_${Math.random().toString(16).slice(2, 6)}`;
const url = typeof msg?.url === "string" ? msg.url.trim() : "";
- if (!isSafeUploadUrl(url)) return;
+ if (!isSafeUploadUrl(url)) {
+ noteWalkie("send-denied-bad-url", mapId, { username });
+ return;
+ }
const x = clamp01(msg?.x);
const y = clamp01(msg?.y);
const createdAt = api.now();
+ room.lastActiveAt = createdAt;
const pending = new Set(usersInRoom(mapId));
if (!room.walkies) room.walkies = new Map();
- room.walkies.set(id, { url, pending, createdAt, mapId });
+ const timeout = setTimeout(() => {
+ const r = rooms.get(mapId);
+ if (!r?.walkies?.has(id)) return;
+ cleanupWalkieEntry(r, id, "cleanup-timeout", {});
+ }, 2 * 60 * 1000);
+ room.walkies.set(id, { url, pending, createdAt, mapId, timeout });
+ noteWalkie("send", mapId, { id, pendingCount: pending.size });
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) => {
@@ -1168,10 +1768,27 @@ module.exports = function init(api) {
if (!id) return;
const entry = room.walkies.get(id);
if (!entry) return;
+ if (!entry.pending.has(username)) {
+ noteWalkie("ack-duplicate", mapId, { id, username, reason: String(msg?.reason || "") });
+ return;
+ }
entry.pending.delete(username);
+ noteWalkie("ack", mapId, { id, username, pendingCount: entry.pending.size, reason: String(msg?.reason || "") });
if (entry.pending.size === 0) {
- room.walkies.delete(id);
- tryDeleteUploadSoon(entry.url, entry.createdAt);
+ cleanupWalkieEntry(room, id, "cleanup-all-acked", {});
}
});
+
+ api.registerWs("walkieState", (ws, msg) => {
+ const username = userIdentity(ws);
+ if (!username) return;
+ const mapId = normId(ws.__mapsRoomId || msg?.mapId || "");
+ if (!mapId) return;
+ const phaseRaw = typeof msg?.phase === "string" ? msg.phase.trim().toLowerCase() : "";
+ const phase = /^[a-z_]{2,24}$/.test(phaseRaw) ? phaseRaw : "unknown";
+ const id = typeof msg?.id === "string" ? msg.id.trim().slice(0, 120) : "";
+ const attempt = clampInt(msg?.attempt, 0, 10);
+ const error = typeof msg?.error === "string" ? msg.error.trim().slice(0, 120) : "";
+ noteWalkie(`client-${phase}`, mapId, { username, id, attempt, error });
+ });
};
diff --git a/public/app.js b/public/app.js
@@ -42,6 +42,7 @@ const showSideRackBtn = document.getElementById("showSideRack");
const showRightRackBtn = document.getElementById("showRightRack");
const chatModToggleWrapEl = document.getElementById("chatModToggleWrap");
const chatModToggleEl = document.getElementById("chatModToggle");
+const poweredByVersionEl = document.getElementById("poweredByVersion");
const authHint = document.getElementById("authHint");
const onboardingCard = document.getElementById("onboardingCard");
@@ -342,6 +343,26 @@ const HIVES_LIST_AUTO_THRESHOLD_PX = 520;
let lastHivesWidthPx = 0;
let hivesResizeObserver = null;
+function isOwnerRole(role) {
+ return String(role || "").toLowerCase() === "owner";
+}
+
+function isAdminRole(role) {
+ return String(role || "").toLowerCase() === "admin";
+}
+
+function isModeratorRole(role) {
+ return String(role || "").toLowerCase() === "moderator";
+}
+
+function isStaffRole(role) {
+ return isOwnerRole(role) || isAdminRole(role) || isModeratorRole(role);
+}
+
+function canManagePluginsRole(role) {
+ return isOwnerRole(role) || isAdminRole(role);
+}
+
// --- Rack layout (experimental) ------------------------------------------------
const RACK_LAYOUT_ENABLED_KEY = "bzl_rackLayout_enabled";
@@ -2351,7 +2372,7 @@ function ensureChatPostPanelInstance(postId, opts) {
toast("Walkie Talkie", "This hive is walkie-only. Hold ~ to talk.");
return;
}
- if (currentPost?.readOnly && !(loggedInRole === "owner" || loggedInRole === "moderator")) {
+ if (currentPost?.readOnly && !isStaffRole(loggedInRole)) {
toast("Read-only", "This hive is read-only.");
return;
}
@@ -2484,7 +2505,7 @@ function renderChatPostPanelInstance(panelId, forceScroll) {
}
const isWalkie = String(post.mode || post.chatMode || "").toLowerCase() === "walkie";
- const canChatWrite = Boolean(loggedInRole === "owner" || loggedInRole === "moderator" || !post.readOnly);
+ const canChatWrite = Boolean(isStaffRole(loggedInRole) || !post.readOnly);
if (editorEl) editorEl.contentEditable = String(Boolean(canChatWrite && !isWalkie));
if (sendBtn instanceof HTMLButtonElement) sendBtn.disabled = !(loggedInUser && canChatWrite && !isWalkie);
@@ -2545,7 +2566,7 @@ function renderChatPostPanelInstance(panelId, forceScroll) {
)}">Report</button>`
: "";
const deleteAction =
- loggedInUser && !m.deleted && (loggedInRole === "owner" || loggedInRole === "moderator" || from === loggedInUser)
+ loggedInUser && !m.deleted && (isStaffRole(loggedInRole) || from === loggedInUser)
? `<button type="button" class="ghost smallBtn" data-delchat="${escapeHtml(m.id)}" data-postid="${escapeHtml(
post.id
)}">Delete</button>`
@@ -3685,6 +3706,12 @@ function renderInstanceBranding() {
if (instanceSubtitleEl) instanceSubtitleEl.textContent = b.subtitle;
}
+function renderPoweredByVersion() {
+ if (!poweredByVersionEl) return;
+ const version = String(serverHealth?.version || "").trim();
+ poweredByVersionEl.textContent = version ? `v${version}` : "";
+}
+
function formatLocalTime(ts) {
const n = Number(ts || 0);
if (!n) return "";
@@ -3709,9 +3736,11 @@ async function requestServerInfo() {
serverInfo = await infoRes.json();
serverHealth = await healthRes.json();
serverInfoStatus = { loading: false, at: Date.now(), error: "" };
+ renderPoweredByVersion();
renderModPanel();
} catch (e) {
serverInfoStatus = { loading: false, at: Date.now(), error: e?.message || "Failed to load server info." };
+ renderPoweredByVersion();
renderModPanel();
}
}
@@ -5941,15 +5970,15 @@ function normalizePlugins(rawList) {
}
function isOwnerUser() {
- return Boolean(loggedInUser && loggedInRole === "owner");
+ return Boolean(loggedInUser && isOwnerRole(loggedInRole));
}
function canManagePlugins() {
- return Boolean(loggedInUser && (loggedInRole === "owner" || loggedInRole === "moderator"));
+ return Boolean(loggedInUser && canManagePluginsRole(loggedInRole));
}
function renderPluginsAdminHtml() {
- if (!canManagePlugins()) return `<div class="muted small">Moderator/owner only.</div>`;
+ if (!canManagePlugins()) return `<div class="muted small">Admin/owner only.</div>`;
const status = pluginAdminStatus ? `<div class="small muted">${escapeHtml(pluginAdminStatus)}</div>` : "";
const busyLine = pluginAdminBusy ? `<div class="small muted">Working...</div>` : "";
const listHtml = !plugins.length
@@ -5981,7 +6010,7 @@ function renderPluginsAdminHtml() {
})
.join("");
return `
- <div class="small muted">Moderator/owner only. Install optional plugins to extend your instance.</div>
+ <div class="small muted">Admin/owner only. Install optional plugins to extend your instance.</div>
<div class="pluginInstallRow" style="margin-top:10px">
<input data-pluginzip="1" type="file" accept=".zip,application/zip" />
<button data-plugininstall="1" class="ghost" type="button">Install</button>
@@ -6029,7 +6058,7 @@ function roleDefByKey(key) {
function roleTokenLabel(token) {
const t = String(token || "");
- if (t === "owner" || t === "moderator" || t === "member") return t;
+ if (t === "owner" || t === "admin" || t === "moderator" || t === "member") return t;
if (t.startsWith("role:")) {
const key = t.slice("role:".length);
const found = roleDefByKey(key);
@@ -6059,7 +6088,7 @@ function renderCustomRoleBadges(username) {
}
function availableGateTokens() {
- const base = ["member", "moderator", "owner"];
+ const base = ["member", "moderator", "admin", "owner"];
const custom = customRoles.map((r) => `role:${r.key}`);
return [...base, ...custom];
}
@@ -6836,7 +6865,7 @@ function setAuthUi() {
const canMakePermanent =
Boolean(loggedInUser) &&
- (loggedInRole === "owner" || loggedInRole === "moderator" || Boolean(normalizeInstanceBranding(instanceBranding).allowMemberPermanentPosts));
+ (isStaffRole(loggedInRole) || Boolean(normalizeInstanceBranding(instanceBranding).allowMemberPermanentPosts));
if (ttlMinutesEl) {
ttlMinutesEl.min = canMakePermanent ? "0" : "1";
if (!canMakePermanent && Number(ttlMinutesEl.value || 0) <= 0) ttlMinutesEl.value = "60";
@@ -6849,8 +6878,8 @@ function setAuthUi() {
}
function roleLabel(role) {
- const r = String(role || "member");
- return r === "owner" || r === "moderator" ? r : "member";
+ const r = String(role || "member").toLowerCase();
+ return r === "owner" || r === "admin" || r === "moderator" ? r : "member";
}
function peopleOnlineCardStyle(member) {
@@ -7047,13 +7076,13 @@ function renderModPanel() {
btn.classList.toggle("ghost", !on);
// Owner-only plugin tabs should not show for non-owners.
const ownerOnly = btn.dataset.ownerOnly === "1";
- btn.classList.toggle("hidden", Boolean(ownerOnly && loggedInRole !== "owner"));
+ btn.classList.toggle("hidden", Boolean(ownerOnly && !isOwnerRole(loggedInRole) && !isAdminRole(loggedInRole)));
}
// Plugin-provided moderation tabs (render into modBody).
if (modPluginTabs.has(modTab)) {
const def = modPluginTabs.get(modTab);
- if (def?.ownerOnly && loggedInRole !== "owner") {
+ if (def?.ownerOnly && !isOwnerRole(loggedInRole) && !isAdminRole(loggedInRole)) {
modTab = "server";
renderModPanel();
return;
@@ -7091,8 +7120,8 @@ function renderModPanel() {
}
if (modTab === "server") {
- const isOwner = loggedInRole === "owner";
- const canEditAppearance = loggedInRole === "owner" || loggedInRole === "moderator";
+ const isOwner = isOwnerRole(loggedInRole) || isAdminRole(loggedInRole);
+ const canEditAppearance = isStaffRole(loggedInRole);
const b = normalizeInstanceBranding(instanceBranding);
const a = b.appearance || {};
const loading = Boolean(serverInfoStatus.loading);
@@ -7291,8 +7320,8 @@ function renderModPanel() {
}
if (modTab === "onboarding") {
- const isOwner = loggedInRole === "owner";
- const canEdit = loggedInRole === "owner" || loggedInRole === "moderator";
+ const isOwner = isOwnerRole(loggedInRole) || isAdminRole(loggedInRole);
+ const canEdit = isStaffRole(loggedInRole);
syncOnboardingAdminDraft(false);
normalizeOnboardingDraftRules();
const roleOptions = customRoles
@@ -7452,7 +7481,7 @@ function renderModPanel() {
</label>
<button type="button" data-rolecreate="1">Create</button>
</div>
- <div class="small muted" style="margin-bottom:8px">Tip: gate collections with <span class="tag">member</span>, <span class="tag">moderator</span>, <span class="tag">owner</span>, or <span class="tag">role:yourkey</span>.</div>
+ <div class="small muted" style="margin-bottom:8px">Tip: gate collections with <span class="tag">member</span>, <span class="tag">moderator</span>, <span class="tag">admin</span>, <span class="tag">owner</span>, or <span class="tag">role:yourkey</span>.</div>
<div class="gateList">${roleList}</div>
</div>`;
if (!modUsers.length) {
@@ -7471,7 +7500,8 @@ function renderModPanel() {
canModerate &&
u.username !== loggedInUser &&
role !== "owner" &&
- (role !== "moderator" || loggedInRole === "owner");
+ (role !== "admin" || isOwnerRole(loggedInRole)) &&
+ (role !== "moderator" || isOwnerRole(loggedInRole) || isAdminRole(loggedInRole));
const customBadges = renderCustomRoleBadges(u.username);
return `<div class="modCard">
<div class="modRowTop">
@@ -7496,6 +7526,20 @@ function renderModPanel() {
}
${
canPromote && role === "moderator"
+ ? `<button type="button" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml(
+ u.username
+ )}" data-role="admin">Make admin</button>`
+ : ""
+ }
+ ${
+ canPromote && role === "admin"
+ ? `<button type="button" class="danger" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml(
+ u.username
+ )}" data-role="moderator">Remove admin</button>`
+ : ""
+ }
+ ${
+ canPromote && role === "moderator"
? `<button type="button" class="danger" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml(
u.username
)}" data-role="member">Remove mod</button>`
@@ -7999,7 +8043,7 @@ function renderChatPanel(forceScroll = false) {
chatMeta.textContent = `${author}${isWalkie ? " | walkie talkie" : ""}${streamMeta}${ro} | ${
exp === "permanent" ? "permanent" : `expires in ${exp}`
} | ${tags}`.trim();
- const canChatWrite = Boolean(loggedInRole === "owner" || loggedInRole === "moderator" || !post.readOnly);
+ const canChatWrite = Boolean(isStaffRole(loggedInRole) || !post.readOnly);
if (chatEditor) chatEditor.contentEditable = String(Boolean(canChatWrite && !isWalkie));
const chatSendBtn = chatForm?.querySelector?.("button[type='submit']") || null;
if (chatSendBtn) chatSendBtn.disabled = !(loggedInUser && canChatWrite && !isWalkie);
@@ -8918,7 +8962,7 @@ function attachStreamPreview(stream, kind, local = false) {
function streamCanHostPost(post) {
if (!post || !loggedInUser) return false;
if (String(post.author || "") === String(loggedInUser || "")) return true;
- return loggedInRole === "owner" || loggedInRole === "moderator";
+ return isStaffRole(loggedInRole);
}
function streamResetState(keepPostId = false) {
@@ -9768,7 +9812,7 @@ newPostForm.addEventListener("submit", (e) => {
}
const ttlMinutes = Number(ttlMinutesEl.value || 60);
const canMakePermanent =
- loggedInRole === "owner" || loggedInRole === "moderator" || Boolean(normalizeInstanceBranding(instanceBranding).allowMemberPermanentPosts);
+ isStaffRole(loggedInRole) || Boolean(normalizeInstanceBranding(instanceBranding).allowMemberPermanentPosts);
const minMinutes = canMakePermanent ? 0 : 1;
const ttl = Math.max(minMinutes, Math.min(2880, Math.floor(ttlMinutes))) * 60_000;
@@ -9884,7 +9928,7 @@ function submitChat() {
toast("Walkie Talkie", "This hive is walkie-only. Hold ~ to talk.");
return;
}
- if (post?.readOnly && !(loggedInRole === "owner" || loggedInRole === "moderator")) {
+ if (post?.readOnly && !isStaffRole(loggedInRole)) {
toast("Read-only", "This hive is read-only.");
return;
}
@@ -10427,7 +10471,7 @@ modBodyEl?.addEventListener("click", (e) => {
const devLogClearBtn = e.target.closest("button[data-devlogclear]");
if (devLogClearBtn) {
- if (!(canModerate && loggedInRole === "owner")) return;
+ if (!(canModerate && (isOwnerRole(loggedInRole) || isAdminRole(loggedInRole)))) return;
const ok = confirm("Clear the server dev log?");
if (!ok) return;
ws.send(JSON.stringify({ type: "devLogClear" }));
@@ -10474,7 +10518,7 @@ modBodyEl?.addEventListener("click", (e) => {
const onbRuleAddBtn = e.target.closest("button[data-onb-ruleadd]");
if (onbRuleAddBtn) {
- if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ if (!(canModerate && isStaffRole(loggedInRole))) return;
normalizeOnboardingDraftRules();
const nextIndex = onboardingAdminDraft.rules.length + 1;
const id = `r${Date.now()}_${nextIndex}`;
@@ -10505,7 +10549,7 @@ modBodyEl?.addEventListener("click", (e) => {
const onbRuleDeleteBtn = e.target.closest("button[data-onb-ruledelete]");
if (onbRuleDeleteBtn) {
- if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ if (!(canModerate && isStaffRole(loggedInRole))) return;
const id = String(onbRuleDeleteBtn.getAttribute("data-onb-ruledelete") || "").trim();
onboardingAdminDraft.rules = onboardingAdminDraft.rules.filter((r) => r.id !== id);
onboardingAdminExpandedRuleIds.delete(id);
@@ -10516,7 +10560,7 @@ modBodyEl?.addEventListener("click", (e) => {
const onbRuleUpBtn = e.target.closest("button[data-onb-ruleup]");
if (onbRuleUpBtn) {
- if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ if (!(canModerate && isStaffRole(loggedInRole))) return;
const id = String(onbRuleUpBtn.getAttribute("data-onb-ruleup") || "").trim();
const idx = onboardingAdminDraft.rules.findIndex((r) => r.id === id);
if (idx <= 0) return;
@@ -10530,7 +10574,7 @@ modBodyEl?.addEventListener("click", (e) => {
const onbRuleDownBtn = e.target.closest("button[data-onb-ruledown]");
if (onbRuleDownBtn) {
- if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ if (!(canModerate && isStaffRole(loggedInRole))) return;
const id = String(onbRuleDownBtn.getAttribute("data-onb-ruledown") || "").trim();
const idx = onboardingAdminDraft.rules.findIndex((r) => r.id === id);
if (idx < 0 || idx >= onboardingAdminDraft.rules.length - 1) return;
@@ -10544,7 +10588,7 @@ modBodyEl?.addEventListener("click", (e) => {
const onboardingSaveBtn = e.target.closest("button[data-onboarding-save],button[data-onboarding-publish]");
if (onboardingSaveBtn) {
- if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ if (!(canModerate && isStaffRole(loggedInRole))) return;
const publish = onboardingSaveBtn.hasAttribute("data-onboarding-publish");
normalizeOnboardingDraftRules();
ws.send(
@@ -10570,7 +10614,7 @@ modBodyEl?.addEventListener("click", (e) => {
const instanceSaveBtn = e.target.closest("button[data-instance-save]");
if (instanceSaveBtn) {
- if (!(canModerate && loggedInRole === "owner")) return;
+ if (!(canModerate && (isOwnerRole(loggedInRole) || isAdminRole(loggedInRole)))) return;
const title = String(modBodyEl.querySelector("input[data-instance-title]")?.value || "").replace(/\s+/g, " ").trim().slice(0, 32);
const subtitle = String(modBodyEl.querySelector("input[data-instance-subtitle]")?.value || "").replace(/\s+/g, " ").trim().slice(0, 80);
const allowMemberPermanentPosts = Boolean(modBodyEl.querySelector("input[data-instance-allowpermanent]")?.checked);
@@ -10605,7 +10649,7 @@ modBodyEl?.addEventListener("click", (e) => {
const instanceSaveAppearanceBtn = e.target.closest("button[data-instance-saveappearance]");
if (instanceSaveAppearanceBtn) {
- if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ if (!(canModerate && isStaffRole(loggedInRole))) return;
const bg = String(modBodyEl.querySelector("input[data-instance-bg]")?.value || "").trim();
const panel = String(modBodyEl.querySelector("input[data-instance-panel]")?.value || "").trim();
const text = String(modBodyEl.querySelector("input[data-instance-text]")?.value || "").trim();
@@ -10630,7 +10674,7 @@ modBodyEl?.addEventListener("click", (e) => {
const themeResetBtn = e.target.closest("button[data-theme-reset]");
if (themeResetBtn) {
- if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ if (!(canModerate && isStaffRole(loggedInRole))) return;
applyInstanceAppearance();
renderModPanel();
toast("Theme", "Reset to saved theme.");
@@ -10928,7 +10972,7 @@ modBodyEl?.addEventListener("change", (e) => {
const presetSelect = e.target?.closest?.("select[data-theme-preset]");
if (presetSelect) {
- if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ if (!(canModerate && isStaffRole(loggedInRole))) return;
const id = String(presetSelect.value || "").trim();
if (!id) return;
const preset = THEME_PRESETS.find((p) => p.id === id) || null;
diff --git a/public/index.html b/public/index.html
@@ -173,13 +173,22 @@
</div>
<div class="sidebarFooter">
- <div class="poweredByTile" aria-label="Powered by Bzl">
+ <a
+ id="poweredByTileLink"
+ class="poweredByTile"
+ aria-label="Powered by Bzl"
+ href="https://bzl.one"
+ target="_blank"
+ rel="noopener noreferrer"
+ title="Visit bzl.one"
+ >
<img class="poweredByLogo" src="/assets/logobzl.png" alt="Bzl logo" />
<div class="poweredByText">
<div class="poweredByTitle">Powered by Bzl</div>
<div class="poweredByByline">by Azakaela Erin Redfire</div>
+ <div class="poweredByVersion" id="poweredByVersion"></div>
</div>
- </div>
+ </a>
<button id="toggleSidebar" class="ghost smallBtn" type="button" title="Hide sidebar">Hide</button>
</div>
</aside>
diff --git a/public/styles.css b/public/styles.css
@@ -47,6 +47,7 @@
--chat-rail-inset: 12px;
--chat-rail-side-max: 66%;
--chat-rail-center-max: 70%;
+ --hotbar-safe-lane: 0px;
}
:root[data-ui-scale="xs"] {
@@ -285,11 +286,16 @@ body {
grid-template-areas: "sidebar sidebarResize chat chatResize main";
gap: 0;
padding: var(--app-pad);
+ padding-bottom: calc(var(--app-pad) + var(--hotbar-safe-lane));
height: 100vh;
overflow: hidden;
position: relative;
}
+.app.hotbarVisible {
+ --hotbar-safe-lane: calc(82px + env(safe-area-inset-bottom, 0px));
+}
+
.app.rackMode {
grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr 10px minmax(var(--people-min), var(--people-width));
grid-template-areas: "sidebar sidebarResize main mainResize rightRack";
@@ -688,6 +694,8 @@ body {
}
.poweredByTile {
+ text-decoration: none;
+ color: inherit;
width: 220px;
height: 84px;
border-radius: 16px;
@@ -700,6 +708,13 @@ body {
align-items: center;
justify-content: flex-start;
gap: 12px;
+ transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease;
+}
+
+a.poweredByTile:hover {
+ transform: translateY(-1px);
+ border-color: rgba(246, 240, 255, 0.24);
+ box-shadow: 0 18px 52px rgba(0, 0, 0, 0.36);
}
.poweredByLogo {
@@ -730,6 +745,12 @@ body {
line-height: 1.1;
}
+.poweredByVersion {
+ font-size: 10px;
+ color: rgba(246, 240, 255, 0.82);
+ line-height: 1.1;
+}
+
.main {
grid-area: main;
display: flex;
@@ -2865,7 +2886,7 @@ button:disabled {
}
.app.hotbarVisible .chatForm {
- padding-bottom: 76px;
+ padding-bottom: 12px;
}
.chatForm > .primary[type="submit"] {
diff --git a/server.js b/server.js
@@ -1,6 +1,7 @@
const http = require("http");
const fs = require("fs");
const path = require("path");
+const { version: APP_VERSION = "0.0.0" } = require("./package.json");
// Minimal .env loader (no external deps). Only sets vars that aren't already set.
function loadDotEnvIfPresent() {
@@ -252,8 +253,9 @@ let persistTimer = null;
const ROLE_MEMBER = "member";
const ROLE_MODERATOR = "moderator";
+const ROLE_ADMIN = "admin";
const ROLE_OWNER = "owner";
-const ROLE_RANK = { [ROLE_MEMBER]: 1, [ROLE_MODERATOR]: 2, [ROLE_OWNER]: 3 };
+const ROLE_RANK = { [ROLE_MEMBER]: 1, [ROLE_MODERATOR]: 2, [ROLE_ADMIN]: 3, [ROLE_OWNER]: 4 };
const DEFAULT_COLLECTION_ID = "general";
const POST_MODE_TEXT = "text";
const POST_MODE_WALKIE = "walkie";
@@ -807,7 +809,7 @@ function normalizeCustomRoleKey(key) {
const cleaned = key.trim().toLowerCase();
if (!cleaned) return "";
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(cleaned)) return "";
- if (cleaned === ROLE_OWNER || cleaned === ROLE_MODERATOR || cleaned === ROLE_MEMBER) return "";
+ if (cleaned === ROLE_OWNER || cleaned === ROLE_ADMIN || cleaned === ROLE_MODERATOR || cleaned === ROLE_MEMBER) return "";
return cleaned;
}
@@ -843,7 +845,7 @@ function sanitizeAllowedRoleTokens(list) {
for (const item of list) {
if (typeof item !== "string") continue;
const token = item.trim().toLowerCase();
- const isBase = token === ROLE_MEMBER || token === ROLE_MODERATOR || token === ROLE_OWNER;
+ const isBase = token === ROLE_MEMBER || token === ROLE_MODERATOR || token === ROLE_ADMIN || token === ROLE_OWNER;
const isCustom = /^role:[a-z0-9][a-z0-9_-]{0,31}$/.test(token);
if (!isBase && !isCustom) continue;
if (seen.has(token)) continue;
@@ -868,7 +870,7 @@ function validateAllowedRoleTokensForCurrentRoles(tokens) {
const existing = existingCustomRoleTokenSet();
const out = [];
for (const token of clean) {
- if (token === ROLE_MEMBER || token === ROLE_MODERATOR || token === ROLE_OWNER) {
+ if (token === ROLE_MEMBER || token === ROLE_MODERATOR || token === ROLE_ADMIN || token === ROLE_OWNER) {
out.push(token);
continue;
}
@@ -923,7 +925,7 @@ function sanitizeReplyMeta(raw) {
}
function normalizeRole(role) {
- if (role === ROLE_OWNER || role === ROLE_MODERATOR || role === ROLE_MEMBER) return role;
+ if (role === ROLE_OWNER || role === ROLE_ADMIN || role === ROLE_MODERATOR || role === ROLE_MEMBER) return role;
return ROLE_MEMBER;
}
@@ -3575,8 +3577,8 @@ async function handlePluginInstall(req, res, url) {
return true;
}
const username = getSessionUserFromRequest(req);
- if (!username || !hasRole(username, ROLE_MODERATOR)) {
- sendJson(res, 403, { error: "Moderator access required." });
+ if (!username || !hasRole(username, ROLE_ADMIN)) {
+ sendJson(res, 403, { error: "Admin access required." });
return true;
}
@@ -3874,7 +3876,7 @@ function serveStatic(req, res) {
}
if (pathname === "/api/health") {
- const roleCounts = { owner: 0, moderator: 0, member: 0 };
+ const roleCounts = { owner: 0, admin: 0, moderator: 0, member: 0 };
for (const user of usersByName.values()) {
const role = normalizeRole(user?.role);
roleCounts[role] = (roleCounts[role] || 0) + 1;
@@ -3883,6 +3885,7 @@ function serveStatic(req, res) {
res.end(
JSON.stringify({
ok: true,
+ version: APP_VERSION,
uptimeSec: Math.floor(process.uptime()),
now: now(),
stats: {
@@ -4062,8 +4065,11 @@ function applyModerationAction(ws, msg) {
if (target === actor) return { ok: false, message: "You cannot moderate yourself." };
const targetRole = normalizeRole(targetUser.role);
if (targetRole === ROLE_OWNER) return { ok: false, message: "Owner account cannot be moderated." };
- if (targetRole === ROLE_MODERATOR && actorRole !== ROLE_OWNER) {
- return { ok: false, message: "Only the owner can moderate moderators." };
+ if (targetRole === ROLE_ADMIN && actorRole !== ROLE_OWNER) {
+ return { ok: false, message: "Only the owner can moderate admins." };
+ }
+ if (targetRole === ROLE_MODERATOR && !(actorRole === ROLE_OWNER || actorRole === ROLE_ADMIN)) {
+ return { ok: false, message: "Only admins/owner can moderate moderators." };
}
const minutesRaw = Number(metadata.minutes || 0) || 0;
@@ -5762,7 +5768,7 @@ wss.on("connection", (ws, req) => {
return;
}
if (hasRole(target, ROLE_MODERATOR)) {
- sendError(ws, "You can't ignore moderators or the owner.");
+ sendError(ws, "You can't ignore staff (moderator/admin/owner).");
return;
}
const ignore = msg.type === "ignoreUser";
@@ -5801,7 +5807,7 @@ wss.on("connection", (ws, req) => {
return;
}
if (hasRole(target, ROLE_MODERATOR)) {
- sendError(ws, "You can't block moderators or the owner.");
+ sendError(ws, "You can't block staff (moderator/admin/owner).");
return;
}
const block = msg.type === "blockUser";
@@ -5998,8 +6004,8 @@ wss.on("connection", (ws, req) => {
if (msg.type === "pluginSetEnabled") {
const actor = ws?.user?.username;
- if (!actor || !hasRole(actor, ROLE_MODERATOR)) {
- ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
+ if (!actor || !hasRole(actor, ROLE_ADMIN)) {
+ ws.send(JSON.stringify({ type: "permissionDenied", message: "Admin access required." }));
return;
}
const id = normalizePluginId(msg.id || msg.pluginId || "");
@@ -6023,8 +6029,8 @@ wss.on("connection", (ws, req) => {
if (msg.type === "pluginUninstall") {
const actor = ws?.user?.username;
- if (!actor || !hasRole(actor, ROLE_MODERATOR)) {
- ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
+ if (!actor || !hasRole(actor, ROLE_ADMIN)) {
+ ws.send(JSON.stringify({ type: "permissionDenied", message: "Admin access required." }));
return;
}
const id = normalizePluginId(msg.id || msg.pluginId || "");
@@ -6053,8 +6059,8 @@ wss.on("connection", (ws, req) => {
if (msg.type === "pluginReload") {
const actor = ws?.user?.username;
- if (!actor || !hasRole(actor, ROLE_MODERATOR)) {
- ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
+ if (!actor || !hasRole(actor, ROLE_ADMIN)) {
+ ws.send(JSON.stringify({ type: "permissionDenied", message: "Admin access required." }));
return;
}
loadPluginsFromDisk();