bzl

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

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:
Adocs/MAPS_PLUGIN_CURRENT_BEHAVIOR.md | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/MAPS_V2_IMPLEMENTATION_SPEC.md | 600+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplugins_dev/maps/client.js | 2053++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mplugins_dev/maps/server.js | 705++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpublic/app.js | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mpublic/index.html | 13+++++++++++--
Mpublic/styles.css | 23++++++++++++++++++++++-
Mserver.js | 42++++++++++++++++++++++++------------------
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();