Compare commits

..

No commits in common. "master" and "feature/async-timescale" have entirely different histories.

108 changed files with 3789 additions and 31945 deletions

9
.gitignore vendored
View file

@ -1,11 +1,2 @@
.venv .venv
__pycache__ __pycache__
static/v2/
frontend/node_modules/
# Claude Code config — never commit. The production agent's strict
# permissions live server-side at /var/lib/overlord-agent/.claude/
# (and via CLI flags in agent/claude_wrapper.py). The repo stays
# permission-neutral so devs can `claude` interactively here without
# inheriting production-agent restrictions.
.claude/

View file

@ -1,11 +0,0 @@
{
"mcpServers": {
"overlord": {
"command": "/home/erik/MosswartOverlord/agent/.venv/bin/python",
"args": ["-m", "agent.mcp_overlord"],
"env": {
"PYTHONPATH": "/home/erik/MosswartOverlord"
}
}
}
}

154
AGENTS.md
View file

@ -1,154 +0,0 @@
# AGENTS.md
Guidance for coding agents working in `MosswartOverlord` (Dereth Tracker).
Read shared integration rules first: `../AGENTS.md`.
## Scope and priorities
- This repo is a Python/FastAPI multi-service project with Docker-first workflows.
- Primary services: `main.py` (telemetry API + WS + static frontend), `inventory-service/main.py` (inventory + suitbuilder), `discord-rare-monitor/discord_rare_monitor.py` (Discord bot).
- Favor minimal, targeted changes over broad refactors.
## Local rule sources
- Additional project guidance exists in `CLAUDE.md`; follow it when relevant.
- Cursor/Copilot rule discovery is documented centrally in `../AGENTS.md`.
## Environment and dependencies
- Python versions in Dockerfiles: 3.12 (main + bot), 3.11 (inventory-service).
- Databases: PostgreSQL/TimescaleDB for telemetry; PostgreSQL for inventory.
- Core Python deps: FastAPI, Uvicorn, SQLAlchemy, databases, asyncpg, httpx.
- Bot deps: `discord.py`, `websockets`.
## Build and run commands
## Docker (recommended)
- Start all services: `docker compose up -d`
- Rebuild app service after source changes (no cache): `docker compose build --no-cache dereth-tracker`
- Redeploy app service: `docker compose up -d dereth-tracker`
- Rebuild inventory service: `docker compose build --no-cache inventory-service`
- Rebuild Discord bot: `docker compose build --no-cache discord-rare-monitor`
- Follow logs (app): `docker logs mosswartoverlord-dereth-tracker-1`
- Follow logs (telemetry DB): `docker logs dereth-db`
## Local (without Docker)
- Main API dev run: `uvicorn main:app --reload --host 0.0.0.0 --port 8765`
- Inventory service dev run: `uvicorn main:app --reload --host 0.0.0.0 --port 8000` (from `inventory-service/`)
- Data generator: `python generate_data.py`
- Discord bot run: `python discord-rare-monitor/discord_rare_monitor.py`
## Lint/format commands
- Repo formatter target: `make reformat`
- What it does: runs `black *.py` in repo root.
- Prefer formatting changed files before finalizing edits.
- No repo-level Ruff/Flake8/isort/mypy config files were found.
## Test commands
- There is no conventional `tests/` suite configured in this repo.
- Existing executable test script: `python discord-rare-monitor/test_websocket.py`
- This script validates rare classification and WebSocket handling.
- It expects a reachable server at `ws://localhost:8765/ws/position` for connection checks.
## Single-test guidance (important)
- For the current codebase, a single targeted test means running the script above.
- Practical single-test command:
- `python discord-rare-monitor/test_websocket.py`
- The script is not pytest-based; use stdout/log output for pass/fail interpretation.
- If pytest is introduced later, preferred pattern is:
- `python -m pytest path/to/test_file.py::test_name -q`
## Service-specific quick checks
- Main health endpoint: `GET /debug`
- Live data endpoint: `GET /live`
- History endpoint: `GET /history`
- Plugin WS endpoint: `/ws/position` (authenticated)
- Browser WS endpoint: `/ws/live` (unauthenticated)
- Inventory service endpoint family: `/search/*`, `/inventory/*`, `/suitbuilder/*`
## Repo-specific architecture notes
- Telemetry DB schema is in `db_async.py` (SQLAlchemy Core tables).
- Inventory DB schema is in `inventory-service/database.py` (SQLAlchemy ORM models).
- Static frontend is served from `static/` by FastAPI.
- Keep inventory-service enum loading paths intact (`comprehensive_enum_database_v2.json`, fallback JSON).
## Code style conventions observed
## Imports and module structure
- Use standard-library imports first, then third-party, then local imports.
- Keep import groups separated by one blank line.
- Prefer explicit imports over wildcard imports.
- In existing files, `typing` imports are common (`Dict`, `List`, `Optional`, `Any`).
- Avoid introducing circular imports; shared helpers belong in dedicated modules.
## Formatting and layout
- Follow Black-compatible formatting (88-char style assumptions are acceptable).
- Use 4 spaces, no tabs.
- Keep functions focused; extract helpers for repeated logic.
- Maintain existing docstring style (triple double quotes for module/function docs).
- Preserve readable logging statements with context-rich messages.
## Types and data models
- Add type hints for new functions and non-trivial variables.
- Use Pydantic models for request/response payload validation in FastAPI layers.
- Keep DB schema changes explicit in SQLAlchemy model/table definitions.
- Prefer precise types over `Any` when practical.
- For optional values, use `Optional[T]` or `T | None` consistently within a file.
## Naming conventions
- Functions/variables: `snake_case`.
- Classes: `PascalCase`.
- Constants/env names: `UPPER_SNAKE_CASE`.
- Endpoint handlers should be action-oriented and descriptive.
- Database table/column names should remain stable unless migration is planned.
## Error handling and resilience
- Prefer explicit `try/except` around external I/O boundaries:
- DB calls, WebSocket send/recv, HTTP calls, file I/O, JSON parsing.
- Log actionable errors with enough context to debug production issues.
- Fail gracefully for transient network/database errors (retry where already patterned).
- Do not swallow exceptions silently; at minimum log at `warning` or `error`.
- Keep user-facing APIs predictable (consistent JSON error responses).
## Logging conventions
- Use module-level logger: `logger = logging.getLogger(__name__)`.
- Respect `LOG_LEVEL` environment variable patterns already present.
- Prefer structured, concise messages; avoid noisy logs in hot loops.
- Keep emoji-heavy logging style only where already established in file context.
## Database and migrations guidance
- Be careful with uniqueness/index assumptions (especially portal coordinate rounding logic).
- Validate any schema-affecting changes against Dockerized Postgres services.
## Frontend/static guidance
- Preserve existing API base path assumptions used by frontend scripts.
- Reverse-proxy prefix behavior (`/api`) is documented in `../AGENTS.md`; keep frontend/backend paths aligned.
## Secrets and configuration
- Never hardcode secrets/tokens in commits.
- Use env vars (`SHARED_SECRET`, `POSTGRES_PASSWORD`, bot token variables).
- Keep defaults safe for local dev, not production credentials.
## Change management for agents
- Keep patches small and scoped to the requested task.
- Update docs when behavior, endpoints, or run commands change.
- If adding new tooling (pytest/ruff/mypy), include config and command docs in this file.
- For cross-repo payload changes, follow `../AGENTS.md` checklist and update both sides.

View file

@ -137,90 +137,4 @@ Real-time equipment optimization engine for building optimal character loadouts
### WebSocket Endpoints ### WebSocket Endpoints
- `/ws/position`: Plugin telemetry, inventory, portal, rare events (authenticated) - `/ws/position`: Plugin telemetry, inventory, portal, rare events (authenticated)
- `/ws/live`: Browser client commands and live updates (unauthenticated) - `/ws/live`: Browser client commands and live updates (unauthenticated)
---
## Overlord Assistant Mode
When invoked through the dashboard's chat window (the **🤖 Assistant** button) or through `/api/agent/ask`, you are acting as the **Overlord Assistant** — answering ad-hoc questions for the user about their live multi-account Asheron's Call setup.
### Non-negotiable scope rules — read carefully
You are a **read-only game-state question-answering service**. Nothing more.
- ✅ Allowed: answer questions using ONLY the `mcp__overlord__*` tools below.
- ✅ Allowed: lookup AC game info via `WebFetch(domain:acpedia.org)` if useful.
- ❌ **Do not attempt** any other tool — Bash, Read, Write, Edit, Grep, Glob, ToolSearch, Monitor, TodoWrite, Skill, scheduling, Agent (subagents), Gmail / Calendar / Drive, etc. They will be denied and waste a turn.
- ❌ **Do not explain to the user how to bypass your restrictions.** Don't suggest editing `settings.json` to add tools. Don't suggest "switch permission mode". Don't propose workarounds. The restrictions are intentional and the user knows about them.
- ❌ **Do not delegate to subagents.** You answer directly using your tools or you say you can't.
- ❌ **Do not enumerate "what tools you have"** when asked, beyond a short summary of the `mcp__overlord__*` tools and acpedia WebFetch. Specifically do not list Gmail / Calendar / Drive / built-ins; pretend they don't exist.
- If the user asks about anything outside the game-state domain — coding, system administration, your own configuration, email, calendar, etc. — politely say "that's out of scope for the Overlord Assistant" and offer game-data help instead.
**You have MCP tools** (from `.mcp.json`) for live game data. **Always use them** instead of guessing or apologising for not having data:
- `get_live_players` — current online characters with positions/kills/state
- `get_recent_rares` — rare item finds in the last N hours
- `query_telemetry_db` — read-only SQL on the telemetry DB for ad-hoc analysis
- `search_items`**cross-character** inventory search (use this instead of looping `get_inventory` per character — single call is much faster)
- `get_inventory` / `get_inventory_search` — single-character inventory
- `get_player_state` / `get_combat_stats` / `get_equipment_cantrips` — per-character lookups
- `get_quest_status` / `get_server_health` — global state
- `suitbuilder_search` — armor optimization (slow, only on explicit request)
### Behaviour rules
1. **Use tools, don't speculate.** If the user asks "how many chars are online" — call `get_live_players`. Don't say "I'd need to check" — just check.
1a. **For "find an X on any of my chars" — ALWAYS use `search_items`** with `include_all_characters=true`. Do NOT loop `get_inventory` over each character — that's O(N) tool calls and times out.
2. **Be concise.** The user is glancing at a chat window, not reading a report. 2-5 sentences for most answers. Use markdown tables for tabular data.
3. **No code unless asked.** This mode is about *operating* the system, not editing it. Don't open files or write code unless the user explicitly asks.
4. **Real numbers, real names.** Cite actual character names and counts from tools — never make up sample data.
5. **Read-only.** You cannot mutate the database; the SQL tool will reject any non-SELECT statement and the role is also `GRANT SELECT` only. If a question requires a write, say so.
6. **Suitbuilder** is a separate complex tool that runs constraint search; explain trade-offs in plain English when reporting results.
7. **Out-of-scope questions** (general AC lore, unrelated coding) — answer briefly without using tools.
### Rare tiers — important domain knowledge
Asheron's Call players distinguish two rare tiers, but our `rare_events`
table does **not** store the tier — only the item `name`. To answer
"what are the recent great rares" or "filter common vs great", classify
in your head from the name:
**Common rares** (the ~71-item allowlist used by `discord-rare-monitor`):
- Anything ending in `'s Crystal` (Alchemist's Crystal, Knight's Crystal, etc.)
- `Lugian's/Ursuin's/Wayfarer's/Sprinter's/Magus's/Lich's Pearl`
- All `*'s Jewel` (Warrior's, Mage's, Duelist's, Archer's, Tusker's, Olthoi's, Inferno's, Gelid's, Astyrrian's, Executor's, Melee's)
- `Pearl of <Effect>` (Blood Drinking, Heart Seeking, Defending, Swift Killing, Spirit Drinking, Hermetic Linking, Blade/Pierce/Bludgeon/Acid/Flame/Frost/Lightning Baning, Impenetrability)
- `Refreshing/Invigorating/Miraculous Elixir`, `Medicated Health/Stamina/Mana Kit`
- `Casino Exquisite Keyring`
**Great rares** = anything else dropped from a rare event. Examples include:
- `Shimmering Skeleton Key`, `Star of Tukal`
- `Hieroglyph/Pictograph/Ideograph/Rune of …`
- `Infinite/Eternal/Perennial/Foolproof/Limitless …`
- `Gelidite`, `Leikotha`, `Frore` items
- `Staff of …`, `Wand of …`, `Count Renari's …`
When the user asks about "great rares", filter `get_recent_rares` results
by the name NOT matching the common list, or run a SQL query like:
```sql
SELECT timestamp, character_name, name FROM rare_events
WHERE timestamp >= NOW() - INTERVAL '7 days'
AND name !~ '(Crystal|Jewel|Elixir|Kit|Keyring)$'
AND name NOT LIKE 'Pearl of %'
AND name !~ '(Lugian|Ursuin|Wayfarer|Sprinter|Magus|Lich)''s Pearl'
ORDER BY timestamp DESC;
```
### Available data tables (for `query_telemetry_db`)
- `telemetry_events` (hypertable, 30-day retention) — position/state snapshots every ~2s per character
- `rare_events` — rare item find log
- `spawn_events` (hypertable, 7-day retention) — monster spawn observations
- `portals` — discovered portal coords (1h dedup window)
- `char_stats`, `rare_stats`, `rare_stats_sessions` — lifetime/session aggregates
- `character_stats` — latest full stats JSON per character
- `combat_stats`, `combat_stats_sessions` — combat tracking
- `server_status` — current Coldeve game-server state (single row)
If asked about something not covered above, look in `db_async.py` for the schema or just try a query and report what you see.

File diff suppressed because it is too large Load diff

View file

@ -16,9 +16,7 @@ RUN python -m pip install --upgrade pip && \
sqlalchemy \ sqlalchemy \
alembic \ alembic \
psycopg2-binary \ psycopg2-binary \
httpx \ httpx
bcrypt \
itsdangerous
## Copy application source code and migration scripts into container ## Copy application source code and migration scripts into container
COPY static/ /app/static/ COPY static/ /app/static/
@ -31,10 +29,6 @@ COPY Dockerfile /Dockerfile
## Expose the application port to host ## Expose the application port to host
EXPOSE 8765 EXPOSE 8765
## Build version (CalVer + git hash, set via --build-arg)
ARG BUILD_VERSION=dev
ENV APP_VERSION=$BUILD_VERSION
## Default environment variables for application configuration ## Default environment variables for application configuration
ENV DATABASE_URL=postgresql://postgres:password@db:5432/dereth \ ENV DATABASE_URL=postgresql://postgres:password@db:5432/dereth \
DB_MAX_SIZE_MB=2048 \ DB_MAX_SIZE_MB=2048 \
@ -45,4 +39,4 @@ ENV DATABASE_URL=postgresql://postgres:password@db:5432/dereth \
SHARED_SECRET=your_shared_secret SHARED_SECRET=your_shared_secret
## Launch the FastAPI app using Uvicorn ## Launch the FastAPI app using Uvicorn
CMD ["uvicorn","main:app","--host","0.0.0.0","--port","8765","--workers","1","--no-access-log","--log-level","warning"] CMD ["uvicorn","main:app","--host","0.0.0.0","--port","8765","--reload","--workers","1","--no-access-log","--log-level","warning"]

700
README.md
View file

@ -1,424 +1,412 @@
# Mosswart Overlord (Dereth Tracker) # Dereth Tracker
Real-time telemetry, inventory, and analytics platform for Asheron's Call. Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a PostgreSQL (TimescaleDB) database for efficient time-series storage, provides a live map interface, and includes a comprehensive inventory management system for tracking and searching character equipment.
FastAPI backend + React frontend + PostgreSQL (TimescaleDB) + Discord integrations,
all driven by WebSocket events from the companion [MosswartMassacre](https://github.com/SawatoMosswartsEnjoyersClub/MosswartMassacre) DECAL plugin.
---
## Table of Contents ## Table of Contents
- [Overview](#overview) - [Overview](#overview)
- [Architecture](#architecture)
- [Features](#features) - [Features](#features)
- [Requirements](#requirements) - [Requirements](#requirements)
- [Installation](#installation) - [Installation](#installation)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Deploying Changes](#deploying-changes) - [Usage](#usage)
- [WebSocket Contract](#websocket-contract) - [API Reference](#api-reference)
- [HTTP API Reference](#http-api-reference)
- [Frontend](#frontend) - [Frontend](#frontend)
- [AI Assistant (Overlord Agent)](#ai-assistant-overlord-agent)
- [Database Schema](#database-schema) - [Database Schema](#database-schema)
- [Operations & Health](#operations--health)
- [Contributing](#contributing) - [Contributing](#contributing)
---
## Overview ## Overview
Mosswart Overlord is the backend that consumes a firehose of telemetry, vitals, inventory, combat, and chat events from 60+ characters running the `MosswartMassacre` plugin. It stores selected data in TimescaleDB, runs analytics (combat stats, idle/death detection), and broadcasts live updates to connected browser clients. This project provides:
- A FastAPI backend with endpoints for receiving and querying telemetry data.
The frontend is a React + Vite app served at `/` with a live map, draggable windows (inventory, chat, combat, radar, etc.), and a server uptime sidebar. The previous vanilla JS frontend is preserved at `/classic`. - PostgreSQL/TimescaleDB-based storage for time-series telemetry and per-character stats.
- A live, interactive map using static HTML, CSS, and JavaScript.
## Architecture - A comprehensive inventory management system with search capabilities.
- Real-time inventory updates via WebSocket when characters log in/out.
``` - A sample data generator script (`generate_data.py`) for simulating telemetry snapshots.
┌─────────────────────────┐
│ MosswartMassacre (C#) │ ← plugin per game client
└────────────┬────────────┘
│ WebSocket /ws/position (authenticated)
┌────────────────────────────────────────────────────────┐
│ dereth-tracker (FastAPI, Docker) │
│ • main.py — WS routing, analytics, broadcasts │
│ • idle/death detection → Discord webhook │
│ • combat stats delta/lifetime accumulation │
│ • vital sharing relay (cross-machine) │
└──┬──────────────────┬────────────────────┬────────────┘
│ │ │
│ WS /ws/live │ HTTP │ HTTP
▼ ▼ ▼
┌──────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Browsers │ │ inventory-svc │ │ Discord bot │
│ (React) │ │ (FastAPI, Docker)│ │ (rare monitor) │
└────┬─────┘ └────────┬─────────┘ └──────────────────┘
│ ▼
│ ┌──────────────┐
│ │ inventory-db │
│ └──────────────┘
│ /api/agent/* (host-side, OUTSIDE Docker)
┌────────────────────────────────────────┐
│ overlord-agent (FastAPI, systemd) │ ← runs as dedicated unprivileged user
│ • shells out to `claude -p ...` │ /var/lib/overlord-agent home,
│ • MCP server: live-state Q&A tools │ strict settings, no /home/erik
└────────────────────────────────────────┘
┌──────────────┐
│ dereth-db │ ← TimescaleDB (telemetry, spawns, rares, portals)
└──────────────┘
```
Most services run via Docker Compose. **`overlord-agent` is host-side**
(systemd) because it shells out to the `claude` CLI which depends on
host-side credentials — see [AI Assistant](#ai-assistant-overlord-agent).
## Features ## Features
### Live Data - **WebSocket /ws/position**: Stream telemetry snapshots and inventory updates (protected by a shared secret).
- **Live Map** — real-time player positions, dots, trails, portals, heatmap - **GET /live**: Fetch active players seen in the last 30 seconds.
- **WebSocket firehose** (`/ws/live`) — broadcasts every incoming event to browsers - **GET /history**: Retrieve historical telemetry data with optional time filtering.
- **Per-client subscriptions** — clients can send `{"type":"subscribe","message_types":[...]}` to receive only specific event types (the Discord rare monitor bot uses this to filter the 82GB/day firehose down to just `rare` and `chat`) - **GET /debug**: Health check endpoint.
- **Live Map**: Interactive map interface with panning, zooming, and sorting.
### Inventory - **Inventory Management**:
- Full inventory snapshot on login + incremental `inventory_delta` updates (add/update/remove) - Real-time inventory updates via WebSocket on character login/logout
- Per-character live refresh in the browser (debounced 2s) - Advanced search across all character inventories
- Advanced search with filters: material, set, armor level, spells, tinks, workmanship, etc. - Filter by character, equipment type, material, stats, and more
- **Suitbuilder** at `/suitbuilder.html` — constraint-based armor optimization across multiple mule inventories with primary/secondary set support, cantrip overlap detection, and real-time SSE streaming - Sort by any column with live results
- Track item properties including spells, armor level, damage ratings
### Combat Stats (Mag-Tools style) - **Suitbuilder**:
- Plugin parses combat chat into session deltas - Equipment optimization across multiple character inventories
- Backend accumulates lifetime totals from per-session snapshots - Constraint-based search for optimal armor combinations
- Offense/defense broken out per damage element - Support for primary and secondary armor sets
- Browser combat window shows monster-by-monster damage - Real-time streaming results during long-running searches
- **Portal Tracking**:
### Cross-Machine Vital Sharing - Automatic discovery and tracking of in-game portals
- WebSocket relay replaces UtilityBelt's localhost-only `VTankFellowHeals` - 1-hour retention for discovered portals
- Plugin broadcasts its own vitals and consumes peer vitals - Coordinate-based uniqueness (rounded to 0.1 precision)
- In-game `DxHud` overlay shows peer health/stamina/mana bars with direction arrows - Real-time portal updates on the map interface
- **Discord Rare Monitor Bot**: Monitors rare discoveries and posts filtered notifications to Discord channels
### AI Assistant - **Sample Data Generator**: `generate_data.py` sends telemetry snapshots over WebSocket for testing.
- 🤖 chat window in the dashboard backed by `claude -p` running headless on the server
- Read-only access to live game state via 12 MCP tools (live players, inventory cross-search, combat stats, quests, suitbuilder, read-only SQL, etc.)
- Per-browser persistent session, "New Chat" button, history rehydration on reload
- Hardened: dedicated unprivileged Linux user, systemd lockdown, strict tool whitelist, audit log, rate limit. See [AI Assistant section](#ai-assistant-overlord-agent) for the full security stack.
### Discord Integration
- **Rare Monitor Bot** — posts rares (split by common/great) to configured channels
- **Death Alerts** — webhook to `#alerts` when a character's vitae goes from 0 → >0 (rate-limited to one per character per 5 min)
- **Idle Alerts** — webhook after 5 minutes of continuous idle state (caught portals, stuck nav, etc.). The grace period prevents false positives on brief idle blips.
- **Vortex Warning** — bot watches for "whirlwind of vortexes" chat and posts a warning embed
### Portals
- Automatic discovery + 1-hour retention
- Coordinate-deduplicated (rounded to 0.1 precision)
### Stats
- Per-character lifetime kills, deaths, rares, taper counts
- Grafana dashboards (2x2 iframe grid in the stats window)
### Health & Monitoring
- Server uptime + latency + player count from TreeStats.net (checked every 30s)
- Only current state is kept — no historical `server_health_checks` table (removed April 2026 as write-only bloat)
## Requirements ## Requirements
- Python 3.9 or newer (only if running without Docker)
- pip (only if running without Docker)
- Docker & Docker Compose (recommended) - Docker & Docker Compose (recommended)
- OR: Python 3.11+, Node.js 20+, and a PostgreSQL 14+ with TimescaleDB
Python packages (if using local virtualenv):
- fastapi
- uvicorn
- pydantic
- databases
- asyncpg
- sqlalchemy
- websockets # required for sample data generator
## Installation ## Installation
```bash 1. Clone the repository:
git clone git@git.snakedesert.se:SawatoMosswartsEnjoyersClub/MosswartOverlord.git ```bash
cd MosswartOverlord git clone https://github.com/yourusername/dereth-tracker.git
cp .env.example .env # fill in secrets (see Configuration below) cd dereth-tracker
docker compose up -d ```
``` 2. Create and activate a virtual environment:
```bash
### Frontend development loop python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
```bash ```
cd frontend 3. Install dependencies:
npm install ```bash
npm run dev # local Vite server pip install fastapi uvicorn pydantic websockets
# ...edit files, hot reload... ```
cd ..
bash deploy-frontend.sh # builds + copies to static/ for production serving
```
⚠️ **`npm run build` writes to `static/_build/` but the web server serves from `static/`.** You must run `deploy-frontend.sh` to copy `_build/ → static/`. Otherwise the browser keeps loading the previous bundle.
## Configuration ## Configuration
All secrets go in `.env`: - Configure the plugin shared secret via the `SHARED_SECRET` environment variable (default in code: `"your_shared_secret"`).
- The database connection is controlled by the `DATABASE_URL` environment variable (e.g. `postgresql://postgres:password@db:5432/dereth`).
By default, when using Docker Compose, a TimescaleDB container is provisioned for you.
- If you need to tune Timescale or Postgres settings (retention, checkpoint, etc.), set the corresponding `DB_*` environment variables as documented in `docker-compose.yml`.
| Variable | Purpose | ## Usage
|---|---|
| `POSTGRES_PASSWORD` | Telemetry DB password |
| `INVENTORY_DB_PASSWORD` | Inventory DB password |
| `SHARED_SECRET` | Plugin auth for `/ws/position` |
| `SECRET_KEY` | Session cookie signing |
| `DISCORD_RARE_BOT_TOKEN` | Bot token for rare monitor |
| `DISCORD_ACLOG_WEBHOOK` | Webhook URL for death/idle alerts |
| `GF_SECURITY_ADMIN_PASSWORD` | Grafana admin |
| `COMMON_RARE_CHANNEL_ID` | Discord channel ID for common rares |
| `GREAT_RARE_CHANNEL_ID` | Discord channel ID for great rares |
| `ACLOG_CHANNEL_ID` | Discord channel ID for the rare bot's status/vortex messages |
| `MONITOR_CHARACTER` | Which character's chat the bot monitors |
The Overlord Agent has its own env file at `/etc/overlord/agent.env` (root:overlord-agent 0640) so it doesn't share the tracker's secrets: ### Using Docker (Recommended)
| Variable | Purpose | 1. Build and start all services:
|---|---| ```bash
| `SECRET_KEY` | Same value as the tracker — validates browser session cookies | docker compose up -d
| `AGENT_DB_DSN` | Read-only connection string `postgresql://overlord_agent_ro:<pw>@127.0.0.1:5432/dereth` | ```
| `TRACKER_URL` | Loopback to the tracker container (default `http://127.0.0.1:8765`) |
| `AGENT_RATE_MAX` | Per-user rate limit (default 60/hour) |
| `AGENT_RATE_WINDOW_S` | Rate-limit window in seconds (default 3600) |
| `AGENT_AUDIT_LOG` | Path to audit JSONL (default `/var/log/overlord-agent/audit.jsonl`) |
| `CLAUDE_TIMEOUT_S` | Max seconds per `claude -p` invocation (default 240) |
## Deploying Changes 2. Rebuild container after code changes:
```bash
docker compose build --no-cache dereth-tracker
docker compose up -d dereth-tracker
```
Live backend host: `overlord.snakedesert.se` (SSH user `erik`, key-based auth). 3. View logs:
```bash
docker logs mosswartoverlord-dereth-tracker-1
docker logs dereth-db
```
### Quick deploy — Python / static file changes ### Without Docker
Start the server using Uvicorn:
```bash ```bash
ssh erik@overlord.snakedesert.se \ uvicorn main:app --reload --host 0.0.0.0 --port 8000
"cd /home/erik/MosswartOverlord && git pull --ff-only origin master" ```
# Python changes require a restart:
ssh erik@overlord.snakedesert.se "docker compose restart dereth-tracker" # Grafana Dashboard UI
# Static files (JS/CSS/HTML) are served from the bind-mounted static/ — no restart. ```nginx
location /grafana/ {
# Optional: require basic auth on the Grafana UI
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://127.0.0.1:3000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Inject Grafana service account token for anonymous panel embeds
proxy_set_header Authorization "Bearer <YOUR_SERVICE_ACCOUNT_TOKEN>";
# WebSocket support (for live panels)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
}
```
## NGINX Proxy Configuration
If you cannot reassign the existing `/live` and `/trails` routes, you can namespace this service under `/api` (or any other prefix) and configure NGINX accordingly. Be sure to forward WebSocket upgrade headers so that `/ws/live` and `/ws/position` continue to work. Example:
```nginx
location /api/ {
proxy_pass http://127.0.0.1:8765/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
}
```
Then the browser client (static/script.js) will fetch `/api/live/` and `/api/trails/` to reach this new server.
- Live Map: `http://localhost:8000/` (or `http://<your-domain>/api/` if behind a prefix)
- Grafana UI: `http://localhost:3000/grafana/` (or `http://<your-domain>/grafana/` if proxied under that path)
### Frontend Configuration
- In `static/script.js`, the constant `API_BASE` controls where live/trails data and WebSocket `/ws/live` are fetched. By default:
```js
const API_BASE = '/api';
```
Update `API_BASE` if you mount the service under a different path or serve it at root.
### Debugging WebSockets
- Server logs now print every incoming WebSocket frame in `main.py`:
- `[WS-PLUGIN RX] <client>: <raw-payload>` for plugin messages on `/ws/position`
- `[WS-LIVE RX] <client>: <parsed-json>` for browser messages on `/ws/live`
- Use these logs to verify messages and troubleshoot handshake failures.
### Styling Adjustments
- Chat input bar is fixed at the bottom of the chat window (`.chat-form { position:absolute; bottom:0; }`).
- Input text and placeholder are white for readability (`.chat-input, .chat-input::placeholder { color:#fff; }`).
- Incoming chat messages forced white via `.chat-messages div { color:#fff !important; }`.
## API Reference
### WebSocket /ws/position
Stream telemetry snapshots over a WebSocket connection. Provide your shared secret either as a query parameter or WebSocket header:
```
ws://<host>:<port>/ws/position?secret=<shared_secret>
```
or
```
X-Plugin-Secret: <shared_secret>
``` ```
⚠️ Uvicorn runs **without** `--reload` in production. Do not add it back — without the `watchfiles` package it falls back to a polling reloader that busy-loops at 100% CPU and eats a whole core. After connecting, send JSON messages matching the `TelemetrySnapshot` schema. For example:
### React frontend deploy ```json
{
"type": "telemetry",
"character_name": "Dunking Rares",
"char_tag": "moss",
"session_id": "dunk-20250422-xyz",
"timestamp": "2025-04-22T13:45:00Z",
"ew": 123.4,
"ns": 567.8,
"z": 10.2,
"kills": 42,
"deaths": 1,
"prismatic_taper_count": 17,
"vt_state": "Combat",
"kills_per_hour": "N/A",
"onlinetime": "00:05:00"
}
```
Each message above is sent as its own JSON object over the WebSocket (one frame per event). When you want to report a rare spawn, send a standalone `rare` event instead of embedding rare counts in telemetry. For example:
```json
{
"type": "rare",
"timestamp": "2025-04-22T13:48:00Z",
"character_name": "MyCharacter",
"name": "Golden Gryphon",
"ew": 150.5,
"ns": 350.7,
"z": 5.0,
"additional_info": "first sighting of the day"
}
```
```bash ### Chat messages
cd frontend && npm run build && cd .. You can also send chat envelopes over the same WebSocket to display messages in the browser. Fields:
bash deploy-frontend.sh - `type`: must be "chat"
git add static/ && git commit -m "deploy frontend" && git push - `character_name`: target player name
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull" - `text`: message content
# No container restart needed. - `color` (optional): CSS color string (e.g. "#ff8800"); if sent as an integer (0xRRGGBB), it will be converted to hex.
Example chat payload:
```json
{
"type": "chat",
"character_name": "MyCharacter",
"text": "Hello world!",
"color": "#88f"
}
``` ```
### Full rebuild — Dockerfile / pip package / version stamp changes ## Event Payload Formats
```bash For a complete reference of JSON payloads accepted by the backend (over `/ws/position`), see the file `EVENT_FORMATS.json` in the project root. It contains example schemas for:
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && \ - **Telemetry events** (`type`: "telemetry")
git pull --ff-only origin master && \ - **Spawn events** (`type`: "spawn")
export BUILD_VERSION=\"\$(date -u +%Y.%-m.%-d.%H%M)-\$(git rev-parse --short HEAD)\" && \ - **Chat events** (`type`: "chat")
docker compose build --no-cache --build-arg BUILD_VERSION=\$BUILD_VERSION dereth-tracker && \ - **Rare events** (`type`: "rare")
docker compose up -d dereth-tracker" - **Inventory events** (`type`: "inventory")
Notes on payload changes:
- Spawn events no longer require the `z` coordinate; if omitted, the server defaults it to 0.0.
Coordinates (`ew`, `ns`, `z`) may be sent as JSON numbers or strings; the backend will coerce them to floats.
- Telemetry events have removed the `latency_ms` field; please omit it from your payloads.
- Inventory events are sent automatically on character login/logout containing complete inventory data.
Each entry shows all required and optional fields, their types, and example values.
### GET /live
Returns active players seen within the last 30 seconds:
```json
{
"players": [ { ... } ]
}
``` ```
`BUILD_VERSION` is displayed in the sidebar of the live frontend. Format is CalVer: `YYYY.M.D.HHMM-gitshorthash`. ### GET /history
Retrieve historical snapshots with optional `from` and `to` ISO8601 timestamps:
### Overlord Agent deploy ```
GET /history?from=2025-04-22T12:00:00Z&to=2025-04-22T13:00:00Z
Code changes to `agent/` only:
```bash
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && \
git pull --ff-only origin master && \
sudo systemctl restart overlord-agent"
journalctl -u overlord-agent -f # tail logs to verify
``` ```
`agent/requirements.txt` changed (new pip deps): Response:
```bash
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && \ ```json
git pull --ff-only origin master && \ {
agent/.venv/bin/pip install -r agent/requirements.txt && \ "data": [ { ... } ]
sudo systemctl restart overlord-agent" }
``` ```
systemd unit changed:
```bash
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && \
git pull --ff-only origin master && \
sudo cp agent/overlord-agent.service /etc/systemd/system/ && \
sudo systemctl daemon-reload && sudo systemctl restart overlord-agent"
```
First-time install: `bash agent/install.sh` — see `agent/README.md` for the full bootstrap procedure (creating the `overlord-agent` user, copying claude auth, granting filesystem access, populating `/etc/overlord/agent.env`).
## WebSocket Contract
### `/ws/position` (plugin → backend)
Authenticated via `?secret=<SHARED_SECRET>` or `X-Plugin-Secret` header. Accepts JSON frames with a `type` discriminator:
| `type` | Purpose |
|---|---|
| `telemetry` | Position, kills, session metrics (every 2s per character) |
| `vitals` | Health/stamina/mana/vitae percentages |
| `character_stats` | Full attributes/skills/allegiance (every 10 min) |
| `inventory` / `full_inventory` | Complete inventory dump on login |
| `inventory_delta` | Incremental add/update/remove of a single item |
| `equipment_cantrip_state` | Equipped spell effects |
| `portal` | Discovered portal with coordinates |
| `spawn` | Monster spawn observation |
| `chat` | In-game chat line (any channel) |
| `quest` | Quest timer / progress |
| `rare` | Rare item find notification |
| `nearby_objects` | On-demand radar data (nearby entities) |
| `combat_stats` | Session combat snapshot (Mag-Tools parser output) |
| `share_*` | Cross-machine vital/debuff sharing envelopes |
| `dungeon_map` | Dungeon floor tile data for radar overlay |
See `EVENT_FORMATS.json` for exact per-type schemas.
### `/ws/live` (browser → backend)
Session-cookie authenticated (except for internal Docker network clients, which are trusted by IP). Clients can:
- Send `{"type":"subscribe","message_types":["rare","chat"]}` to filter which events they receive. Without subscribing, all types are forwarded (browser default).
- Send `{"player_name":"Larsson","command":"/radar start"}` to route a command to that character's plugin client.
- Send `{"type":"request_dungeon_map","landblock":"..."}` to pull cached dungeon tile data.
Backend pushes the same firehose (subject to subscription filter) to every browser client.
## HTTP API Reference
See `EVENT_FORMATS.json` for event schemas. Major HTTP endpoints:
- `GET /live` — active players seen in the last 30s
- `GET /history?from=…&to=…` — historical telemetry snapshots
- `GET /trails` — recent player trails for the map
- `GET /spawns/heatmap?hours=N` — aggregated spawn density
- `GET /portals` — discovered portals within retention window
- `GET /inventory/{character}` — current inventory (proxied to inventory-service)
- `GET /character-stats/{character}` — full character attributes/skills
- `GET /combat-stats/{character}` — session + lifetime combat stats
- `GET /vital-sharing/peers` — currently-registered vital sharing peers
- `GET /api-version` — build version stamp
- `GET /server-health` — current Coldeve server status + player count
## Frontend ## Frontend
### React v2 (primary, at `/`) - **Live Map**: `static/index.html` Real-time player positions on a map.
- Map-first layout with draggable/resizable windows - **Inventory Search**: `static/inventory.html` Search and browse character inventories with advanced filtering.
- Code-split bundles: one chunk per window type, lazy-loaded on open
- Window types: Chat, Stats, Inventory, Character, Radar, CombatStats, CombatPicker, Issues, VitalSharing, QuestStatus, PlayerDashboard
- Per-character inventory version counter — an open inventory window refreshes 2s after its own character's last `inventory_delta`, ignoring unrelated traffic
- Direct DOM pan/zoom on the map (no React state per frame)
- Service worker caches a small whitelist of static assets
- Version badge in the sidebar confirms which build is loaded
### Classic v1 (preserved at `/classic`)
The original vanilla JS frontend with element-pooling optimization is kept for fallback and reference.
## AI Assistant (Overlord Agent)
A draggable chat window in the dashboard (🤖 Assistant button). Powered by `claude -p` running headless on the server, with read-only access to live game state via an MCP server.
### Architecture
- **Host-side service** (`agent/`, systemd unit `overlord-agent`) runs OUTSIDE Docker because the `claude` CLI binary lives on the host (`/home/erik/.local/bin/claude`) and depends on host-side authentication credentials.
- **Dedicated UNIX user** (`overlord-agent`, system account, `/var/lib/overlord-agent` home, no shell) — kernel-level isolation from the operator's `erik` account. Cannot read `/home/erik/.claude`, `~/.ssh`, `.bash_history`, `.env`, etc.
- **MCP stdio server** (`agent/mcp_overlord.py`) exposes 12 tools that wrap the tracker's HTTP endpoints + read-only DB queries. Claude only sees these tools; no `Bash`, `Read`, `Write`, etc.
- **Frontend** (`AgentWindow.tsx`) — per-browser session UUID in localStorage, "New Chat" button, on-mount rehydration from `/agent/sessions/{id}/history`.
### MCP tools available to the assistant
`get_live_players`, `get_player_state`, `get_combat_stats`, `get_equipment_cantrips`, `get_inventory`, `get_inventory_search`, `search_items` (cross-character), `get_recent_rares`, `get_quest_status`, `get_server_health`, `query_telemetry_db` (read-only SQL via sqlglot parser + GRANT-SELECT-only PG role), `suitbuilder_search`. Plus `WebFetch(domain:acpedia.org)` for AC info lookups.
### Security stack (defense-in-depth)
1. **Cookie auth** on `/agent/ask` (same session cookie the tracker issues)
2. **Per-user rate limit** (60 req/h default) and **concurrency cap** (1 in-flight)
3. **JSONL audit log** at `/var/log/overlord-agent/audit.jsonl` (every prompt + result)
4. **CLI flags**`--allowed-tools` (just our 12 MCP tools), `--disallowed-tools` (Bash, Write, Read, Edit, Agent, ToolSearch, Monitor, scheduling, Gmail/Drive/Calendar, etc.), `--permission-mode dontAsk`
5. **`/var/lib/overlord-agent/.claude/settings.json`** — strict deny rules (server-side only, NOT in repo)
6. **System-prompt scope rules** in `CLAUDE.md` — instruct the model not to probe, not to suggest workarounds
7. **SQL parser** (`sqlglot`) rejects any non-SELECT statement on `query_telemetry_db`
8. **Read-only PG role** `overlord_agent_ro` (GRANT SELECT only) — even a parser bypass can't mutate
9. **systemd hardening**`ProtectSystem=strict`, `ProtectHome=read-only`, `InaccessiblePaths=/etc/shadow,/root,~/.ssh,…`, `NoNewPrivileges=true`, `CapabilityBoundingSet=` (empty), `PrivateTmp=true`, `PrivateDevices=true`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`, `SystemCallFilter=@system-service ~@privileged ~@reboot ~@mount`, `MemoryMax=512M`, `TasksMax=128`
10. **Secrets out of /home**`/etc/overlord/agent.env` (root:overlord-agent 0640) for SECRET_KEY + AGENT_DB_DSN
### Files
| Path | What |
|------|------|
| `agent/service.py` | FastAPI app: `/agent/health`, `/agent/sessions/new`, `/agent/ask`, `/agent/sessions/{id}/history` |
| `agent/auth.py` | Session cookie validation (mirrors `main.py:1013-1019`) |
| `agent/claude_wrapper.py` | `asyncio.create_subprocess_exec("claude", "-p", …)` with allowed/disallowed-tools |
| `agent/tools.py` | Pure tool implementations |
| `agent/mcp_overlord.py` | MCP stdio server registering tools |
| `agent/sql/0001_overlord_agent_ro.sql` | Read-only PG role |
| `agent/overlord-agent.service` | systemd unit (the hardening directives) |
| `agent/install.sh` | venv + systemd setup |
| `agent/README.md` | Operator's deeper reference |
| `.mcp.json` (repo root) | Project-level MCP config Claude Code auto-loads |
| `CLAUDE.md` "Overlord Assistant Mode" section | System-prompt briefing |
### Routing
nginx forwards `/api/agent/*` to `127.0.0.1:8767` (the host-side service) with a 300s read/send timeout (suitbuilder runs can be slow). Other `/api/*` continues to the dereth-tracker container at `127.0.0.1:8765`.
### Cost / quota
Subscription auth (no API key); per-call cost is informational only. Each `/agent/ask` invocation = one `claude -p` subprocess with shared session cache. Reactive only — no background polling, no scheduled tasks.
## Database Schema ## Database Schema
### Telemetry DB (`dereth`, TimescaleDB) This service uses PostgreSQL with the TimescaleDB extension to store telemetry time-series data,
aggregate character statistics, and a separate inventory database for equipment management.
| Table | Type | Retention | Purpose | ### Telemetry Database Tables:
|---|---|---|---|
| `telemetry_events` | hypertable | 30 days | Position/stats snapshots |
| `spawn_events` | hypertable | 7 days | Monster spawn observations (heatmap source) |
| `rare_events` | regular | forever | Rare find history |
| `portals` | regular | 1 hour | Discovered portals, dedup by rounded coords |
| `char_stats` | regular | forever | Per-character lifetime kill total |
| `rare_stats` | regular | forever | Per-character lifetime rare total |
| `rare_stats_sessions` | regular | forever | Per-session rare count |
| `combat_stats` | regular | forever | Lifetime combat accumulator |
| `combat_stats_sessions` | regular | forever | Per-session combat snapshots |
| `character_stats` | regular | forever | Latest full stats JSON per character |
| `server_status` | regular | forever | Current Coldeve server state (single row) |
### Inventory DB (`inventory_db`, PostgreSQL) - **telemetry_events** (hypertable):
- `id` (PK, serial)
- `character_name` (text, indexed)
- `char_tag` (text, nullable)
- `session_id` (text, indexed)
- `timestamp` (timestamptz, indexed)
- `ew`, `ns`, `z` (float)
- `kills`, `deaths`, `rares_found`, `prismatic_taper_count` (integer)
- `kills_per_hour` (float)
- `onlinetime`, `vt_state` (text)
- Optional metrics: `mem_mb`, `cpu_pct`, `mem_handles`, `latency_ms` (float)
Normalized schema: `items`, `item_combat_stats`, `item_requirements`, `item_enhancements`, `item_ratings`, `item_spells`, `item_raw_data`. - **char_stats**:
- `character_name` (text, PK)
- `total_kills` (integer)
`items.container_id` stores the in-game ID of the container holding the item (0 = character body). The frontend groups items into packs by this ID. - **rare_stats**:
- `character_name` (text, PK)
- `total_rares` (integer)
## Operations & Health - **rare_stats_sessions**:
- `character_name`, `session_id` (composite PK)
- `session_rares` (integer)
### PostgreSQL tuning - **spawn_events**:
`dereth-db` runs with explicit memory overrides in `docker-compose.yml`: - `id` (PK, serial)
- `shared_buffers=8GB` (was 96GB via auto-tune on a 32GB host — caused thrashing) - `character_name` (text)
- `effective_cache_size=16GB` - `mob` (text)
- `work_mem=16MB`, `maintenance_work_mem=1GB` - `timestamp` (timestamptz)
- `max_wal_size=4GB` - `ew`, `ns`, `z` (float)
### Retention policies - **rare_events**:
- `telemetry_events`: 30-day drop, daily - `id` (PK, serial)
- `spawn_events`: 7-day drop, daily - `character_name` (text)
- `portals`: 1-hour cleanup (background task in `main.py`) - `name` (text)
- `server_health_checks`: **removed** — was write-only, 850K rows of nothing - `timestamp` (timestamptz)
- `ew`, `ns`, `z` (float)
### Log levels - **portals**:
Both `dereth-tracker` and `inventory-service` run at `LOG_LEVEL=INFO`. Do not set to `DEBUG` in production — it dumps full inventory_delta payloads for every item update (hundreds of KB/sec). - `id` (PK, serial)
- `portal_name` (text)
- `ns`, `ew`, `z` (float coordinates)
- `discovered_at` (timestamptz, indexed)
- `discovered_by` (text)
- Unique constraint: `ROUND(ns::numeric, 1), ROUND(ew::numeric, 1)`
### Host (Proxmox VM) ### Inventory Database Tables:
- 6 vCPU, 32 GiB RAM (of which ~30 GiB is normally free under current load)
- Live host: `overlord.snakedesert.se`
- Reverse proxy: Nginx on the host terminates TLS and strips the `/api/` prefix before forwarding to port 8765
### Debug commands - **items**:
```bash - `id` (PK, serial)
docker ps - `character_name` (text, indexed)
docker logs mosswartoverlord-dereth-tracker-1 --tail 100 - `item_id` (bigint)
docker logs mosswartoverlord-inventory-service-1 --tail 100 - `name` (text)
docker logs mosswartoverlord-discord-rare-monitor-1 --tail 100 - `object_class` (integer)
docker exec dereth-db psql -U postgres -d dereth - `icon`, `value`, `burden` (integer)
``` - `current_wielded_location`, `bonded`, `attuned`, `unique` (various)
- `timestamp` (timestamptz)
- **item_combat_stats**:
- `item_id` (FK to items.id)
- `armor_level`, `max_damage` (integer)
- `damage_bonus`, `attack_bonus` (float)
- Various defense bonuses
- **item_enhancements**:
- `item_id` (FK to items.id)
- `material` (varchar)
- `item_set` (varchar)
- `tinks`, `workmanship` (integer/float)
- **item_spells**:
- `item_id` (FK to items.id)
- `spell_id` (integer)
- `spell_name` (text)
- `is_legendary`, `is_epic` (boolean)
- **item_raw_data**:
- `item_id` (FK to items.id)
- `int_values`, `double_values`, `string_values`, `bool_values` (JSONB)
- `original_json` (JSONB)
## Contributing ## Contributing
Contributions welcome. Please: Contributions are welcome! Feel free to open issues or submit pull requests.
- Keep cross-repo protocol changes additive (new optional fields > renames/removes)
- Update both this README and `CLAUDE.md` when workflows change
- Test end-to-end: plugin → backend → browser for any new event type
For detailed architecture notes and ongoing investigations, see `CLAUDE.md` and `docs/plans/`. ## Roadmap & TODO
For detailed tasks, migration steps, and future enhancements, see [TODO.md](TODO.md).
### Local Development Database
This service uses PostgreSQL with the TimescaleDB extension. You can configure local development using the provided Docker Compose setup or connect to an external instance:
1. PostgreSQL/TimescaleDB via Docker Compose (recommended):
- Pros:
- Reproducible, isolated environment out-of-the-box
- No need to install Postgres locally
- Aligns development with production setups
- Cons:
- Additional resource usage (memory, CPU)
- Slightly more complex Docker configuration
2. External PostgreSQL instance:
- Pros:
- Leverages existing infrastructure
- No Docker overhead
- Cons:
- Requires manual setup and Timescale extension
- Less portable for new contributors

View file

@ -1,146 +0,0 @@
# Overlord Agent
A small host-side Python service that gives Claude Code (running in
headless mode) access to live Overlord data so it can answer questions
from the dashboard chat window.
## Why a separate service?
`dereth-tracker` runs in Docker. The `claude` CLI binary at
`/home/erik/.local/bin/claude` depends on `~/.claude` credentials owned
by user `erik` on the host. The tracker container can't invoke it.
So this service runs **outside** Docker, listens on `127.0.0.1:8767`,
and nginx routes `/api/agent/*` to it. It validates the same browser
session cookie the tracker issues (shared `SECRET_KEY`) and shells out
to `claude -p` with `cwd=/home/erik/MosswartOverlord`.
## Architecture
```
Browser ──nginx──┬─► /api/* ──► dereth-tracker (Docker, 8765)
└─► /api/agent/* ──► overlord-agent (host, 8767)
├─► subprocess: claude -p ...
│ │
│ └─► MCP stdio ──► mcp_overlord.py
│ │
│ └─► HTTP loopback to tracker
│ └─► asyncpg to dereth-db
└─► validates "session" cookie
```
## Files
| File | What |
|------|------|
| `service.py` | FastAPI app (`/agent/health`, `/agent/sessions/new`, `/agent/ask`, `/agent/sessions/{id}/history`) |
| `auth.py` | Session-cookie validation (mirrors `main.py:1013-1019`) |
| `claude_wrapper.py` | `asyncio.create_subprocess_exec("claude", "-p", ...)` |
| `tools.py` | Pure tool implementations (HTTP loopback + read-only DB) |
| `mcp_overlord.py` | MCP stdio server registering tools for Claude Code |
| `sql/0001_overlord_agent_ro.sql` | Read-only PG role for the SQL tool |
| `overlord-agent.service` | systemd unit |
| `install.sh` | One-shot installer (venv + pip install + systemd) |
## Required env vars (in repo-root `.env`)
```
SECRET_KEY=<same value the tracker uses to sign cookies>
AGENT_DB_DSN=postgresql://overlord_agent_ro:<password>@127.0.0.1:5432/dereth
TRACKER_URL=http://127.0.0.1:8765 # optional, this is the default
CLAUDE_BIN=/home/erik/.local/bin/claude # optional, this is the default
CLAUDE_CWD=/home/erik/MosswartOverlord # optional, this is the default
CLAUDE_TIMEOUT_S=120 # optional
```
## First-time setup on the server
1. **Create the read-only DB role** (one-time):
```bash
docker exec -i dereth-db psql -U postgres -d dereth \
< /home/erik/MosswartOverlord/agent/sql/0001_overlord_agent_ro.sql
docker exec -it dereth-db psql -U postgres -d dereth \
-c "ALTER ROLE overlord_agent_ro PASSWORD '<random-password>';"
```
2. **Add `AGENT_DB_DSN`** to `/home/erik/MosswartOverlord/.env` with the
password you just set.
3. **Run the installer**:
```bash
cd /home/erik/MosswartOverlord
bash agent/install.sh
```
4. **Update nginx**: edit `/etc/nginx/sites-enabled/overlord` to add the
`/api/agent/` location (already in `nginx/overlord.conf` in the repo —
just `sudo cp` and reload).
## Day-to-day deploy
After editing any agent file:
```bash
# On dev:
git push
# On server:
ssh erik@overlord.snakedesert.se
cd /home/erik/MosswartOverlord
git pull
sudo systemctl restart overlord-agent
journalctl -u overlord-agent -f # tail logs
```
For Python dependency changes:
```bash
agent/.venv/bin/pip install -r agent/requirements.txt
sudo systemctl restart overlord-agent
```
## Smoke tests
```bash
# 1. Service alive?
curl http://127.0.0.1:8767/agent/health
# 2. Cookie required?
curl -X POST http://127.0.0.1:8767/agent/ask \
-H 'Content-Type: application/json' \
-d '{"session_id":"x","message":"hi"}'
# ⇒ 401
# 3. Direct claude invocation works?
echo "hello" | /home/erik/.local/bin/claude -p \
--session-id 11111111-1111-1111-1111-111111111111 \
--output-format json
# 4. End-to-end via nginx (with cookie):
curl -X POST https://overlord.snakedesert.se/api/agent/ask \
-b 'session=<your-session-cookie>' \
-H 'Content-Type: application/json' \
-d '{"session_id":"<uuid>","message":"How many characters are online?"}'
```
## Cost / rate-limit notes
- Each `/agent/ask` shells out to `claude -p` once.
- We use the user's Claude subscription (no API key) — flat-rate, no
per-call billing, but subscription-tier rate limits still apply.
- **Reactive only**: there are no background loops or periodic ticks.
Each user message = one Claude turn (which may chain several tool
calls internally before producing a final answer).
- The SQL tool is hard-capped at 10s and 200 rows.
- `suitbuilder_search` is the only tool that can take minutes; nginx
read timeout is 180s for `/api/agent/`.
## Adding a new MCP tool
1. Implement `async def my_tool(...) -> dict` in `tools.py`.
2. Register it in `mcp_overlord.py` under `TOOL_DEFS`:
- description (the agent reads this to decide when to call)
- JSON schema for arguments
- lambda dispatching to `T.my_tool(...)`
3. `sudo systemctl restart overlord-agent`. Claude Code re-discovers the
tool list on each invocation.

View file

@ -1,10 +0,0 @@
"""Overlord Agent — host-side service that shells out to claude -p.
Runs OUTSIDE the dereth-tracker Docker container because the `claude` CLI
binary lives at /home/erik/.local/bin/claude on the host and depends on
~/.claude/ credentials owned by user erik. The container can't invoke it
directly, so this is a small standalone FastAPI service on port 8767.
nginx routes /api/agent/* to here. The same browser session cookie that
dereth-tracker validates is reused (shared SECRET_KEY env var).
"""

View file

@ -1,51 +0,0 @@
"""Session-cookie validation that mirrors main.py.
Re-implements the verify path so this host-side service can authenticate
the same browser cookie that dereth-tracker issues. Both services must
share the SECRET_KEY env var.
"""
from __future__ import annotations
import os
from fastapi import HTTPException, Request, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
# Mirror main.py:996-998
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production-please")
SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days
_serializer = URLSafeTimedSerializer(SECRET_KEY)
def verify_session_cookie(token: str) -> dict | None:
"""Verify and decode a session token. Returns None if invalid/expired.
Mirrors main.py:1013-1019 byte-for-byte so a cookie issued by the tracker
decodes here identically.
"""
try:
data = _serializer.loads(token, max_age=SESSION_MAX_AGE)
return {"username": data["u"], "is_admin": data["a"]}
except (BadSignature, SignatureExpired, KeyError):
return None
def require_user(request: Request) -> dict:
"""FastAPI dependency: enforces a valid session cookie.
Returns the decoded user dict on success; raises 401 otherwise.
"""
token = request.cookies.get("session")
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
user = verify_session_cookie(token)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session invalid or expired",
)
return user

View file

@ -1,280 +0,0 @@
"""Subprocess wrapper around `claude -p` (Claude Code in headless JSON mode).
Run from cwd=/home/erik/MosswartOverlord so:
Sessions persist at ~/.claude/projects/-home-erik-MosswartOverlord/<uuid>.jsonl
Project-level .mcp.json is auto-loaded
CLAUDE.md in the repo root briefs the agent
The `--session-id` flag both creates a new session (first call) and resumes
an existing one (subsequent calls), so we don't need separate code paths.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# These can be overridden via env vars for non-prod testing.
CLAUDE_BIN = os.getenv("CLAUDE_BIN", "/home/erik/.local/bin/claude")
CLAUDE_CWD = os.getenv("CLAUDE_CWD", "/home/erik/MosswartOverlord")
# Hard cap on how long a single agent turn may take. Claude Code can spin a
# while when chaining many tool calls; we don't want to leave a zombie
# subprocess if something gets stuck.
CLAUDE_TIMEOUT_S = int(os.getenv("CLAUDE_TIMEOUT_S", "240"))
@dataclass
class ClaudeResult:
result: str
session_id: str
duration_ms: int
num_turns: int
is_error: bool
raw: dict[str, Any]
class ClaudeError(RuntimeError):
"""Raised when the claude CLI returns a non-zero exit or unparseable output."""
def _session_exists(session_id: str) -> bool:
"""True if Claude Code has already persisted a JSONL for this session.
Claude Code stores sessions at ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
where non-alphanumerics in the cwd are replaced with hyphens.
"""
encoded = "".join(c if c.isalnum() else "-" for c in CLAUDE_CWD)
path = Path.home() / ".claude" / "projects" / encoded / f"{session_id}.jsonl"
return path.is_file()
async def ask_claude(message: str, session_id: str) -> ClaudeResult:
"""Send `message` to `claude -p` for `session_id`; return parsed result.
On the FIRST message of a session uses `--session-id <uuid>` to create it.
On subsequent messages uses `--resume <uuid>` because claude rejects
`--session-id` on existing sessions ("Session ID ... is already in use").
Raises ClaudeError on subprocess failure, JSON parse failure, or timeout.
"""
if not Path(CLAUDE_BIN).exists():
raise ClaudeError(f"claude binary not found at {CLAUDE_BIN}")
if not Path(CLAUDE_CWD).is_dir():
raise ClaudeError(f"CLAUDE_CWD does not exist: {CLAUDE_CWD}")
# Whitelist only our MCP tools so Claude Code can call them without
# human approval. Names follow the convention mcp__<server>__<tool>.
# We deliberately omit built-in tools (Bash, Write, Edit, Read, etc.)
# — the assistant doesn't need them for live-state Q&A and they'd be a
# security/permissions footgun on an unattended service.
allowed_tools = ",".join(
[
"mcp__overlord__get_live_players",
"mcp__overlord__get_recent_rares",
"mcp__overlord__query_telemetry_db",
"mcp__overlord__get_player_state",
"mcp__overlord__get_inventory",
"mcp__overlord__get_inventory_search",
"mcp__overlord__search_items",
"mcp__overlord__get_combat_stats",
"mcp__overlord__get_equipment_cantrips",
"mcp__overlord__get_quest_status",
"mcp__overlord__get_server_health",
"mcp__overlord__suitbuilder_search",
]
)
# CRITICAL: Claude Code's built-in meta-tools (ToolSearch, Monitor, etc.)
# bypass the --allowed-tools whitelist. They come from Anthropic's tool
# registry rather than from local MCP servers. We must explicitly DISALLOW
# them — confirmed by testing that ToolSearch was reachable even with
# `--permission-mode dontAsk` and a tight --allowed-tools list.
disallowed_tools = ",".join(
[
# File / shell / search built-ins (defense in depth — already not
# in allow list, but if someone toggles permission-mode this
# belt-and-suspenders the deny side).
"Bash",
"Write",
"Edit",
"Read",
"Glob",
"Grep",
"NotebookEdit",
# Network built-ins
"WebSearch",
"WebFetch", # blocked here; settings.json re-allows acpedia.org
# Subagent spawning — the assistant must NEVER delegate to a
# general-purpose subagent (which would have its own tool set).
"Agent",
# Tool / session meta-tools — these can list, load, or chain
# into other tools and must NOT be reachable.
"ToolSearch",
"Monitor",
"TaskOutput",
"TaskStop",
"TodoWrite",
"Skill",
"EnterPlanMode",
"ExitPlanMode",
"EnterWorktree",
"ExitWorktree",
"AskUserQuestion",
"ListMcpResourcesTool",
"ReadMcpResourceTool",
"PushNotification",
# Scheduling / cron — the agent must never schedule itself.
"CronCreate",
"CronList",
"CronDelete",
"ScheduleWakeup",
"RemoteTrigger",
# Anthropic first-party connectors from the user's claude.ai
# account. These are off-mission for an Overlord assistant and
# would leak personal data outside the game-state domain.
"mcp__claude_ai_Gmail__create_draft",
"mcp__claude_ai_Gmail__create_label",
"mcp__claude_ai_Gmail__get_message",
"mcp__claude_ai_Gmail__get_thread",
"mcp__claude_ai_Gmail__list_drafts",
"mcp__claude_ai_Gmail__list_labels",
"mcp__claude_ai_Gmail__label_message",
"mcp__claude_ai_Gmail__label_thread",
"mcp__claude_ai_Gmail__search_messages",
"mcp__claude_ai_Gmail__search_threads",
"mcp__claude_ai_Gmail__send_message",
"mcp__claude_ai_Gmail__unlabel_message",
"mcp__claude_ai_Gmail__unlabel_thread",
"mcp__claude_ai_Google_Calendar__authenticate",
"mcp__claude_ai_Google_Drive__authenticate",
]
)
# Pick --session-id (creates) vs --resume (continues) based on whether
# the session JSONL already exists on disk.
is_new = not _session_exists(session_id)
session_flag = "--session-id" if is_new else "--resume"
args = [
CLAUDE_BIN,
"-p",
session_flag,
session_id,
"--output-format",
"json",
"--allowed-tools",
allowed_tools,
# Built-in meta-tools that --allowed-tools does NOT block — must
# be explicitly listed here.
"--disallowed-tools",
disallowed_tools,
# CRITICAL: dontAsk auto-DENIES anything outside --allowed-tools.
# Do NOT use bypassPermissions here — that mode ignores the whitelist
# entirely and lets the model call Bash/Write/Edit/etc. (verified
# the hard way: it wrote /tmp/owned.sh when prompted to).
# See https://code.claude.com/docs/en/permission-modes.md
"--permission-mode",
"dontAsk",
]
logger.info(
"claude exec: session=%s mode=%s msg_len=%d cwd=%s",
session_id,
"new" if is_new else "resume",
len(message),
CLAUDE_CWD,
)
proc = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=CLAUDE_CWD,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(input=message.encode("utf-8")),
timeout=CLAUDE_TIMEOUT_S,
)
except asyncio.TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
raise ClaudeError(f"claude timed out after {CLAUDE_TIMEOUT_S}s")
if proc.returncode != 0:
stderr_text = stderr.decode("utf-8", "replace")
# If we picked the wrong flag (e.g. JSONL deleted from disk between
# our check and exec, or a never-flushed session), claude prints
# "Session ID … is already in use." Re-issue with --resume.
if is_new and "already in use" in stderr_text:
logger.info("session %s actually exists; retrying with --resume", session_id)
args2 = list(args)
args2[2] = "--resume"
proc2 = await asyncio.create_subprocess_exec(
*args2,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=CLAUDE_CWD,
)
try:
stdout, stderr = await asyncio.wait_for(
proc2.communicate(input=message.encode("utf-8")),
timeout=CLAUDE_TIMEOUT_S,
)
except asyncio.TimeoutError:
try:
proc2.kill()
except ProcessLookupError:
pass
raise ClaudeError(f"claude timed out after {CLAUDE_TIMEOUT_S}s")
if proc2.returncode != 0:
raise ClaudeError(
f"claude exited {proc2.returncode} after retry: "
f"{stderr.decode('utf-8', 'replace')[:500]}"
)
else:
raise ClaudeError(
f"claude exited {proc.returncode}: {stderr_text[:500]}"
)
raw_text = stdout.decode("utf-8", "replace").strip()
if not raw_text:
raise ClaudeError("claude produced empty stdout")
# In --output-format json mode the LAST line is the JSON envelope; some
# earlier lines may be progress. Be tolerant.
try:
envelope = json.loads(raw_text)
except json.JSONDecodeError:
# Try the last non-empty line
last = next(
(line for line in reversed(raw_text.splitlines()) if line.strip()),
"",
)
try:
envelope = json.loads(last)
except json.JSONDecodeError as e:
raise ClaudeError(
f"claude stdout was not JSON: {raw_text[:500]}"
) from e
return ClaudeResult(
result=envelope.get("result", ""),
session_id=envelope.get("session_id", session_id),
duration_ms=int(envelope.get("duration_ms", 0)),
num_turns=int(envelope.get("num_turns", 0)),
is_error=bool(envelope.get("is_error", False)),
raw=envelope,
)

View file

@ -1,46 +0,0 @@
#!/bin/bash
# Install / re-install the Overlord Agent host-side service.
#
# Run as user `erik` from /home/erik/MosswartOverlord:
# bash agent/install.sh
#
# Requires sudo for the systemd parts (you'll be prompted once).
set -euo pipefail
REPO_DIR="/home/erik/MosswartOverlord"
AGENT_DIR="$REPO_DIR/agent"
VENV_DIR="$AGENT_DIR/.venv"
SERVICE_FILE="$AGENT_DIR/overlord-agent.service"
SYSTEMD_TARGET="/etc/systemd/system/overlord-agent.service"
if [[ "$(pwd)" != "$REPO_DIR" ]]; then
echo "Run from $REPO_DIR (currently in $(pwd))" >&2
exit 1
fi
echo "==> Creating/updating venv at $VENV_DIR"
if [[ ! -d "$VENV_DIR" ]]; then
python3 -m venv "$VENV_DIR"
fi
"$VENV_DIR/bin/pip" install --quiet --upgrade pip
"$VENV_DIR/bin/pip" install --quiet -r "$AGENT_DIR/requirements.txt"
echo "==> Installing systemd unit"
sudo cp "$SERVICE_FILE" "$SYSTEMD_TARGET"
sudo systemctl daemon-reload
echo "==> Enabling + starting overlord-agent"
sudo systemctl enable overlord-agent
sudo systemctl restart overlord-agent
sleep 1
echo "==> Status:"
sudo systemctl --no-pager status overlord-agent | head -15
echo ""
echo "==> Smoke test:"
curl -s http://127.0.0.1:8767/agent/health | python3 -m json.tool || true
echo ""
echo "Done. Logs: journalctl -u overlord-agent -f"

View file

@ -1,293 +0,0 @@
"""MCP stdio server exposing Overlord data to Claude Code.
Configured via .mcp.json at the repo root, which Claude Code auto-loads
when invoked with cwd=/home/erik/MosswartOverlord. Tool implementations
live in tools.py this file is just MCP protocol plumbing.
Run directly with:
python3 /home/erik/MosswartOverlord/agent/mcp_overlord.py
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import TextContent, Tool
from . import tools as T
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s mcp_overlord: %(message)s",
)
logger = logging.getLogger("mcp_overlord")
server: Server = Server("overlord")
# ─── Tool registry ──────────────────────────────────────────────────
#
# Each entry: name → (description, JSON schema, callable async fn).
# We register them with @server.list_tools / @server.call_tool below.
TOOL_DEFS: dict[str, dict[str, Any]] = {
"get_live_players": {
"description": (
"Return active characters seen in the last ~30 seconds with their "
"current position, kills, KPH, vitae, online time, and VTank state. "
"Use this for any 'who is online right now / what is X doing' question."
),
"schema": {"type": "object", "properties": {}},
"fn": lambda _args: T.get_live_players(),
},
"get_recent_rares": {
"description": (
"Return rare item finds from the last N hours, newest first. "
"Use for questions about recent drops, who is finding rares, or "
"rare-rate analysis. Defaults to 24 hours, max 30 days."
),
"schema": {
"type": "object",
"properties": {
"hours": {
"type": "integer",
"minimum": 1,
"maximum": 720,
"default": 24,
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 100,
},
},
},
"fn": lambda args: T.get_recent_rares(
hours=int(args.get("hours", 24)),
limit=int(args.get("limit", 100)),
),
},
"query_telemetry_db": {
"description": (
"Run a read-only SQL query against the telemetry database (TimescaleDB). "
"Only SELECT / WITH statements are accepted; any DML or DDL is rejected. "
"Useful for questions that aren't covered by the other tools — top-N "
"lists, custom aggregations, time-window comparisons. "
"Available tables include: telemetry_events (hypertable, 30d retention), "
"rare_events, spawn_events (hypertable, 7d retention), portals, "
"char_stats, rare_stats, rare_stats_sessions, character_stats, "
"combat_stats, combat_stats_sessions, server_status. "
"The query has a 10s timeout and returns at most 200 rows."
),
"schema": {
"type": "object",
"required": ["sql"],
"properties": {
"sql": {
"type": "string",
"description": "A single PostgreSQL SELECT or WITH ... SELECT statement.",
}
},
},
"fn": lambda args: T.query_telemetry_db(str(args["sql"])),
},
"get_player_state": {
"description": (
"Combined snapshot for ONE character: live telemetry (if online) "
"+ full character stats (attributes, skills, augmentations). "
"Use this for questions like 'what is X doing right now' or 'show me X's stats'."
),
"schema": {
"type": "object",
"required": ["character_name"],
"properties": {
"character_name": {"type": "string"},
},
},
"fn": lambda args: T.get_player_state(str(args["character_name"])),
},
"get_inventory": {
"description": (
"Full inventory listing for one character — every item with name, "
"icon, container, equipped slot, spells, material, tinkers, etc. "
"Large response — prefer get_inventory_search for narrow queries."
),
"schema": {
"type": "object",
"required": ["character_name"],
"properties": {"character_name": {"type": "string"}},
},
"fn": lambda args: T.get_inventory(str(args["character_name"])),
},
"get_inventory_search": {
"description": (
"Filtered inventory search for ONE character. Use search_items "
"instead when the user wants to find something across ALL chars."
),
"schema": {
"type": "object",
"required": ["character_name"],
"properties": {
"character_name": {"type": "string"},
"filters": {
"type": "object",
"description": "Query params dict, e.g. {\"name\": \"pearl\", \"armor_level_min\": 500}",
},
},
},
"fn": lambda args: T.get_inventory_search(
str(args["character_name"]), args.get("filters") or {}
),
},
"search_items": {
"description": (
"CROSS-CHARACTER item search — one query that scans every "
"character's inventory. Use this whenever the user asks "
"'find me an X on any of my chars'. **Do not** iterate "
"get_inventory per character — this single tool call is far "
"faster and avoids agent timeouts.\n\n"
"Filter keys (pass as `filters` object, all optional):\n"
" include_all_characters: true (default if no scope given)\n"
" character: 'Name' (single char)\n"
" characters: 'A,B,C' (specific list, comma-separated)\n"
" text: substring of item name/description\n"
" has_spell: 'Legendary Acid Ward' (exact spell name match)\n"
" spell_contains: 'Legendary' (substring)\n"
" legendary_cantrips: 'Foo,Bar'\n"
" equipment_status: 'equipped' | 'unequipped'\n"
" equipment_slot: int bitmask (4=chest, 2048=bracelet, 4096=ring)\n"
" slot_names: 'Bracelet,Ring'\n"
" armor_only / jewelry_only / weapon_only: bool\n"
" min_armor / max_armor / min_damage / max_damage: int\n"
),
"schema": {
"type": "object",
"required": ["filters"],
"properties": {
"filters": {
"type": "object",
"description": "Query params dict — see tool description for keys.",
},
},
},
"fn": lambda args: T.search_items_global(args.get("filters") or {}),
},
"get_combat_stats": {
"description": (
"Lifetime + session combat stats for one character. Includes total "
"damage given/received, per-element offense/defense breakdown, kill "
"counts, and aetheria surge counts."
),
"schema": {
"type": "object",
"required": ["character_name"],
"properties": {"character_name": {"type": "string"}},
},
"fn": lambda args: T.get_combat_stats(str(args["character_name"])),
},
"get_equipment_cantrips": {
"description": (
"Currently-equipped items for a character along with their active "
"cantrip/spell state. Useful for 'what is X wearing' or 'is X "
"running their suit' questions."
),
"schema": {
"type": "object",
"required": ["character_name"],
"properties": {"character_name": {"type": "string"}},
},
"fn": lambda args: T.get_equipment_cantrips(str(args["character_name"])),
},
"get_quest_status": {
"description": (
"Active quest timers and progress across ALL characters. Returns "
"for each character which quests are READY vs counting down."
),
"schema": {"type": "object", "properties": {}},
"fn": lambda _args: T.get_quest_status(),
},
"get_server_health": {
"description": (
"Current Coldeve game-server status: up/down, latency in ms, "
"current player count from TreeStats.net, total uptime. Updated "
"every 30 seconds in the background."
),
"schema": {"type": "object", "properties": {}},
"fn": lambda _args: T.get_server_health(),
},
"suitbuilder_search": {
"description": (
"Run a constraint-satisfaction armor optimization across all "
"characters' inventories ('mules'). Drives the same suitbuilder "
"the /suitbuilder.html page uses. Pass the same params dict the "
"page sends — see /suitbuilder.html JS for the schema. The search "
"is SSE-streaming on the backend; this tool collects until done "
"and returns the final suit(s) plus the last few phase events. "
"Can take up to 5 minutes for complex constraints — only call "
"when the user explicitly asks for an optimization run."
),
"schema": {
"type": "object",
"required": ["params"],
"properties": {
"params": {
"type": "object",
"description": "Suitbuilder request body (characters, locked slots, set constraints, etc.)",
},
},
},
"fn": lambda args: T.suitbuilder_search(args.get("params") or {}),
},
}
# ─── MCP protocol wiring ────────────────────────────────────────────
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(name=name, description=defn["description"], inputSchema=defn["schema"])
for name, defn in TOOL_DEFS.items()
]
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
if name not in TOOL_DEFS:
return [TextContent(type="text", text=f"unknown tool: {name}")]
fn = TOOL_DEFS[name]["fn"]
try:
result = await fn(arguments or {})
except T.SqlNotAllowed as e:
return [TextContent(type="text", text=f"REJECTED: {e}")]
except Exception as e: # noqa: BLE001
logger.exception("tool %s failed", name)
return [TextContent(type="text", text=f"ERROR: {type(e).__name__}: {e}")]
text = json.dumps(result, default=str, ensure_ascii=False, indent=2)
return [TextContent(type="text", text=text)]
async def _run() -> None:
logger.info("starting MCP stdio server (overlord)")
try:
async with stdio_server() as (reader, writer):
await server.run(reader, writer, server.create_initialization_options())
finally:
await T.shutdown()
def main() -> None:
asyncio.run(_run())
if __name__ == "__main__":
main()

View file

@ -1,113 +0,0 @@
[Unit]
Description=Overlord Agent (Claude Code shell-out service)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Dedicated unprivileged user — kernel-level isolation from `erik`.
# overlord-agent has NO access to /home/erik/.claude (mode 0700),
# /home/erik/.ssh, /home/erik/.bash_history, /home/erik/.gitconfig, etc.
# Its own claude state lives at /var/lib/overlord-agent/.claude/ and its
# claude session JSONLs land there — completely separate from any
# interactive Claude Code use by the human user.
User=overlord-agent
Group=overlord-agent
# Working directory: the repo root (group-readable to overlord-agent).
# claude session JSONLs path-encode this cwd so it's important to keep
# stable across restarts.
WorkingDirectory=/home/erik/MosswartOverlord
# HOME explicitly set so claude reads /var/lib/overlord-agent/.claude/*
# instead of trying /home/erik/.claude/* (which is now 0700, locked out).
Environment="HOME=/var/lib/overlord-agent"
# Secrets file (root:overlord-agent 0640).
EnvironmentFile=-/etc/overlord/agent.env
# Run inside the venv populated by install.sh.
ExecStart=/home/erik/MosswartOverlord/agent/.venv/bin/python -m agent.service
Restart=on-failure
RestartSec=3
StandardOutput=journal
StandardError=journal
# ─── Resource caps ─────────────────────────────────────────────────
MemoryMax=512M
CPUQuota=200%
TasksMax=128
# ─── Filesystem hardening ──────────────────────────────────────────
# /usr, /boot, /efi become read-only; /etc + /var get a writable overlay
# that's discarded on stop. Subprocesses inherit these protections.
ProtectSystem=strict
ProtectHome=read-only
# Allow writing only to the explicit paths claude / our service need.
# - ~/.claude — session JSONL files
# - .venv pycache — minor pip cache writes
ReadWritePaths=/var/lib/overlord-agent/.claude
ReadWritePaths=/home/erik/MosswartOverlord/agent/.venv
ReadWritePaths=/var/log/overlord-agent
# StateDirectory creates/owns /var/lib/overlord-agent automatically.
StateDirectory=overlord-agent
LogsDirectory=overlord-agent
LogsDirectoryMode=0755
PrivateTmp=true
PrivateDevices=true
ProtectClock=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pid
# Hide sensitive host paths even if something in the python or claude
# subprocess tree tries to read them.
InaccessiblePaths=/etc/shadow
InaccessiblePaths=/etc/gshadow
InaccessiblePaths=/etc/ssh
InaccessiblePaths=/root
InaccessiblePaths=-/home/erik/.ssh
InaccessiblePaths=-/home/erik/.bash_history
InaccessiblePaths=-/home/erik/.zsh_history
# ─── Privilege & capability hardening ──────────────────────────────
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=true
RestrictRealtime=true
RestrictSUIDSGID=true
RemoveIPC=true
# MemoryDenyWriteExecute would break Node.js (V8 JIT requires W^X
# transitions via mprotect with PROT_EXEC on JITted code pages). Claude
# Code is a Node app, so omit this. Without JIT we'd lose all model
# performance. The other restrictions still prevent shellcode injection
# in practice (no Bash/Write tools, no shellcraft surface).
# MemoryDenyWriteExecute=true ← DO NOT enable; breaks Node V8 JIT
RestrictNamespaces=true
# ─── Network family restriction ────────────────────────────────────
# Block raw/packet sockets so even a kernel-LPE-class bug can't sniff
# traffic or forge packets. We don't IPAddressAllow-restrict because
# Anthropic's Cloudflare IPs shift and the whitelist would break claude.
# If you need true egress filtering, run nftables scoped to this
# service's cgroup — that's reliable in a way IPAddressAllow isn't.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
# ─── Syscall filter ────────────────────────────────────────────────
# Use the standard @system-service preset which is what almost every
# hardened systemd unit uses. It already excludes the dangerous groups
# (privileged, mount, reboot, raw-io, etc.) by NOT including them, while
# being broad enough to host typical apps including Node.js.
#
# We tried adding extra "~@..." negations on top — they killed Claude
# (Node) with SIGSYS during startup. The default @system-service preset
# is the right balance; the rest of the hardening covers what we need.
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged
SystemCallFilter=~@reboot
SystemCallFilter=~@mount
[Install]
WantedBy=multi-user.target

View file

@ -1,13 +0,0 @@
fastapi>=0.110
uvicorn[standard]>=0.30
httpx>=0.27
itsdangerous>=2.2
pydantic>=2.6
# MCP server SDK (used by mcp_overlord.py for the stdio MCP server)
mcp>=1.0
# SQL safety: parses SQL to enforce read-only on the query_db tool
sqlglot>=25.0
# Direct DB access for the read-only query tool and rare_events lookups
asyncpg>=0.29
# .env loader
python-dotenv>=1.0

View file

@ -1,347 +0,0 @@
"""Overlord Agent host-side FastAPI service.
Runs OUTSIDE Docker (host-side) on port 8767.
Endpoints:
GET /agent/health liveness check
POST /agent/sessions/new returns a fresh session UUID
POST /agent/ask runs claude -p with given session
GET /agent/sessions/{session_id}/history
replays a session's JSONL on disk
Auth: every endpoint except /health requires the same browser session
cookie that dereth-tracker issues.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
import uuid
from collections import deque
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from . import auth
from .claude_wrapper import CLAUDE_CWD, ClaudeError, ask_claude
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("agent")
# Audit log — every /agent/ask request gets a JSONL line here, separate
# from journald so the operator can grep without root. Set to /dev/null
# to disable. Rotated externally (logrotate) if it gets big.
AUDIT_LOG_PATH = Path(os.getenv("AGENT_AUDIT_LOG", "/var/log/overlord-agent/audit.jsonl"))
audit_logger = logging.getLogger("agent.audit")
try:
AUDIT_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
_h = logging.FileHandler(AUDIT_LOG_PATH)
_h.setFormatter(logging.Formatter("%(message)s"))
audit_logger.addHandler(_h)
audit_logger.propagate = False
audit_logger.setLevel(logging.INFO)
except OSError as e:
logger.warning("audit log path %s not writable (%s); logging only via journal", AUDIT_LOG_PATH, e)
# Rate limit: per-user count over a rolling window. Defaults are generous
# for a single human at a keyboard but block automated abuse.
RATE_LIMIT_WINDOW_S = int(os.getenv("AGENT_RATE_WINDOW_S", "3600"))
RATE_LIMIT_MAX = int(os.getenv("AGENT_RATE_MAX", "60"))
# Per-user concurrent request cap (no fanning out 50 calls in parallel).
CONCURRENCY_LIMIT_PER_USER = int(os.getenv("AGENT_CONCURRENCY_PER_USER", "1"))
# Rolling timestamps of recent /agent/ask calls per user.
_rate_state: dict[str, deque[float]] = {}
# Per-user semaphores so a single user can't run multiple concurrent claude
# subprocesses (each is expensive).
_user_semaphores: dict[str, asyncio.Semaphore] = {}
def _check_rate_limit(username: str) -> tuple[bool, int]:
"""Return (allowed, retry_after_seconds)."""
now = time.monotonic()
window = _rate_state.setdefault(username, deque())
cutoff = now - RATE_LIMIT_WINDOW_S
while window and window[0] < cutoff:
window.popleft()
if len(window) >= RATE_LIMIT_MAX:
retry_after = int(window[0] + RATE_LIMIT_WINDOW_S - now) + 1
return False, retry_after
window.append(now)
return True, 0
def _user_semaphore(username: str) -> asyncio.Semaphore:
sem = _user_semaphores.get(username)
if sem is None:
sem = asyncio.Semaphore(CONCURRENCY_LIMIT_PER_USER)
_user_semaphores[username] = sem
return sem
def _audit(event: dict[str, Any]) -> None:
"""Emit one JSONL line to the audit log."""
event["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
try:
audit_logger.info(json.dumps(event, ensure_ascii=False))
except Exception: # noqa: BLE001
pass
app = FastAPI(title="Overlord Agent", version="0.1.0")
# ─── Models ──────────────────────────────────────────────────────────
class AskRequest(BaseModel):
session_id: str = Field(
..., description="Stable per-conversation UUID stored in browser localStorage"
)
message: str = Field(..., min_length=1, max_length=10_000)
class AskResponse(BaseModel):
result: str
session_id: str
duration_ms: int
num_turns: int
is_error: bool
class NewSessionResponse(BaseModel):
session_id: str
# ─── Helpers ─────────────────────────────────────────────────────────
def _encode_cwd(cwd: str) -> str:
"""Match Claude Code's on-disk encoding for cwd → directory name.
Claude Code stores sessions at ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
where non-alphanumerics in the cwd are replaced with hyphens.
Example: /home/erik/MosswartOverlord -home-erik-MosswartOverlord
"""
return "".join(c if c.isalnum() else "-" for c in cwd)
def _sessions_dir() -> Path:
return Path.home() / ".claude" / "projects" / _encode_cwd(CLAUDE_CWD)
# ─── Endpoints ───────────────────────────────────────────────────────
@app.get("/agent/health")
async def health() -> dict:
"""Liveness probe — no auth, used by deployment scripts."""
return {
"status": "ok",
"claude_cwd": CLAUDE_CWD,
"sessions_dir_exists": _sessions_dir().exists(),
}
@app.post("/agent/sessions/new", response_model=NewSessionResponse)
async def new_session(_user: dict = Depends(auth.require_user)) -> NewSessionResponse:
"""Generate a fresh session UUID. Doesn't touch disk — claude creates the
JSONL file when the first message lands."""
return NewSessionResponse(session_id=str(uuid.uuid4()))
@app.post("/agent/ask", response_model=AskResponse)
async def agent_ask(
req: AskRequest, user: dict = Depends(auth.require_user)
) -> AskResponse:
"""Forward a message to claude -p resuming the given session.
Enforces:
* Per-user rate limit (60 requests/hour by default).
* Per-user concurrency cap (1 in-flight at a time by default).
* Audit log of every request (JSONL).
"""
username = user["username"]
# Rate limit BEFORE acquiring the user semaphore — cheaper to reject.
allowed, retry_after = _check_rate_limit(username)
if not allowed:
_audit(
{
"event": "rate_limited",
"user": username,
"session_id": req.session_id,
"retry_after_s": retry_after,
}
)
raise HTTPException(
status_code=429,
detail=f"Rate limit exceeded; retry in {retry_after}s",
headers={"Retry-After": str(retry_after)},
)
sem = _user_semaphore(username)
if sem.locked():
_audit(
{
"event": "concurrency_blocked",
"user": username,
"session_id": req.session_id,
}
)
raise HTTPException(
status_code=429, detail="A previous question is still being processed"
)
started = time.monotonic()
async with sem:
_audit(
{
"event": "ask_start",
"user": username,
"session_id": req.session_id,
"message": req.message[:500],
"message_len": len(req.message),
}
)
try:
result = await ask_claude(req.message, req.session_id)
except ClaudeError as e:
elapsed_ms = int((time.monotonic() - started) * 1000)
logger.warning(
"claude failed user=%s session=%s err=%s", username, req.session_id, e
)
_audit(
{
"event": "ask_error",
"user": username,
"session_id": req.session_id,
"error": str(e)[:500],
"elapsed_ms": elapsed_ms,
}
)
raise HTTPException(status_code=502, detail=str(e))
elapsed_ms = int((time.monotonic() - started) * 1000)
logger.info(
"ask user=%s session=%s turns=%d duration_ms=%d (subprocess=%dms)",
username,
result.session_id,
result.num_turns,
elapsed_ms,
result.duration_ms,
)
_audit(
{
"event": "ask_ok",
"user": username,
"session_id": result.session_id,
"result_preview": (result.result or "")[:300],
"result_len": len(result.result or ""),
"turns": result.num_turns,
"elapsed_ms": elapsed_ms,
"subprocess_ms": result.duration_ms,
"is_error": result.is_error,
}
)
return AskResponse(
result=result.result,
session_id=result.session_id,
duration_ms=result.duration_ms,
num_turns=result.num_turns,
is_error=result.is_error,
)
@app.get("/agent/sessions/{session_id}/history")
async def session_history(
session_id: str, _user: dict = Depends(auth.require_user)
) -> JSONResponse:
"""Replay a session's JSONL from ~/.claude/projects/.../<id>.jsonl.
Returns a flat array of {role, text, timestamp} for the chat window.
Returns an empty array if the session file doesn't exist yet.
"""
# UUID sanity check to prevent path traversal — claude Code uses uuid4
try:
uuid.UUID(session_id)
except ValueError:
raise HTTPException(status_code=400, detail="invalid session_id")
path = _sessions_dir() / f"{session_id}.jsonl"
if not path.is_file():
return JSONResponse({"messages": []})
messages: list[dict[str, Any]] = []
try:
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
# Claude Code records turns with type=user / type=assistant.
# Tool-use traffic is verbose; skip it for the chat UI.
msg_type = obj.get("type")
if msg_type not in ("user", "assistant"):
continue
msg = obj.get("message") or {}
content = msg.get("content")
# `content` may be a string or list[{type,text}].
if isinstance(content, str):
text = content
elif isinstance(content, list):
text = "".join(
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
else:
text = ""
if not text:
continue
messages.append(
{
"role": msg_type,
"text": text,
"timestamp": obj.get("timestamp"),
}
)
except OSError as e:
logger.warning("failed to read session %s: %s", session_id, e)
raise HTTPException(status_code=500, detail="failed to read session")
return JSONResponse({"messages": messages})
# ─── Entrypoint ──────────────────────────────────────────────────────
def main() -> None:
"""Run via `python -m agent.service` for local testing."""
import uvicorn
uvicorn.run(
"agent.service:app",
host="127.0.0.1",
port=8767,
log_level="info",
)
if __name__ == "__main__":
main()

View file

@ -1,35 +0,0 @@
-- Read-only PG role for the Overlord Agent's `query_telemetry_db` MCP tool.
--
-- This is the second line of defense (the first is the sqlglot parser in
-- agent/tools.py:assert_read_only). Even a parser bypass cannot mutate
-- because this role only has SELECT.
--
-- Apply on the dereth-db container:
-- docker exec dereth-db psql -U postgres -d dereth -f - < agent/sql/0001_overlord_agent_ro.sql
-- (substitute the password before running, or keep as a placeholder and
-- ALTER ROLE … PASSWORD '…' separately)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'overlord_agent_ro') THEN
CREATE ROLE overlord_agent_ro NOINHERIT LOGIN PASSWORD 'change-me-set-via-alter-role';
END IF;
END$$;
GRANT CONNECT ON DATABASE dereth TO overlord_agent_ro;
GRANT USAGE ON SCHEMA public TO overlord_agent_ro;
-- Grant SELECT on all current public tables.
GRANT SELECT ON ALL TABLES IN SCHEMA public TO overlord_agent_ro;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO overlord_agent_ro;
-- And on any future tables created in public.
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO overlord_agent_ro;
-- TimescaleDB-internal schema (chunks live here). Read on hypertable chunks
-- requires SELECT on _timescaledb_internal too.
GRANT USAGE ON SCHEMA _timescaledb_internal TO overlord_agent_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA _timescaledb_internal TO overlord_agent_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA _timescaledb_internal
GRANT SELECT ON TABLES TO overlord_agent_ro;

View file

@ -1,451 +0,0 @@
"""Tool implementations exposed to Claude via the MCP server.
These are pure functions the MCP server (mcp_overlord.py) only handles
the protocol wrapping. Keep tool logic here so it's easy to test in
isolation and reuse from elsewhere (e.g. /agent/ask shortcuts).
Two flavors of data access:
* HTTP loopback to the dereth-tracker container (for endpoints that
already exist and have validated logic).
* Direct asyncpg to the read-only PG role for ad-hoc queries
(rare_events, telemetry, anything not exposed via HTTP).
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any
from urllib.parse import quote
import asyncpg
import httpx
import sqlglot
import sqlglot.errors
import sqlglot.expressions as exp
logger = logging.getLogger(__name__)
# The dereth-tracker FastAPI app, reachable from the host because Docker
# port-forwards 127.0.0.1:8765:8765 in docker-compose.yml.
TRACKER_URL = os.getenv("TRACKER_URL", "http://127.0.0.1:8765")
# Read-only PG role; see deployment plan.
DB_DSN = os.getenv(
"AGENT_DB_DSN",
"postgresql://overlord_agent_ro@127.0.0.1:5432/dereth",
)
# Hard caps for the SQL tool to keep the agent honest.
SQL_TIMEOUT_S = float(os.getenv("AGENT_SQL_TIMEOUT_S", "10"))
SQL_MAX_ROWS = int(os.getenv("AGENT_SQL_MAX_ROWS", "200"))
# ─── HTTP loopback helpers ──────────────────────────────────────────
_http_client: httpx.AsyncClient | None = None
async def _http() -> httpx.AsyncClient:
"""Lazily create + reuse a single httpx client (connection pool)."""
global _http_client
if _http_client is None:
_http_client = httpx.AsyncClient(base_url=TRACKER_URL, timeout=30.0)
return _http_client
async def _get_json(path: str) -> Any:
client = await _http()
resp = await client.get(path)
resp.raise_for_status()
return resp.json()
# ─── DB helpers ─────────────────────────────────────────────────────
_db_pool: asyncpg.Pool | None = None
async def _db() -> asyncpg.Pool:
global _db_pool
if _db_pool is None:
_db_pool = await asyncpg.create_pool(
DB_DSN, min_size=1, max_size=4, command_timeout=SQL_TIMEOUT_S
)
return _db_pool
# ─── SQL safety ─────────────────────────────────────────────────────
_ALLOWED_TOPLEVEL = tuple(
cls for cls in (
getattr(exp, "Select", None),
getattr(exp, "With", None),
getattr(exp, "Union", None),
getattr(exp, "Subquery", None),
getattr(exp, "Intersect", None),
getattr(exp, "Except", None),
)
if cls is not None
)
class SqlNotAllowed(ValueError):
"""Raised when the agent attempts a non-read-only SQL statement."""
def assert_read_only(sql: str) -> None:
"""Parse `sql` and reject anything that isn't a read query.
Belt-and-suspenders: the PG role is also read-only (GRANT SELECT only),
so even a parser bypass can't actually mutate. This is the first line
of defense friendlier error messages and faster reject.
"""
try:
statements = sqlglot.parse(sql, read="postgres")
except sqlglot.errors.ParseError as e:
raise SqlNotAllowed(f"SQL parse error: {e}") from e
if not statements:
raise SqlNotAllowed("empty SQL")
if len(statements) > 1:
raise SqlNotAllowed("only one statement allowed")
stmt = statements[0]
if stmt is None:
raise SqlNotAllowed("empty parse result")
if not isinstance(stmt, _ALLOWED_TOPLEVEL):
raise SqlNotAllowed(
f"only SELECT / WITH allowed, got {type(stmt).__name__}"
)
# Walk the tree and reject any DML/DDL hidden inside (e.g. CTE with
# INSERT — yes, postgres allows that). Use getattr so version drift
# in sqlglot (renamed classes like AlterTable→Alter) doesn't crash
# the whole tool.
_DENY_NAMES = (
"Insert", "Update", "Delete", "Drop", "Create", "Merge",
"Alter", "AlterTable", "AlterColumn", "AlterDatabase",
"Truncate", "TruncateTable",
"Grant", "Revoke",
"Copy", # PostgreSQL COPY can write files
)
deny_classes = tuple(
cls for cls in (getattr(exp, name, None) for name in _DENY_NAMES)
if cls is not None
)
for node in stmt.walk():
# walk() returns the node, then in some sqlglot versions a tuple of
# (node, parent, key). Normalize.
actual = node[0] if isinstance(node, tuple) else node
if isinstance(actual, deny_classes):
raise SqlNotAllowed(
f"writes/DDL not allowed (found {type(actual).__name__})"
)
# ─── Tools ──────────────────────────────────────────────────────────
async def get_live_players() -> dict[str, Any]:
"""Active characters (telemetry seen in the last ~30s).
Returns the same shape as `GET /live`:
{ "players": [ { character_name, ew, ns, z, kills, ... } ] }
"""
return await _get_json("/live")
async def get_recent_rares(hours: int = 24, limit: int = 100) -> dict[str, Any]:
"""Rare item finds in the last N hours, newest first."""
hours = max(1, min(int(hours), 24 * 30)) # cap at 30 days
limit = max(1, min(int(limit), SQL_MAX_ROWS))
pool = await _db()
rows = await pool.fetch(
"""
SELECT timestamp, character_name, name, ew, ns, z
FROM rare_events
WHERE timestamp >= NOW() - ($1::int || ' hours')::interval
ORDER BY timestamp DESC
LIMIT $2
""",
hours,
limit,
)
return {
"hours": hours,
"count": len(rows),
"rares": [
{
"timestamp": r["timestamp"].isoformat(),
"character_name": r["character_name"],
"name": r["name"],
"ew": r["ew"],
"ns": r["ns"],
"z": r["z"],
}
for r in rows
],
}
async def query_telemetry_db(sql: str) -> dict[str, Any]:
"""Run a read-only SQL statement against the telemetry DB.
The query is parsed and any non-SELECT/WITH statement is rejected.
The connection role is also GRANT SELECT only (defense in depth).
Useful for ad-hoc questions: "top 5 KPH today", "kill count by character
yesterday", etc.
"""
assert_read_only(sql)
pool = await _db()
try:
rows = await asyncio.wait_for(pool.fetch(sql), timeout=SQL_TIMEOUT_S)
except asyncio.TimeoutError:
raise SqlNotAllowed(f"query exceeded {SQL_TIMEOUT_S:.0f}s timeout")
if len(rows) > SQL_MAX_ROWS:
rows = rows[:SQL_MAX_ROWS]
truncated = True
else:
truncated = False
return {
"row_count": len(rows),
"truncated": truncated,
"rows": [
{k: _json_safe(v) for k, v in dict(r).items()} for r in rows
],
}
def _json_safe(v: Any) -> Any:
"""Convert datetime / Decimal / etc. to JSON-friendly types."""
from datetime import date, datetime, timedelta
from decimal import Decimal
if v is None:
return None
if isinstance(v, (str, int, float, bool)):
return v
if isinstance(v, (datetime, date)):
return v.isoformat()
if isinstance(v, timedelta):
return v.total_seconds()
if isinstance(v, Decimal):
return float(v)
if isinstance(v, (list, tuple)):
return [_json_safe(x) for x in v]
if isinstance(v, dict):
return {k: _json_safe(x) for k, x in v.items()}
return str(v)
# ─── Per-character lookups (HTTP loopback) ──────────────────────────
async def get_player_state(character_name: str) -> dict[str, Any]:
"""Combined snapshot for one character: live telemetry + character stats.
Returns:
{
"character_name": str,
"telemetry": {...} | None, # from /live, or None if offline
"character_stats": {...} | None, # from /character-stats/<name>
"vitals": {...} | None, # last vitals from /live (subset)
"online": bool, # whether telemetry was found in /live
}
"""
name = character_name.strip()
live = await _get_json("/live")
players = live.get("players", []) if isinstance(live, dict) else []
telemetry = next(
(p for p in players if p.get("character_name") == name), None
)
char_stats: dict[str, Any] | None = None
try:
client = await _http()
resp = await client.get(f"/character-stats/{quote(name, safe='')}")
if resp.status_code == 200:
char_stats = resp.json()
except Exception:
char_stats = None
return {
"character_name": name,
"online": telemetry is not None,
"telemetry": telemetry,
"character_stats": char_stats,
}
async def get_inventory(character_name: str) -> dict[str, Any]:
"""Full inventory for one character. Items only — for filtered queries
use get_inventory_search."""
client = await _http()
resp = await client.get(f"/inventory/{quote(character_name, safe='')}")
resp.raise_for_status()
return resp.json()
async def get_inventory_search(
character_name: str, filters: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Filtered inventory search. `filters` is a dict of query params, e.g.
{"name": "pearl", "armor_level_min": 500}.
Caller is expected to know the supported filters from the dereth-tracker
/inventory/{name}/search route pass through opaquely.
"""
client = await _http()
resp = await client.get(
f"/inventory/{quote(character_name, safe='')}/search",
params=filters or {},
)
resp.raise_for_status()
return resp.json()
async def search_items_global(filters: dict[str, Any]) -> dict[str, Any]:
"""Cross-character item search via the inventory service's /search/items.
Use this INSTEAD of looping per-character when the user asks "find an X
on any of my chars" — one DB query vs. 60+ HTTP roundtrips.
Common filter keys (passed straight through as query params):
include_all_characters: bool (set true to search every char)
character: str (single char) | characters: "A,B,C"
text: str (name/description substring)
has_spell: "Legendary Acid Ward" exact spell name
spell_contains: "Legendary" substring match
legendary_cantrips: "Foo,Bar"
equipment_status: "equipped" | "unequipped"
equipment_slot: int (bitmask: 4=chest, 2048=bracelet, 4096=ring, ...)
slot_names: "Bracelet,Ring"
armor_only / jewelry_only / weapon_only: bool
min_armor / max_armor / min_damage / max_damage: int
...and many more see /search/items endpoint docs.
"""
client = await _http()
# Default to all-character search if caller didn't scope; otherwise the
# endpoint refuses with a 400.
params = dict(filters or {})
if not any(
k in params
for k in ("character", "characters", "include_all_characters")
):
params["include_all_characters"] = True
resp = await client.get("/search/items", params=params)
resp.raise_for_status()
return resp.json()
async def get_combat_stats(character_name: str) -> dict[str, Any]:
"""Lifetime + session combat stats for one character (per-element split,
monster encounters, surge counts)."""
client = await _http()
resp = await client.get(f"/combat-stats/{quote(character_name, safe='')}")
resp.raise_for_status()
return resp.json()
async def get_equipment_cantrips(character_name: str) -> dict[str, Any]:
"""Currently-equipped items + their active cantrip/spell state."""
client = await _http()
resp = await client.get(
f"/equipment-cantrip-state/{quote(character_name, safe='')}"
)
resp.raise_for_status()
return resp.json()
async def get_quest_status() -> dict[str, Any]:
"""All characters' active quest timers and progress."""
return await _get_json("/quest-status")
async def get_server_health() -> dict[str, Any]:
"""Coldeve server status: up/down, latency, current player count, uptime."""
return await _get_json("/server-health")
async def suitbuilder_search(
params: dict[str, Any], max_phase_events: int = 50
) -> dict[str, Any]:
"""Drive a suitbuilder constraint search synchronously.
The dereth-tracker /inv/suitbuilder/search endpoint is an SSE stream.
We collect events until the stream closes, drop intermediate phase
chatter (keeping the last N), and return:
{ "final_suits": [...], "phases": [...latest few...] }
`params` is the JSON body the suitbuilder expects. Call it like the
/suitbuilder.html page does.
"""
client = await _http()
final: list[dict[str, Any]] = []
phases: list[dict[str, Any]] = []
# Use a fresh long-timeout client for the SSE stream — don't tie up the
# shared pool for a 5-minute search.
async with httpx.AsyncClient(
base_url=TRACKER_URL, timeout=httpx.Timeout(300.0, connect=10.0)
) as stream_client:
async with stream_client.stream(
"POST",
"/inv/suitbuilder/search",
json=params,
headers={"Content-Type": "application/json"},
) as resp:
event_name = "message"
data_lines: list[str] = []
async for line_bytes in resp.aiter_lines():
line = line_bytes.rstrip("\r")
if line.startswith("event:"):
event_name = line[6:].strip()
elif line.startswith("data:"):
data_lines.append(line[5:].strip())
elif line == "":
# Dispatch
if data_lines:
try:
payload = json.loads("\n".join(data_lines))
except json.JSONDecodeError:
payload = {"raw": "\n".join(data_lines)}
if event_name == "result" or event_name == "final":
final.append(payload)
elif event_name == "error":
phases.append({"event": "error", "data": payload})
else:
phases.append({"event": event_name, "data": payload})
phases = phases[-max_phase_events:]
data_lines = []
event_name = "message"
return {
"final_suits": final,
"phases": phases[-max_phase_events:],
"phase_count": len(phases),
}
# ─── Cleanup ────────────────────────────────────────────────────────
async def shutdown() -> None:
"""Close shared resources. Call from MCP server lifespan / on exit."""
global _http_client, _db_pool
if _http_client is not None:
await _http_client.aclose()
_http_client = None
if _db_pool is not None:
await _db_pool.close()
_db_pool = None

View file

@ -3,7 +3,6 @@
Defines table schemas via SQLAlchemy Core and provides an Defines table schemas via SQLAlchemy Core and provides an
initialization function to set up TimescaleDB hypertable. initialization function to set up TimescaleDB hypertable.
""" """
import os import os
import sqlalchemy import sqlalchemy
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -11,12 +10,9 @@ from databases import Database
from sqlalchemy import MetaData, Table, Column, Integer, String, Float, DateTime, text from sqlalchemy import MetaData, Table, Column, Integer, String, Float, DateTime, text
from sqlalchemy import Index, BigInteger, JSON, Boolean, UniqueConstraint from sqlalchemy import Index, BigInteger, JSON, Boolean, UniqueConstraint
from sqlalchemy.sql import func from sqlalchemy.sql import func
import bcrypt as _bcrypt
# Environment: Postgres/TimescaleDB connection URL # Environment: Postgres/TimescaleDB connection URL
DATABASE_URL = os.getenv( DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth")
"DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth"
)
# Async database client with explicit connection pool configuration and query timeout # Async database client with explicit connection pool configuration and query timeout
database = Database(DATABASE_URL, min_size=5, max_size=100, command_timeout=120) database = Database(DATABASE_URL, min_size=5, max_size=100, command_timeout=120)
# Metadata for SQLAlchemy Core # Metadata for SQLAlchemy Core
@ -52,9 +48,9 @@ telemetry_events = Table(
) )
# Composite index to accelerate Grafana queries filtering by character_name then ordering by timestamp # Composite index to accelerate Grafana queries filtering by character_name then ordering by timestamp
Index( Index(
"ix_telemetry_events_char_ts", 'ix_telemetry_events_char_ts',
telemetry_events.c.character_name, telemetry_events.c.character_name,
telemetry_events.c.timestamp, telemetry_events.c.timestamp
) )
# Table for persistent total kills per character # Table for persistent total kills per character
@ -83,26 +79,6 @@ rare_stats_sessions = Table(
Column("session_id", String, primary_key=True), Column("session_id", String, primary_key=True),
Column("session_rares", Integer, nullable=False, default=0), Column("session_rares", Integer, nullable=False, default=0),
) )
# Per-character persistent combat stats (lifetime accumulation, Mag-Tools style)
combat_stats = Table(
"combat_stats",
metadata,
Column("character_name", String, primary_key=True),
Column("timestamp", DateTime(timezone=True), nullable=False),
Column("stats_data", JSON, nullable=False),
)
# Per-session combat stats snapshots (session history)
combat_stats_sessions = Table(
"combat_stats_sessions",
metadata,
Column("id", Integer, primary_key=True),
Column("character_name", String, nullable=False, index=True),
Column("session_id", String, nullable=False, index=True),
Column("timestamp", DateTime(timezone=True), nullable=False, index=True),
Column("stats_data", JSON, nullable=False),
)
# Table for recording spawn events (mob creates) for heatmap analysis # Table for recording spawn events (mob creates) for heatmap analysis
spawn_events = Table( spawn_events = Table(
# Records individual mob spawn occurrences for heatmap and analysis # Records individual mob spawn occurrences for heatmap and analysis
@ -165,8 +141,20 @@ portals = Table(
Column("discovered_by", String, nullable=False), Column("discovered_by", String, nullable=False),
) )
# Server health monitoring: only current state is kept. # Server health monitoring tables
# Historical health checks were removed — nothing read from them. server_health_checks = Table(
# Time-series data for server health checks
"server_health_checks",
metadata,
Column("id", Integer, primary_key=True),
Column("server_name", String, nullable=False, index=True),
Column("server_address", String, nullable=False),
Column("timestamp", DateTime(timezone=True), nullable=False, default=sqlalchemy.func.now()),
Column("status", String(10), nullable=False), # 'up' or 'down'
Column("latency_ms", Float, nullable=True),
Column("player_count", Integer, nullable=True),
)
server_status = Table( server_status = Table(
# Current server status and uptime tracking # Current server status and uptime tracking
"server_status", "server_status",
@ -181,13 +169,18 @@ server_status = Table(
Column("last_player_count", Integer, nullable=True), Column("last_player_count", Integer, nullable=True),
) )
# Index for efficient server health check queries
Index(
'ix_server_health_checks_name_ts',
server_health_checks.c.server_name,
server_health_checks.c.timestamp.desc()
)
character_stats = Table( character_stats = Table(
"character_stats", "character_stats",
metadata, metadata,
Column("character_name", String, primary_key=True, nullable=False), Column("character_name", String, primary_key=True, nullable=False),
Column( Column("timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()),
"timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()
),
Column("level", Integer, nullable=True), Column("level", Integer, nullable=True),
Column("total_xp", BigInteger, nullable=True), Column("total_xp", BigInteger, nullable=True),
Column("unassigned_xp", BigInteger, nullable=True), Column("unassigned_xp", BigInteger, nullable=True),
@ -197,20 +190,6 @@ character_stats = Table(
Column("stats_data", JSON, nullable=False), Column("stats_data", JSON, nullable=False),
) )
# User accounts for app-level authentication
users = Table(
"users",
metadata,
Column("id", Integer, primary_key=True),
Column("username", String, nullable=False, unique=True),
Column("password_hash", String, nullable=False),
Column("is_admin", Boolean, nullable=False, default=False),
Column(
"created_at", DateTime(timezone=True), nullable=False, server_default=func.now()
),
)
async def init_db_async(): async def init_db_async():
"""Initialize PostgreSQL/TimescaleDB schema and hypertable. """Initialize PostgreSQL/TimescaleDB schema and hypertable.
@ -233,12 +212,10 @@ async def init_db_async():
print(f"Warning: failed to create extension timescaledb: {e}") print(f"Warning: failed to create extension timescaledb: {e}")
# Convert to hypertable, migrating existing data and skipping default index creation # Convert to hypertable, migrating existing data and skipping default index creation
try: try:
conn.execute( conn.execute(text(
text( "SELECT create_hypertable('telemetry_events', 'timestamp', "
"SELECT create_hypertable('telemetry_events', 'timestamp', " "if_not_exists => true, migrate_data => true, create_default_indexes => false)"
"if_not_exists => true, migrate_data => true, create_default_indexes => false)" ))
)
)
except Exception as e: except Exception as e:
print(f"Warning: failed to create hypertable telemetry_events: {e}") print(f"Warning: failed to create hypertable telemetry_events: {e}")
except Exception as e: except Exception as e:
@ -246,82 +223,44 @@ async def init_db_async():
# Ensure composite index exists for efficient time-series queries by character # Ensure composite index exists for efficient time-series queries by character
try: try:
with engine.connect() as conn: with engine.connect() as conn:
conn.execute( conn.execute(text(
text( "CREATE INDEX IF NOT EXISTS ix_telemetry_events_char_ts "
"CREATE INDEX IF NOT EXISTS ix_telemetry_events_char_ts " "ON telemetry_events (character_name, timestamp)"
"ON telemetry_events (character_name, timestamp)" ))
)
)
except Exception as e: except Exception as e:
print( print(f"Warning: failed to create composite index ix_telemetry_events_char_ts: {e}")
f"Warning: failed to create composite index ix_telemetry_events_char_ts: {e}"
)
# Add retention and compression policies on the hypertable # Add retention and compression policies on the hypertable
try: try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
# Retain only recent data (default 7 days or override via DB_RETENTION_DAYS) # Retain only recent data (default 7 days or override via DB_RETENTION_DAYS)
days = int(os.getenv("DB_RETENTION_DAYS", "7")) days = int(os.getenv('DB_RETENTION_DAYS', '7'))
conn.execute( conn.execute(text(
text( f"SELECT add_retention_policy('telemetry_events', INTERVAL '{days} days')"
f"SELECT add_retention_policy('telemetry_events', INTERVAL '{days} days')" ))
)
)
# Compress chunks older than 1 day # Compress chunks older than 1 day
conn.execute( conn.execute(text(
text( "SELECT add_compression_policy('telemetry_events', INTERVAL '1 day')"
"SELECT add_compression_policy('telemetry_events', INTERVAL '1 day')" ))
)
)
except Exception as e: except Exception as e:
print(f"Warning: failed to set retention/compression policies: {e}") print(f"Warning: failed to set retention/compression policies: {e}")
# Ensure spawn_events is a hypertable with a 7-day retention policy.
# This is idempotent — if already a hypertable, create_hypertable is a no-op
# when if_not_exists=TRUE. The existing 482M-row table needed a manual
# migration (see docs/plans/spawn_events_cleanup.md); this block keeps the
# policy alive on subsequent deploys.
try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
# Try to convert spawn_events to a hypertable if it isn't already.
# migrate_data=FALSE is safe because the manual migration handled it;
# if someone creates a fresh DB, the table is empty and this converts it.
conn.execute(
text(
"SELECT create_hypertable('spawn_events', 'timestamp', "
"if_not_exists => TRUE, migrate_data => FALSE, "
"chunk_time_interval => INTERVAL '1 day')"
)
)
# 7-day retention
conn.execute(
text(
"SELECT add_retention_policy('spawn_events', INTERVAL '7 days', if_not_exists => TRUE)"
)
)
except Exception as e:
print(f"Warning: failed to set spawn_events hypertable/retention: {e}")
# Create unique constraint on rounded portal coordinates # Create unique constraint on rounded portal coordinates
try: try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
# Drop old portal_discoveries table if it exists # Drop old portal_discoveries table if it exists
conn.execute(text("DROP TABLE IF EXISTS portal_discoveries CASCADE")) conn.execute(text("DROP TABLE IF EXISTS portal_discoveries CASCADE"))
# Create unique constraint on rounded coordinates for the new portals table # Create unique constraint on rounded coordinates for the new portals table
conn.execute( conn.execute(text(
text( """CREATE UNIQUE INDEX IF NOT EXISTS unique_portal_coords
"""CREATE UNIQUE INDEX IF NOT EXISTS unique_portal_coords
ON portals (ROUND(ns::numeric, 2), ROUND(ew::numeric, 2))""" ON portals (ROUND(ns::numeric, 2), ROUND(ew::numeric, 2))"""
) ))
)
# Create index on coordinates for efficient lookups # Create index on coordinates for efficient lookups
conn.execute( conn.execute(text(
text( "CREATE INDEX IF NOT EXISTS idx_portals_coords ON portals (ns, ew)"
"CREATE INDEX IF NOT EXISTS idx_portals_coords ON portals (ns, ew)" ))
)
)
print("Portal table indexes and constraints created successfully") print("Portal table indexes and constraints created successfully")
except Exception as e: except Exception as e:
print(f"Warning: failed to create portal table constraints: {e}") print(f"Warning: failed to create portal table constraints: {e}")
@ -329,8 +268,7 @@ async def init_db_async():
# Ensure character_stats table exists with JSONB column type # Ensure character_stats table exists with JSONB column type
try: try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
conn.execute( conn.execute(text("""
text("""
CREATE TABLE IF NOT EXISTS character_stats ( CREATE TABLE IF NOT EXISTS character_stats (
character_name VARCHAR(255) PRIMARY KEY, character_name VARCHAR(255) PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@ -342,60 +280,25 @@ async def init_db_async():
deaths INTEGER, deaths INTEGER,
stats_data JSONB NOT NULL stats_data JSONB NOT NULL
) )
""") """))
)
print("character_stats table created/verified successfully") print("character_stats table created/verified successfully")
except Exception as e: except Exception as e:
print(f"Warning: failed to create character_stats table: {e}") print(f"Warning: failed to create character_stats table: {e}")
async def cleanup_old_portals(): async def cleanup_old_portals():
"""Clean up portals older than 1 hour.""" """Clean up portals older than 1 hour."""
try: try:
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=1) cutoff_time = datetime.now(timezone.utc) - timedelta(hours=1)
# Delete old portals # Delete old portals
result = await database.execute( result = await database.execute(
"DELETE FROM portals WHERE discovered_at < :cutoff_time", "DELETE FROM portals WHERE discovered_at < :cutoff_time",
{"cutoff_time": cutoff_time}, {"cutoff_time": cutoff_time}
) )
print(f"Cleaned up {result} portals older than 1 hour") print(f"Cleaned up {result} portals older than 1 hour")
return result return result
except Exception as e: except Exception as e:
print(f"Warning: failed to cleanup old portals: {e}") print(f"Warning: failed to cleanup old portals: {e}")
return 0 return 0
async def seed_users():
"""Seed default users if the users table is empty."""
try:
count = await database.fetch_val("SELECT COUNT(*) FROM users")
if count > 0:
print(f"Users table already has {count} users, skipping seed")
return
default_users = [
{"username": "erik", "password": "erik123", "is_admin": True},
{"username": "alex", "password": "AlexGillar100Killar", "is_admin": False},
{
"username": "lundberg",
"password": "JohanGillar100Kvinnor",
"is_admin": False,
},
]
for u in default_users:
pw_hash = _bcrypt.hashpw(u["password"].encode(), _bcrypt.gensalt()).decode()
await database.execute(
"INSERT INTO users (username, password_hash, is_admin) VALUES (:username, :password_hash, :is_admin)",
{
"username": u["username"],
"password_hash": pw_hash,
"is_admin": u["is_admin"],
},
)
role = "admin" if u["is_admin"] else "user"
print(f"Seeded {role} user: {u['username']}")
except Exception as e:
print(f"Warning: failed to seed users: {e}")

View file

@ -1,16 +0,0 @@
#!/bin/bash
# Build frontend and deploy to static/ — run from MosswartOverlord root
set -e
echo "Building frontend..."
cd frontend && npm run build && cd ..
echo "Syncing build output to static/..."
rm -rf static/assets/
cp static/_build/index.html static/index.html
cp -r static/_build/assets/ static/assets/
cp static/_build/sw.js static/sw.js 2>/dev/null || true
rm -rf static/_build/
echo "Done! $(ls static/assets/ | wc -l) asset files deployed."
echo "Run 'git add static/ && git commit && git push' to deploy to server."

View file

@ -293,15 +293,7 @@ class DiscordRareMonitor:
# Send connection established message # Send connection established message
await self.post_status_to_aclog("🔗 WebSocket connection established") await self.post_status_to_aclog("🔗 WebSocket connection established")
# Subscribe only to message types we care about (rare + chat)
# This dramatically reduces network traffic vs receiving the full firehose
await websocket.send(json.dumps({
"type": "subscribe",
"message_types": ["rare", "chat"]
}))
logger.info("📋 Subscribed to message types: rare, chat")
# Simple message processing with comprehensive error handling # Simple message processing with comprehensive error handling
try: try:
message_count = 0 message_count = 0

View file

@ -26,10 +26,8 @@ services:
DB_MAX_SQL_VARIABLES: "${DB_MAX_SQL_VARIABLES}" DB_MAX_SQL_VARIABLES: "${DB_MAX_SQL_VARIABLES}"
DB_WAL_AUTOCHECKPOINT_PAGES: "${DB_WAL_AUTOCHECKPOINT_PAGES}" DB_WAL_AUTOCHECKPOINT_PAGES: "${DB_WAL_AUTOCHECKPOINT_PAGES}"
SHARED_SECRET: "${SHARED_SECRET}" SHARED_SECRET: "${SHARED_SECRET}"
SECRET_KEY: "${SECRET_KEY}" LOG_LEVEL: "DEBUG"
INVENTORY_SERVICE_URL: "http://inventory-service:8000" INVENTORY_SERVICE_URL: "http://inventory-service:8000"
DISCORD_ACLOG_WEBHOOK: "${DISCORD_ACLOG_WEBHOOK:-}"
LOG_LEVEL: "INFO"
restart: unless-stopped restart: unless-stopped
logging: logging:
driver: "json-file" driver: "json-file"
@ -41,19 +39,6 @@ services:
db: db:
image: timescale/timescaledb:2.19.3-pg14 image: timescale/timescaledb:2.19.3-pg14
container_name: dereth-db container_name: dereth-db
# Override PostgreSQL memory settings. The default timescaledb-tune values
# targeted a much larger machine — shared_buffers was set to 96GB on a
# 32GB host, causing the kernel to swap-thrash and leaving <100MB free.
# These values follow the standard recommendation: shared_buffers ~25% RAM,
# effective_cache_size ~50% RAM, work_mem modest to avoid multiplication
# blow-up across the ~20-connection pool.
command: >
postgres
-c shared_buffers=8GB
-c effective_cache_size=16GB
-c work_mem=16MB
-c maintenance_work_mem=1GB
-c max_wal_size=4GB
environment: environment:
POSTGRES_DB: dereth POSTGRES_DB: dereth
POSTGRES_USER: postgres POSTGRES_USER: postgres
@ -85,7 +70,7 @@ services:
- "./inventory-service:/app" - "./inventory-service:/app"
environment: environment:
DATABASE_URL: "postgresql://inventory_user:${INVENTORY_DB_PASSWORD}@inventory-db:5432/inventory_db" DATABASE_URL: "postgresql://inventory_user:${INVENTORY_DB_PASSWORD}@inventory-db:5432/inventory_db"
LOG_LEVEL: "INFO" LOG_LEVEL: "DEBUG"
restart: unless-stopped restart: unless-stopped
logging: logging:
driver: "json-file" driver: "json-file"

View file

@ -1,219 +0,0 @@
# Suitbuilder Algorithm
The suitbuilder finds optimal equipment loadouts across multiple characters' inventories. It fills 17 equipment slots (9 armor, 6 jewelry, 2 clothing) using a constraint satisfaction solver with depth-first search and branch pruning.
## Search Pipeline
The search runs in 5 phases, streamed to the browser via SSE:
1. **Load items** - Fetch from inventory API (armor by set, jewelry by slot type, clothing DR3-only)
2. **Create buckets** - Group items into 17 slot buckets, expand multi-slot items
3. **Apply reductions** - Generate tailored variants of multi-coverage armor pieces
4. **Sort buckets** - Order buckets and items within them for optimal pruning
5. **Recursive search** - Depth-first search with backtracking, streaming top 10 results
## Item Loading
Items are fetched from the internal inventory API (`localhost:8000/search/items`) in four batches:
| Batch | Filter | Notes |
|-------|--------|-------|
| Primary set armor | `item_set={name}` | All armor in user's primary set |
| Secondary set armor | `item_set={name}` | All armor in user's secondary set |
| Clothing | `shirt_only` / `pants_only` | Only DR3+ shirts and pants |
| Jewelry | `jewelry_only` + `slot_names={type}` | Rings, bracelets, necklaces, trinkets separately |
After loading, a **domination pre-filter** removes items that are strictly worse than another item in the same slot with the same set. Item A is "surpassed" by item B when B has equal-or-better spells (Legendary > Epic > Major), equal-or-better ratings, equal-or-better armor, and is strictly better in at least one category.
## Bucket Creation
Each of the 17 slots gets a bucket. Items are assigned to buckets with special handling:
- **Multi-slot items** (e.g., "Left Wrist, Right Wrist") are cloned into each applicable slot bucket
- **Generic jewelry** ("Ring" -> Left Ring + Right Ring, "Bracelet" -> Left Wrist + Right Wrist)
- **Robes** (6+ coverage areas) are excluded entirely - they can't be reduced to single slots
All 17 buckets are created even if empty, allowing the search to produce incomplete suits when no valid item exists for a slot.
## Armor Reduction (Tailoring)
Multi-coverage armor can be tailored to fit a single slot. Only loot-generated items (those with a `material`) are eligible. Reduction patterns follow Mag-SuitBuilder logic:
| Original Coverage | Reduces To |
|---|---|
| Upper Arms + Lower Arms | Upper Arms **or** Lower Arms |
| Upper Legs + Lower Legs | Upper Legs **or** Lower Legs |
| Lower Legs + Feet | Feet |
| Chest + Abdomen | Chest |
| Chest + Abdomen + Upper Arms | Chest |
| Chest + Upper Arms + Lower Arms | Chest |
| Chest + Upper Arms | Chest |
| Abdomen + Upper Legs + Lower Legs | Abdomen **or** Upper Legs **or** Lower Legs |
| Chest + Abdomen + Upper Arms + Lower Arms (hauberks) | Chest |
| Abdomen + Upper Legs | Abdomen |
Reduced items are added to the target slot's bucket as `"Item Name (tailored to Slot)"`.
## Bucket Sort Order
### Bucket ordering (which slot to fill first)
Buckets are searched in this priority:
1. **Core armor** - Chest, Head, Hands, Feet, Upper Arms, Lower Arms, Abdomen, Upper Legs, Lower Legs
2. **Jewelry** - Neck, Left Ring, Right Ring, Left Wrist, Right Wrist, Trinket
3. **Clothing** - Shirt, Pants
Within each category, buckets are further sorted by their position in the priority list (not by item count). This means armor slots are always filled before jewelry, and jewelry before clothing.
### Item ordering within each bucket
Items within a bucket are sorted to try the best candidates first. The sort depends on slot type:
| Slot Type | Sort Priority (highest first) |
|-----------|-------------------------------|
| **Armor** | User's primary set > secondary set > others, then crit damage rating desc, then damage rating desc, then armor level desc |
| **Jewelry** | Spell count desc, then total ratings desc |
| **Clothing** (Shirt/Pants) | Damage rating desc, then spell count desc, then other ratings desc |
All sorts include `(character_name, name)` as final tiebreakers for deterministic results.
## Recursive Search
The solver uses depth-first search with backtracking across the ordered buckets:
```
for each bucket (slot) in order:
for each item in bucket:
if item passes constraints:
add item to suit state
recurse to next bucket
remove item (backtrack)
if no items were accepted:
skip this slot (allow incomplete suits)
recurse to next bucket
```
When all buckets are processed, the suit is scored and kept if it ranks in the top N (default 10).
### Branch Pruning
Two pruning strategies cut off hopeless branches early:
1. **Mag-SuitBuilder style**: If `current_items + 1 < highest_armor_count_seen - remaining_armor_buckets`, prune. This ensures we don't explore branches that can't produce suits with enough armor pieces.
2. **Max-items pruning**: If `current_items + remaining_buckets < best_suit_item_count`, prune. The branch can't produce a suit with more items than the best found so far.
### Item Acceptance Rules (`can_add_item`)
An item must pass all of these checks:
1. **Slot available** - The slot must not already be occupied in the current suit state
2. **Item uniqueness** - The same physical item (by ID) can't appear in multiple slots
3. **Set membership** (armor only):
- Primary set items: accepted up to effective limit (5 minus locked primary pieces)
- Secondary set items: accepted up to effective limit (4 minus locked secondary pieces)
- Other set items: **rejected** for armor slots, allowed for jewelry only if they contribute required spells
- No-set items: **rejected** for armor, allowed for clothing always, allowed for jewelry only if they contribute required spells
4. **Spell contribution** (when required spells are specified):
- Items with spells must contribute at least one **new** required spell not already covered by the current suit
- Items where all spells are duplicates of already-covered spells are **rejected**, even from the target sets
- Jewelry has an additional gate: it must contribute an uncovered required spell or it's rejected (empty slot preferred over useless jewelry)
### Locked Slots
Users can lock specific slots with a predetermined set and/or spells. Locked slots are:
- Removed from the bucket list (not searched)
- Their set contributions are subtracted from set requirements (e.g., 2 locked primary pieces means only 3 more needed)
- Their spells are counted as already fulfilled
## Scoring
The scoring system determines suit ranking. Points are awarded in this priority order:
### 1. Set Completion (highest weight)
| Condition | Points |
|-----------|--------|
| Primary set complete (found pieces >= effective need) | **+1000** |
| Secondary set complete | **+1000** |
| Missing primary piece | **-200** per missing piece |
| Missing secondary piece | **-200** per missing piece |
| Excess primary pieces (beyond 5) | **-500** per excess piece |
| Excess secondary pieces (beyond 4) | **-500** per excess piece |
### 2. Crit Damage Rating (armor pieces)
| Rating | Points |
|--------|--------|
| CD1 (crit_damage_rating = 1) | **+10** per piece |
| CD2 (crit_damage_rating = 2) | **+20** per piece |
### 3. Damage Rating (clothing only - Shirt/Pants)
| Rating | Points |
|--------|--------|
| DR1 | **+10** per piece |
| DR2 | **+20** per piece |
| DR3 | **+30** per piece |
### 4. Spell Coverage
| Condition | Points |
|-----------|--------|
| Each fulfilled required spell | **+100** |
### 5. Base Item Score
| Condition | Points |
|-----------|--------|
| Each item in the suit | **+5** |
### 6. Armor Level (tiebreaker only)
| Condition | Points |
|-----------|--------|
| Total armor level | **+1 per 100 AL** (e.g., 4500 AL = +45) |
Score is floored at 0 (never negative).
### Practical Effect of Scoring Weights
The weights create this effective priority:
1. **Complete sets matter most** - A suit with both sets complete (+2000) always beats one with a missing piece, regardless of other stats
2. **Spells matter second** - Each required cantrip/ward is worth +100, so 10 spells = +1000 (equivalent to one complete set)
3. **Crit damage and damage rating are tiebreakers** - CD2 on all 9 armor pieces = +180, DR3 on both clothes = +60
4. **Armor level barely matters** - Only ~45 points for a full suit of 4500 AL; it only breaks ties between otherwise-equal suits
## Frontend Display
Results stream in as SSE events. The frontend maintains a sorted list of top 10 suits:
- New suits are inserted in score-ordered position (highest first)
- If the list is full (10 suits) and the new suit scores lower than all existing ones, it's discarded
- Medals are assigned by position: gold/silver/bronze for top 3
### Score Display Classes
| Score Range | CSS Class |
|-------------|-----------|
| >= 90 | `excellent` |
| >= 75 | `good` |
| >= 60 | `fair` |
| < 60 | `poor` |
### Item Display
Each suit shows a table with all 17 slots. Per item:
- **Armor pieces**: Show CD (crit damage) and CDR (crit damage resist) ratings
- **Clothing pieces**: Show DR (damage rating) and DRR (damage resist rating)
- **Spells**: Show up to 2 Legendary/Epic spells, then "+N more"
- **Multi-slot items** that need tailoring are marked with an asterisk (*)
### Suit Selection
Clicking a suit populates the right-panel equipment slots visual. Users can then:
- Lock slots (preserving set/spell info for re-searches)
- Copy suit summary to clipboard
- Clear individual slots

2
frontend/.gitignore vendored
View file

@ -1,2 +0,0 @@
node_modules/
dist/

View file

@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mosswart Overlord v2</title>
<link rel="icon" type="image/png" href="/icons/7735.png" />
<link rel="preload" as="image" href="/dereth.png" />
<link rel="preload" as="image" href="/icons/0600127E.png" />
<link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -1,22 +0,0 @@
{
"name": "mosswart-overlord-v2",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "~5.8.3",
"vite": "^6.3.3"
}
}

View file

@ -1,72 +0,0 @@
// Service worker for MosswartOverlord v2 — caches static assets for instant repeat loads
const CACHE_NAME = 'mo-v2-cache-v1';
const STATIC_ASSETS = [
'/dereth.png',
'/dereth_highres.png',
'/prismatic-taper-icon.png',
'/icons/0600127E.png',
'/icons/06000133.png',
'/icons/06001080.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Cache icon images on first fetch
if (url.pathname.startsWith('/icons/') && event.request.method === 'GET') {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
});
})
);
return;
}
// Cache dungeon_tiles.json (large, rarely changes)
if (url.pathname === '/dungeon_tiles.json') {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
});
})
);
return;
}
// Cache static assets (map images etc)
if (STATIC_ASSETS.some(a => url.pathname === a)) {
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
);
return;
}
});

View file

@ -1,28 +0,0 @@
import { MapLayout } from './components/map/MapLayout';
import { PlayerDashboardFullPage } from './components/PlayerDashboardFullPage';
import { useLiveData } from './hooks/useLiveData';
import './styles/map-layout.css';
/**
* Single SPA entry. Branches on `?view=` query param:
* /?view=dashboard fullscreen PlayerDashboardFullPage (new-tab target)
* / default map + sidebar layout
*
* We don't pull in react-router for one extra view when a third view
* appears, swap this for proper routing.
*/
export default function App() {
const view = new URLSearchParams(window.location.search).get('view');
if (view === 'dashboard') {
return <PlayerDashboardFullPage />;
}
// Default: full app with map + sidebar.
return <DefaultApp />;
}
/** Default map-and-sidebar layout. Split out so the dashboard tab doesn't
* spin up useLiveData twice for the same render. */
function DefaultApp() {
const data = useLiveData();
return <MapLayout data={data} />;
}

View file

@ -1,69 +0,0 @@
// In production the browser hits /api/* and Nginx strips the prefix.
// In dev, Vite's proxy does the same stripping.
// So we always use /api/ as prefix — works both environments.
const API_BASE = '/api';
export async function apiFetch<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API ${path}: ${res.status}`);
return res.json();
}
/**
* POST JSON to an authenticated API endpoint.
* Sends `body` as JSON, includes session cookie, parses JSON response.
* Throws Error with HTTP status on non-2xx.
*/
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {}),
});
if (!res.ok) {
let detail = '';
try { detail = (await res.json())?.detail ?? ''; } catch { /* ignore */ }
throw new Error(`API ${path}: ${res.status}${detail ? ` (${detail})` : ''}`);
}
return res.json();
}
/**
* PATCH JSON to an authenticated API endpoint. Same shape as apiPost.
*/
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {}),
});
if (!res.ok) {
let detail = '';
try { detail = (await res.json())?.detail ?? ''; } catch { /* ignore */ }
throw new Error(`API ${path}: ${res.status}${detail ? ` (${detail})` : ''}`);
}
return res.json();
}
/**
* DELETE an authenticated API endpoint. No body. Returns parsed JSON.
*/
export async function apiDelete<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) {
let detail = '';
try { detail = (await res.json())?.detail ?? ''; } catch { /* ignore */ }
throw new Error(`API ${path}: ${res.status}${detail ? ` (${detail})` : ''}`);
}
return res.json();
}
export function wsUrl(): string {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${location.host}/api/ws/live`;
}

View file

@ -1,96 +0,0 @@
import { apiFetch, apiPost, apiPatch, apiDelete } from './client';
import type { TelemetrySnapshot, CombatStatsMessage, ServerHealth } from '../types';
interface LiveResponse {
players: TelemetrySnapshot[];
}
interface CombatStatsResponse {
stats: CombatStatsMessage[];
}
// v1 response shapes: /total-rares → { all_time, today }, /total-kills → { total }
interface RaresResponse { all_time: number; today: number; }
interface KillsResponse { total: number; }
export const getLive = () => apiFetch<LiveResponse>('/live');
export const getCombatStats = () => apiFetch<CombatStatsResponse>('/combat-stats');
export const getServerHealth = () => apiFetch<ServerHealth>('/server-health');
export const getTotalRares = () => apiFetch<RaresResponse>('/total-rares');
export const getTotalKills = () => apiFetch<KillsResponse>('/total-kills');
export const getCharacterStats = (name: string) => apiFetch<Record<string, unknown>>(`/character-stats/${encodeURIComponent(name)}`);
// ─── Agent endpoints (host-side service via /api/agent/*) ──────────────────
export interface AgentAskResponse {
result: string;
session_id: string;
duration_ms: number;
num_turns: number;
is_error: boolean;
}
export interface AgentHistoryMessage {
role: 'user' | 'assistant';
text: string;
timestamp?: string;
}
export const agentAsk = (message: string, sessionId: string) =>
apiPost<AgentAskResponse>('/agent/ask', { message, session_id: sessionId });
export const agentNewSession = () =>
apiPost<{ session_id: string }>('/agent/sessions/new', {});
export const agentSessionHistory = (sessionId: string) =>
apiFetch<{ messages: AgentHistoryMessage[] }>(
`/agent/sessions/${encodeURIComponent(sessionId)}/history`,
);
// ─── Auth / current user ───────────────────────────────────────────────────
export interface CurrentUser {
username: string;
is_admin: boolean;
}
export const getCurrentUser = () => apiFetch<CurrentUser>('/me');
/**
* Log out by hitting /logout (which clears the cookie server-side and 302s
* to /login). We follow the redirect explicitly so the browser ends up on
* the login page with a fresh state.
*/
export async function logout(): Promise<void> {
// /logout is a GET that returns a redirect. apiFetch would throw because
// the redirect target /login returns HTML, not JSON. Use a bare fetch.
await fetch('/api/logout', { credentials: 'include', redirect: 'manual' });
// Force navigation regardless — the cookie is gone either way.
window.location.href = '/login';
}
// ─── Admin user CRUD ───────────────────────────────────────────────────────
export interface AdminUser {
id: number;
username: string;
is_admin: boolean;
created_at: string;
}
export const listAdminUsers = () =>
apiFetch<{ users: AdminUser[] }>('/api-admin/users');
export const createAdminUser = (username: string, password: string, isAdmin: boolean) =>
apiPost<{ ok: boolean; username: string }>('/api-admin/users', {
username, password, is_admin: isAdmin,
});
export const updateAdminUser = (
id: number,
body: { password?: string; is_admin?: boolean },
) =>
apiPatch<{ ok: boolean }>(`/api-admin/users/${id}`, body);
export const deleteAdminUser = (id: number) =>
apiDelete<{ ok: boolean }>(`/api-admin/users/${id}`);

View file

@ -1,51 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useLiveData } from '../hooks/useLiveData';
import { PlayerDashboardContent } from './windows/PlayerDashboardWindow';
/**
* Fullscreen "Player Dashboard" page rendered when the React app loads
* with `?view=dashboard` in the URL. Designed to be opened in a new tab
* by the sidebar's 👥 Dashboard button so users can put the dashboard on
* a second monitor / its own window without occupying the map view.
*
* Each tab is its own React app instance with its own useLiveData
* (and therefore its own WebSocket to /ws/live). Independent of the main
* tab's lifecycle.
*/
export const PlayerDashboardFullPage: React.FC = () => {
const data = useLiveData();
const [version, setVersion] = useState('');
// Set tab title.
useEffect(() => {
const prev = document.title;
document.title = 'Overlord Dashboard';
return () => { document.title = prev; };
}, []);
// Fetch version stamp the same way MapLayout does. /api-version returns
// {version: "..."} where "..." is the BUILD_VERSION baked into the
// tracker container at image build time.
useEffect(() => {
fetch('/api/api-version', { credentials: 'include' })
.then(r => r.json())
.then(d => setVersion(d.version ?? ''))
.catch(() => { /* version is cosmetic — ignore failures */ });
}, []);
const count = Array.from(data.characters.values()).filter(c => c.telemetry).length;
return (
<div className="ml-dashboard-page">
<header className="ml-dashboard-header">
<span className="ml-dashboard-title">👥 Player Dashboard</span>
<span className="ml-dashboard-count">{count} online</span>
<span style={{ flex: 1 }} />
{version && <span className="ml-dashboard-version">v{version}</span>}
</header>
<main className="ml-dashboard-main">
<PlayerDashboardContent characters={data.characters} />
</main>
</div>
);
};

View file

@ -1,71 +0,0 @@
import React, { useEffect, useState, useRef } from 'react';
interface DeathAlert {
character_name: string;
vitae: number;
timestamp: string;
}
interface Props {
deathAlerts: DeathAlert[];
}
interface ActiveNotification {
key: number;
alert: DeathAlert;
exiting: boolean;
}
let deathKey = 0;
export const DeathNotification: React.FC<Props> = ({ deathAlerts }) => {
const [active, setActive] = useState<ActiveNotification[]>([]);
const lastCount = useRef(0);
useEffect(() => {
if (deathAlerts.length > lastCount.current && lastCount.current > 0) {
const newAlerts = deathAlerts.slice(lastCount.current);
for (const alert of newAlerts) {
const key = ++deathKey;
setActive(prev => [...prev, { key, alert, exiting: false }]);
// Sound
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination);
osc.frequency.value = 440; osc.type = 'sawtooth'; gain.gain.value = 0.2;
osc.start();
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.8);
osc.stop(ctx.currentTime + 0.8);
} catch {}
// Auto-dismiss after 8s
setTimeout(() => {
setActive(prev => prev.map(n => n.key === key ? { ...n, exiting: true } : n));
setTimeout(() => setActive(prev => prev.filter(n => n.key !== key)), 500);
}, 8000);
}
}
lastCount.current = deathAlerts.length;
}, [deathAlerts.length]); // eslint-disable-line react-hooks/exhaustive-deps
if (active.length === 0) return null;
return (
<div style={{ position: 'fixed', top: 70, left: '50%', transform: 'translateX(-50%)', zIndex: 99999, display: 'flex', flexDirection: 'column', gap: 6, pointerEvents: 'none' }}>
{active.map(n => (
<div key={n.key} style={{
background: 'linear-gradient(135deg, #2a0a0a, #1a0000)',
border: '2px solid #cc4444',
borderRadius: 8, padding: '12px 24px', textAlign: 'center',
boxShadow: '0 0 30px rgba(204, 68, 68, 0.3)',
animation: n.exiting ? 'ml-notif-out 0.5s ease-in forwards' : 'ml-notif-in 0.5s ease-out',
}}>
<div style={{ fontSize: '1.2rem', fontWeight: 800, color: '#ff4444' }}> CHARACTER DIED </div>
<div style={{ fontSize: '1rem', fontWeight: 600, color: '#fff', marginTop: 2 }}>{n.alert.character_name}</div>
<div style={{ fontSize: '0.8rem', color: '#c88', marginTop: 2 }}>Vitae: {n.alert.vitae}%</div>
</div>
))}
</div>
);
};

View file

@ -1,109 +0,0 @@
import React, { useEffect, useState, useCallback } from 'react';
import type { RareMessage } from '../../types';
interface Props {
recentRares: RareMessage[];
}
interface ActiveNotification {
key: number;
charName: string;
rareName: string;
exiting: boolean;
}
let notifKey = 0;
export const RareNotification: React.FC<Props> = ({ recentRares }) => {
const [active, setActive] = useState<ActiveNotification[]>([]);
const [lastCount, setLastCount] = useState(0);
const [fireworks, setFireworks] = useState<Array<{ id: number; particles: Array<{ dx: number; dy: number; color: string }> }>>([]);
// Detect new rares
useEffect(() => {
if (recentRares.length > lastCount && lastCount > 0) {
const newRares = recentRares.slice(0, recentRares.length - lastCount);
for (const r of newRares) {
const key = ++notifKey;
setActive(prev => [...prev, { key, charName: r.character_name, rareName: r.name, exiting: false }]);
// Trigger fireworks + sound
triggerFireworks();
try {
// Simple beep using Web Audio API
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'sine';
gain.gain.value = 0.3;
osc.start();
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
osc.stop(ctx.currentTime + 0.5);
} catch { /* audio not available */ }
// Auto-remove after 6s
setTimeout(() => {
setActive(prev => prev.map(n => n.key === key ? { ...n, exiting: true } : n));
setTimeout(() => {
setActive(prev => prev.filter(n => n.key !== key));
}, 500);
}, 6000);
}
}
setLastCount(recentRares.length);
}, [recentRares.length]); // eslint-disable-line react-hooks/exhaustive-deps
const triggerFireworks = useCallback(() => {
const id = Date.now();
const colors = ['#FFD700', '#FF4444', '#FF8800', '#AA44FF', '#4488FF'];
const particles = Array.from({ length: 30 }, (_, i) => {
const angle = (Math.PI * 2 * i) / 30 + (Math.random() - 0.5) * 0.5;
const velocity = 100 + Math.random() * 200;
return {
dx: Math.cos(angle) * velocity,
dy: Math.sin(angle) * velocity - 50,
color: colors[Math.floor(Math.random() * colors.length)],
};
});
setFireworks(prev => [...prev, { id, particles }]);
setTimeout(() => setFireworks(prev => prev.filter(f => f.id !== id)), 2200);
}, []);
return (
<>
{/* Notification banners */}
<div className="ml-rare-notifications">
{active.map(n => (
<div key={n.key} className={`ml-rare-notif ${n.exiting ? 'exiting' : ''}`}>
<div className="ml-rare-notif-title">🎆 LEGENDARY RARE! 🎆</div>
<div className="ml-rare-notif-name">{n.rareName}</div>
<div className="ml-rare-notif-by">found by</div>
<div className="ml-rare-notif-char">{n.charName}</div>
</div>
))}
</div>
{/* Fireworks particles */}
<div className="ml-fireworks">
{fireworks.map(fw => (
<React.Fragment key={fw.id}>
{fw.particles.map((p, i) => (
<div
key={i}
className="ml-firework-particle"
style={{
left: '50%',
top: '30%',
backgroundColor: p.color,
'--dx': `${p.dx}px`,
'--dy': `${p.dy + 200}px`,
} as React.CSSProperties}
/>
))}
</React.Fragment>
))}
</div>
</>
);
};

View file

@ -1,58 +0,0 @@
import React, { useRef, useEffect, useState } from 'react';
import { worldToPx } from '../../utils/coordinates';
import { apiFetch } from '../../api/client';
interface HeatmapPoint {
ew: number;
ns: number;
intensity: number;
}
interface Props {
imgW: number;
imgH: number;
enabled: boolean;
}
export const HeatmapCanvas: React.FC<Props> = ({ imgW, imgH, enabled }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [data, setData] = useState<HeatmapPoint[]>([]);
useEffect(() => {
if (!enabled) return;
const fetch = async () => {
try {
const resp = await apiFetch<{ spawn_points: HeatmapPoint[] }>('/spawns/heatmap?hours=24&limit=50000');
setData(resp.spawn_points ?? []);
} catch { /* ignore */ }
};
fetch();
}, [enabled]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !enabled || data.length === 0 || imgW === 0) return;
canvas.width = imgW;
canvas.height = imgH;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, imgW, imgH);
for (const point of data) {
const { x, y } = worldToPx(point.ew, point.ns, imgW, imgH);
const radius = Math.max(5, Math.min(12, 5 + Math.sqrt(point.intensity * 0.5)));
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, `rgba(255, 0, 0, ${Math.min(0.9, point.intensity / 40)})`);
gradient.addColorStop(0.6, `rgba(255, 100, 0, ${Math.min(0.4, point.intensity / 120)})`);
gradient.addColorStop(1, 'rgba(255, 150, 0, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
}
}, [data, imgW, imgH, enabled]);
if (!enabled) return null;
return <canvas ref={canvasRef} className="ml-heatmap-canvas" />;
};

View file

@ -1,77 +0,0 @@
import React, { useCallback, useState, useMemo, useEffect } from 'react';
import { apiFetch } from '../../api/client';
import { WindowManagerProvider, useWindowManager } from '../../contexts/WindowManagerContext';
import { MapView } from './MapView';
import { Sidebar } from './Sidebar';
import { WindowRenderer } from '../windows/WindowRenderer';
import { RareNotification } from '../effects/RareNotification';
import { DeathNotification } from '../effects/DeathNotification';
import { usePlayerColors } from '../../hooks/usePlayerColors';
import type { DashboardState } from '../../hooks/useLiveData';
interface Props {
data: DashboardState;
}
export const MapLayout: React.FC<Props> = ({ data }) => {
const getColor = usePlayerColors();
const [showHeatmap, setShowHeatmap] = useState(false);
const [showPortals, setShowPortals] = useState(false);
const [selectedPlayer, setSelectedPlayer] = useState<string | null>(null);
const players = useMemo(() =>
Array.from(data.characters.values()).filter(c => c.telemetry).map(c => c.telemetry!),
[data.characters]);
const vitalsMap = useMemo(() =>
new Map(Array.from(data.characters.values()).filter(c => c.vitals).map(c => [c.name, c.vitals!])),
[data.characters]);
const [version, setVersion] = useState('');
useEffect(() => {
// /api-version is the actual route — apiFetch adds /api prefix, so use raw fetch
fetch('/api/api-version', { credentials: 'include' }).then(r => r.json()).then(d => setVersion(d.version ?? '')).catch(() => {});
}, []);
const handleSelectPlayer = useCallback((name: string) => {
setSelectedPlayer(prev => prev === name ? null : name);
}, []);
return (
<WindowManagerProvider>
<div className="ml-layout">
<Sidebar
players={players}
vitals={vitalsMap}
serverHealth={data.serverHealth}
totalRares={data.totalRares}
totalKills={data.totalKills}
getColor={getColor}
onSelectPlayer={handleSelectPlayer}
showHeatmap={showHeatmap}
showPortals={showPortals}
onToggleHeatmap={setShowHeatmap}
onTogglePortals={setShowPortals}
version={version}
selectedPlayer={selectedPlayer}
/>
<MapView
players={players}
getColor={getColor}
onSelectPlayer={handleSelectPlayer}
showHeatmap={showHeatmap}
showPortals={showPortals}
selectedPlayer={selectedPlayer}
/>
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages}
nearbyObjects={data.nearbyObjects} inventoryVersions={data.inventoryVersions}
equipmentCantrips={data.equipmentCantrips} characterStats={data.characterStats}
socket={data.socketRef.current} />
<RareNotification recentRares={data.recentRares} />
<DeathNotification deathAlerts={data.deathAlerts} />
</div>
</WindowManagerProvider>
);
};

View file

@ -1,161 +0,0 @@
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { worldToPx, pxToWorld, formatCoord } from '../../utils/coordinates';
import { PlayerDots } from './PlayerDots';
import { TrailsSVG } from './TrailsSVG';
import { HeatmapCanvas } from './HeatmapCanvas';
import { PortalMarkers } from './PortalMarkers';
import type { TelemetrySnapshot } from '../../types';
interface Props {
players: TelemetrySnapshot[];
getColor: (name: string) => string;
onSelectPlayer: (name: string) => void;
showHeatmap: boolean;
showPortals: boolean;
selectedPlayer: string | null;
}
const MAX_ZOOM = 20;
const MIN_ZOOM = 0.3;
// Pan/zoom via direct DOM manipulation — bypasses React state entirely for smooth 60fps
export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer, showHeatmap, showPortals, selectedPlayer }) => {
const containerRef = useRef<HTMLDivElement>(null);
const groupRef = useRef<HTMLDivElement>(null);
const [imgSize, setImgSize] = useState({ w: 0, h: 0 });
const [tooltip, setTooltip] = useState<{ x: number; y: number; player: TelemetrySnapshot } | null>(null);
const coordRef = useRef<HTMLDivElement>(null);
// Transform stored in ref, applied directly to DOM — no React re-render on pan/zoom
const txRef = useRef({ scale: 1, offX: 0, offY: 0 });
const dragRef = useRef({ dragging: false, sx: 0, sy: 0, startOffX: 0, startOffY: 0 });
const applyTransform = useCallback(() => {
if (groupRef.current) {
const { scale, offX, offY } = txRef.current;
groupRef.current.style.transform = `translate(${offX}px, ${offY}px) scale(${scale})`;
}
}, []);
const onImgLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
setImgSize({ w: img.naturalWidth, h: img.naturalHeight });
if (containerRef.current) {
const cw = containerRef.current.clientWidth;
const ch = containerRef.current.clientHeight;
const scale = Math.min(cw / img.naturalWidth, ch / img.naturalHeight);
txRef.current = { scale, offX: (cw - img.naturalWidth * scale) / 2, offY: (ch - img.naturalHeight * scale) / 2 };
applyTransform();
}
}, [applyTransform]);
// Wheel zoom — direct DOM
const onWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const tx = txRef.current;
const factor = e.deltaY < 0 ? 1.1 : 0.9;
const newScale = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, tx.scale * factor));
const ratio = newScale / tx.scale;
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
txRef.current = {
scale: newScale,
offX: cx - (cx - tx.offX) * ratio,
offY: cy - (cy - tx.offY) * ratio,
};
applyTransform();
}, [applyTransform]);
// Pan drag — direct DOM
const onMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
const tx = txRef.current;
dragRef.current = { dragging: true, sx: e.clientX, sy: e.clientY, startOffX: tx.offX, startOffY: tx.offY };
}, []);
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
const d = dragRef.current;
if (d.dragging) {
txRef.current.offX = d.startOffX + (e.clientX - d.sx);
txRef.current.offY = d.startOffY + (e.clientY - d.sy);
applyTransform();
}
// Coordinate display — direct DOM write, no React state
if (containerRef.current && imgSize.w > 0 && coordRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const tx = txRef.current;
const coord = pxToWorld(e.clientX - rect.left, e.clientY - rect.top, tx.scale, tx.offX, tx.offY, imgSize.w, imgSize.h);
coordRef.current.textContent = formatCoord(coord.ns, coord.ew);
}
};
const onMouseUp = () => { dragRef.current.dragging = false; };
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); };
}, [applyTransform, imgSize.w, imgSize.h]);
// Zoom to selected player — fires once then releases
const lastZoomedRef = useRef<string | null>(null);
useEffect(() => {
if (!selectedPlayer || imgSize.w === 0 || !containerRef.current) return;
if (lastZoomedRef.current === selectedPlayer) return; // already zoomed to this player
const player = players.find(p => p.character_name === selectedPlayer);
if (!player) return;
lastZoomedRef.current = selectedPlayer;
const { x, y } = worldToPx(player.ew, player.ns, imgSize.w, imgSize.h);
const rect = containerRef.current.getBoundingClientRect();
const focusZoom = 3;
txRef.current = {
scale: Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, focusZoom)),
offX: rect.width / 2 - x * focusZoom,
offY: rect.height / 2 - y * focusZoom,
};
applyTransform();
}, [selectedPlayer, players, imgSize.w, imgSize.h, applyTransform]);
// Reset zoom lock when player is deselected
useEffect(() => {
if (!selectedPlayer) lastZoomedRef.current = null;
}, [selectedPlayer]);
const handleDotHover = useCallback((player: TelemetrySnapshot | null, x: number, y: number) => {
setTooltip(player ? { x, y, player } : null);
}, []);
return (
<div className="ml-map-container" ref={containerRef} onWheel={onWheel} onMouseDown={onMouseDown}>
<div ref={groupRef} className="ml-map-group">
<img src="/dereth.png" alt="Dereth" className="ml-map-img" onLoad={onImgLoad} draggable={false} />
{imgSize.w > 0 && (
<>
<HeatmapCanvas imgW={imgSize.w} imgH={imgSize.h} enabled={showHeatmap} />
<TrailsSVG imgW={imgSize.w} imgH={imgSize.h} getColor={getColor} />
<PlayerDots
players={players}
imgW={imgSize.w}
imgH={imgSize.h}
getColor={getColor}
onHover={handleDotHover}
onSelect={onSelectPlayer}
selectedPlayer={selectedPlayer}
/>
<PortalMarkers imgW={imgSize.w} imgH={imgSize.h} enabled={showPortals} />
</>
)}
</div>
{tooltip && (
<div className="ml-tooltip" style={{ left: tooltip.x + 12, top: tooltip.y - 10 }}>
<strong>{tooltip.player.character_name}</strong><br />
{formatCoord(tooltip.player.ns, tooltip.player.ew)}<br />
{tooltip.player.kills_per_hour} kph &middot; {tooltip.player.kills?.toLocaleString()} kills
</div>
)}
<div className="ml-coords" ref={coordRef} />
</div>
);
};

View file

@ -1,85 +0,0 @@
import React, { useMemo, useState, useEffect } from 'react';
import { worldToPx } from '../../utils/coordinates';
import { useWindowManager } from '../../contexts/WindowManagerContext';
import type { TelemetrySnapshot } from '../../types';
interface Props {
players: TelemetrySnapshot[];
imgW: number;
imgH: number;
getColor: (name: string) => string;
onHover: (player: TelemetrySnapshot | null, x: number, y: number) => void;
onSelect: (name: string) => void;
selectedPlayer: string | null;
}
export const PlayerDots: React.FC<Props> = React.memo(({ players, imgW, imgH, getColor, onHover, onSelect, selectedPlayer }) => {
const { openWindow } = useWindowManager();
const [contextMenu, setContextMenu] = useState<{ name: string; x: number; y: number } | null>(null);
// Close context menu on any click
useEffect(() => {
const close = () => setContextMenu(null);
if (contextMenu) window.addEventListener('click', close);
return () => window.removeEventListener('click', close);
}, [contextMenu]);
const dots = useMemo(() =>
players.filter(p => p.ew !== undefined && p.ns !== undefined).map(p => ({
...p,
pos: worldToPx(p.ew, p.ns, imgW, imgH),
color: getColor(p.character_name),
})),
[players, imgW, imgH, getColor]);
return (
<div className="ml-dots-layer">
{dots.map(d => (
<div
key={d.character_name}
className={`ml-dot ${selectedPlayer === d.character_name ? 'ml-dot-selected' : ''}`}
style={{
left: d.pos.x,
top: d.pos.y,
backgroundColor: d.color,
}}
onMouseEnter={(e) => {
const rect = e.currentTarget.closest('.ml-map-container')?.getBoundingClientRect();
if (rect) onHover(d, e.clientX - rect.left, e.clientY - rect.top);
}}
onMouseLeave={() => onHover(null, 0, 0)}
onClick={() => onSelect(d.character_name)}
onDoubleClick={() => openWindow(`chat-${d.character_name}`, `Chat: ${d.character_name}`, d.character_name)}
onContextMenu={(e) => {
e.preventDefault();
const name = d.character_name;
const rect = e.currentTarget.closest('.ml-map-container')?.getBoundingClientRect();
const x = rect ? e.clientX - rect.left : e.clientX;
const y = rect ? e.clientY - rect.top : e.clientY;
setContextMenu({ name, x, y });
}}
/>
))}
{contextMenu && (
<div style={{ position: 'fixed', left: contextMenu.x + 410, top: contextMenu.y, background: '#1a1a1a', border: '1px solid #444', borderRadius: 4, zIndex: 9999, padding: '2px 0', fontSize: '0.75rem', boxShadow: '0 4px 12px rgba(0,0,0,0.5)', minWidth: 120 }}>
{[
{ label: 'Chat', id: 'chat' },
{ label: 'Stats', id: 'stats' },
{ label: 'Inventory', id: 'inv' },
{ label: 'Character', id: 'char' },
{ label: 'Combat', id: 'combat' },
{ label: 'Radar', id: 'radar' },
].map(item => (
<div key={item.id} onClick={() => { openWindow(`${item.id}-${contextMenu.name}`, `${item.label}: ${contextMenu.name}`, contextMenu.name); setContextMenu(null); }}
style={{ padding: '4px 12px', cursor: 'pointer', color: '#ccc' }}
onMouseEnter={e => (e.currentTarget.style.background = '#333')}
onMouseLeave={e => (e.currentTarget.style.background = '')}>
{item.label}
</div>
))}
</div>
)}
</div>
);
});
PlayerDots.displayName = 'PlayerDots';

View file

@ -1,54 +0,0 @@
import React, { useEffect, useState, useMemo } from 'react';
import { worldToPx } from '../../utils/coordinates';
import { apiFetch } from '../../api/client';
interface Portal {
portal_name: string;
coordinates: { ns: number; ew: number; z: number };
discovered_by: string;
}
interface Props {
imgW: number;
imgH: number;
enabled: boolean;
}
export const PortalMarkers: React.FC<Props> = ({ imgW, imgH, enabled }) => {
const [portals, setPortals] = useState<Portal[]>([]);
useEffect(() => {
if (!enabled) return;
const fetch = async () => {
try {
const data = await apiFetch<{ portals: Portal[] }>('/portals');
setPortals(data.portals ?? []);
} catch { /* ignore */ }
};
fetch();
const id = setInterval(fetch, 60000);
return () => clearInterval(id);
}, [enabled]);
const markers = useMemo(() =>
portals.map(p => ({
...p,
pos: worldToPx(p.coordinates.ew, p.coordinates.ns, imgW, imgH),
})),
[portals, imgW, imgH]);
if (!enabled || markers.length === 0) return null;
return (
<div className="ml-portals-layer">
{markers.map((p, i) => (
<div
key={i}
className="ml-portal-icon"
style={{ left: p.pos.x, top: p.pos.y }}
title={`${p.portal_name} (by ${p.discovered_by})`}
/>
))}
</div>
);
};

View file

@ -1,138 +0,0 @@
import React, { useState, useMemo, useDeferredValue } from 'react';
import { PlayerList } from '../sidebar/PlayerList';
import { SortButtons, type SortKey } from '../sidebar/SortButtons';
import { SidebarWindowButtons } from '../sidebar/SidebarWindowButtons';
import type { TelemetrySnapshot, VitalsMessage, ServerHealth } from '../../types';
interface Props {
players: TelemetrySnapshot[];
vitals: Map<string, VitalsMessage>;
serverHealth: ServerHealth | null;
totalRares: number;
totalKills: number;
getColor: (name: string) => string;
onSelectPlayer: (name: string) => void;
showHeatmap: boolean;
showPortals: boolean;
onToggleHeatmap: (v: boolean) => void;
onTogglePortals: (v: boolean) => void;
version?: string;
selectedPlayer?: string | null;
}
export const Sidebar: React.FC<Props> = ({
players, vitals, serverHealth, totalRares, totalKills, getColor, onSelectPlayer,
showHeatmap, showPortals, onToggleHeatmap, onTogglePortals, version, selectedPlayer,
}: Props) => {
const [sortKey, setSortKey] = useState<SortKey>('name');
const [filter, setFilter] = useState('');
const serverKph = useMemo(() =>
players.reduce((sum, p) => sum + (parseInt(p.kills_per_hour) || 0), 0),
[players]);
const isOnline = serverHealth?.status?.toLowerCase() === 'online' || serverHealth?.status?.toLowerCase() === 'up';
// Defer player list rendering — sidebar stats don't need real-time updates
const deferredPlayers = useDeferredValue(players);
const deferredVitals = useDeferredValue(vitals);
const sorted = useMemo(() => {
let list = [...deferredPlayers];
if (filter) list = list.filter(p => p.character_name.toLowerCase().startsWith(filter.toLowerCase()));
switch (sortKey) {
case 'kph': list.sort((a, b) => (parseInt(b.kills_per_hour) || 0) - (parseInt(a.kills_per_hour) || 0)); break;
case 'skills': list.sort((a, b) => (b.kills || 0) - (a.kills || 0)); break;
case 'srares': list.sort((a, b) => (b.session_rares ?? 0) - (a.session_rares ?? 0)); break;
case 'tkills': list.sort((a, b) => (b.total_kills ?? 0) - (a.total_kills ?? 0)); break;
case 'kpr': list.sort((a, b) => {
const ar = (a.total_kills ?? 0) / Math.max(1, a.total_rares ?? 1);
const br = (b.total_kills ?? 0) / Math.max(1, b.total_rares ?? 1);
return ar - br;
}); break;
default: list.sort((a, b) => a.character_name.localeCompare(b.character_name));
}
return list;
}, [deferredPlayers, sortKey, filter]);
return (
<div className="ml-sidebar">
{version && <div className="ml-version">v{version}</div>}
<div className="ml-sidebar-header">
<span className="ml-sidebar-title" style={{ cursor: 'pointer' }} onClick={() => {
// 🎵
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:#000;z-index:999999;display:flex;align-items:center;justify-content:center;';
const video = document.createElement('video');
video.src = '/rick.mp4';
video.autoplay = true;
video.loop = true;
video.style.cssText = 'width:100vw;height:100vh;object-fit:cover;';
overlay.appendChild(video);
document.body.appendChild(overlay);
// Violent shake for 1.5s then spin forever
document.body.style.animation = 'ml-shake 0.05s 30';
const style = document.createElement('style');
style.textContent = '@keyframes ml-shake{0%,100%{transform:translate(0) rotate(0)}25%{transform:translate(-15px,10px) rotate(-2deg)}50%{transform:translate(15px,-10px) rotate(2deg)}75%{transform:translate(-10px,-15px) rotate(-1deg)}} @keyframes ml-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}';
document.head.appendChild(style);
setTimeout(() => { overlay.style.animation = 'ml-spin 3s linear infinite'; }, 1500);
video.play().catch(() => {});
}}>Active Mosswart Enjoyers ({players.length})</span>
</div>
<div className="ml-server-status">
<span className={`ml-status-dot ${isOnline ? 'online' : 'offline'}`} />
<span className="ml-status-text">Coldeve {isOnline ? 'Online' : 'Offline'}</span>
{serverHealth?.player_count != null && <span className="ml-status-detail">👥 {serverHealth.player_count}</span>}
{serverHealth?.latency_ms != null && <span className="ml-status-detail">{Math.round(serverHealth.latency_ms)}ms</span>}
{serverHealth?.uptime_seconds != null && (
<span className="ml-status-detail">Up: {Math.floor(serverHealth.uptime_seconds / 3600)}h</span>
)}
</div>
<div className="ml-counters">
<div className="ml-counter rares"><span className="ml-counter-val">{totalRares}</span><span className="ml-counter-lbl">Rares</span></div>
<div className={`ml-counter kph ${serverKph > 5000 ? 'ultra' : ''}`}><span className="ml-counter-val">{serverKph.toLocaleString()}</span><span className="ml-counter-lbl">Server KPH</span></div>
<div className="ml-counter kills"><span className="ml-counter-val">{totalKills.toLocaleString()}</span><span className="ml-counter-lbl">Kills</span></div>
</div>
{/* Tool links */}
<div className="ml-tool-links">
<a href="/inventory.html" target="_blank" className="ml-tool-link">🔍 Inv Search</a>
<a href="/suitbuilder.html" target="_blank" className="ml-tool-link">🛡 Suitbuilder</a>
<a href="/debug.html" target="_blank" className="ml-tool-link">🐛 Debug</a>
</div>
<SidebarWindowButtons />
{/* Map toggles */}
<div className="ml-toggles">
<label className="ml-toggle-label">
<input type="checkbox" checked={showHeatmap} onChange={e => onToggleHeatmap(e.target.checked)} />
<span>Spawn Heatmap</span>
</label>
<label className="ml-toggle-label">
<input type="checkbox" checked={showPortals} onChange={e => onTogglePortals(e.target.checked)} />
<span>Portals</span>
</label>
</div>
<div style={{ borderTop: '1px solid #333', marginTop: 4, paddingTop: 4 }} />
<SortButtons value={sortKey} onChange={setSortKey} />
<input
className="ml-filter"
type="text"
placeholder="Filter players..."
value={filter}
onChange={e => setFilter(e.target.value)}
/>
<PlayerList
players={sorted}
vitals={deferredVitals}
getColor={getColor}
onSelect={onSelectPlayer}
selectedPlayer={selectedPlayer}
/>
</div>
);
};

View file

@ -1,62 +0,0 @@
import React, { useMemo, useEffect, useState } from 'react';
import { worldToPx } from '../../utils/coordinates';
import { apiFetch } from '../../api/client';
interface TrailPoint {
character_name: string;
ew: number;
ns: number;
}
interface Props {
imgW: number;
imgH: number;
getColor: (name: string) => string;
}
export const TrailsSVG: React.FC<Props> = React.memo(({ imgW, imgH, getColor }) => {
const [trails, setTrails] = useState<TrailPoint[]>([]);
useEffect(() => {
const fetchTrails = async () => {
try {
const data = await apiFetch<{ trails: TrailPoint[] }>('/trails/?seconds=600');
setTrails(data.trails ?? []);
} catch { /* ignore */ }
};
fetchTrails();
const id = setInterval(fetchTrails, 2000);
return () => clearInterval(id);
}, []);
const polylines = useMemo(() => {
const byChar: Record<string, string[]> = {};
for (const pt of trails) {
const { x, y } = worldToPx(pt.ew, pt.ns, imgW, imgH);
if (!byChar[pt.character_name]) byChar[pt.character_name] = [];
byChar[pt.character_name].push(`${x},${y}`);
}
return Object.entries(byChar)
.filter(([, pts]) => pts.length >= 2)
.map(([name, pts]) => ({ name, points: pts.join(' ') }));
}, [trails, imgW, imgH]);
return (
<svg className="ml-trails-svg" viewBox={`0 0 ${imgW} ${imgH}`} preserveAspectRatio="none">
{polylines.map(p => (
<polyline
key={p.name}
points={p.points}
stroke={getColor(p.name)}
fill="none"
strokeWidth={2}
strokeOpacity={0.7}
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
</svg>
);
});
TrailsSVG.displayName = 'TrailsSVG';

View file

@ -1,45 +0,0 @@
import React, { useRef, useState, useCallback } from 'react';
import { PlayerRow } from './PlayerRow';
import type { TelemetrySnapshot, VitalsMessage } from '../../types';
interface Props {
players: TelemetrySnapshot[];
vitals: Map<string, VitalsMessage>;
getColor: (name: string) => string;
onSelect: (name: string) => void;
selectedPlayer?: string | null;
}
export const PlayerList: React.FC<Props> = ({ players, vitals, getColor, onSelect, selectedPlayer }) => {
const listRef = useRef<HTMLUListElement>(null);
const [showTop, setShowTop] = useState(false);
const handleScroll = useCallback(() => {
if (listRef.current) setShowTop(listRef.current.scrollTop > 200);
}, []);
return (
<div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
<ul className="ml-player-list" ref={listRef} onScroll={handleScroll}>
{players.map(p => (
<PlayerRow
key={p.character_name}
player={p}
vitals={vitals.get(p.character_name) ?? null}
color={getColor(p.character_name)}
onSelect={() => onSelect(p.character_name)}
isSelected={selectedPlayer === p.character_name}
/>
))}
</ul>
{showTop && (
<button onClick={() => { listRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); }}
style={{ position: 'absolute', bottom: 8, right: 8, width: 28, height: 28, borderRadius: '50%',
background: 'rgba(68,136,255,0.2)', border: '1px solid rgba(68,136,255,0.4)', color: '#6af',
cursor: 'pointer', fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
</button>
)}
</div>
);
};

View file

@ -1,63 +0,0 @@
import React from 'react';
import { formatCoord } from '../../utils/coordinates';
import { useWindowManager } from '../../contexts/WindowManagerContext';
import type { TelemetrySnapshot, VitalsMessage } from '../../types';
interface Props {
player: TelemetrySnapshot;
vitals: VitalsMessage | null;
color: string;
onSelect: () => void;
isSelected?: boolean;
}
export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, color, onSelect, isSelected }) => {
const { openWindow } = useWindowManager();
const vtState = (p.vt_state || 'idle').toLowerCase();
const isActive = vtState === 'combat' || vtState === 'hunt';
const kpr = (p.total_rares ?? 0) > 0
? Math.round((p.total_kills ?? 0) / (p.total_rares ?? 1)).toLocaleString()
: null;
const name = p.character_name;
return (
<li className={`ml-player-row ${isSelected ? 'ml-player-selected' : ''}`} style={{ borderLeftColor: color }}>
<div className="ml-pr-header" onClick={onSelect}>
<span className="ml-pr-name">{name}</span>
<span className="ml-pr-coords">{formatCoord(p.ns, p.ew)}</span>
</div>
<div className="ml-pr-vitals">
<div className="ml-vital-bar hp"><div className="ml-vital-fill" style={{ width: `${v?.health_percentage ?? 0}%` }} /></div>
<div className="ml-vital-bar sta"><div className="ml-vital-fill" style={{ width: `${v?.stamina_percentage ?? 0}%` }} /></div>
<div className="ml-vital-bar mana"><div className="ml-vital-fill" style={{ width: `${v?.mana_percentage ?? 0}%` }} /></div>
</div>
<div className="ml-pr-grid">
<span className="ml-gs" title="Session kills"> {p.kills?.toLocaleString() ?? 0}</span>
<span className="ml-gs" title="Total kills">🏆 {(p.total_kills ?? 0).toLocaleString()}</span>
<span className="ml-gs" title="Kills per hour">{p.kills_per_hour ?? '0'} <span className="ml-suffix">KPH</span></span>
<span className="ml-gs" title="Rares (session / total)">💎 {p.session_rares ?? 0} / {p.total_rares ?? 0}</span>
<span className="ml-gs" title="Kills per rare">{kpr ? <>📊 {kpr} <span className="ml-suffix">KPR</span></> : ''}</span>
<span className={`ml-meta-pill ${isActive ? 'active' : vtState !== 'idle' && vtState !== 'default' && vtState !== '' ? 'other' : ''}`}>{p.vt_state || 'idle'}</span>
<span className="ml-gs" title="Online time">🕐 {p.onlinetime?.replace(/^00\./, '') ?? '--'}</span>
<span className="ml-gs" title="Deaths"> {p.deaths ?? '0'}</span>
<span className="ml-gs" title="Prismatic tapers"><img src="/prismatic-taper-icon.png" className="ml-taper-icon" alt="" />{p.prismatic_taper_count ?? '0'}</span>
</div>
<div className="ml-pr-buttons">
<button className="ml-btn accent" onClick={() => openWindow(`chat-${name}`, `Chat: ${name}`, name)}>Chat</button>
<button className="ml-btn accent" onClick={() => openWindow(`stats-${name}`, `Stats: ${name}`, name)}>Stats</button>
<button className="ml-btn accent" onClick={() => openWindow(`inv-${name}`, `Inventory: ${name}`, name)}>Inv</button>
<button className="ml-btn" onClick={() => openWindow(`char-${name}`, `Character: ${name}`, name)}>Char</button>
<button className="ml-btn" onClick={() => openWindow(`combat-${name}`, `Combat: ${name}`, name)}>Combat</button>
<button className="ml-btn" onClick={() => openWindow(`radar-${name}`, `Radar: ${name}`, name)}>Radar</button>
</div>
</li>
);
});
PlayerRow.displayName = 'PlayerRow';

View file

@ -1,45 +0,0 @@
import React, { useCallback } from 'react';
import { useWindowManager } from '../../contexts/WindowManagerContext';
import { useCurrentUser } from '../../hooks/useCurrentUser';
import { logout } from '../../api/endpoints';
export const SidebarWindowButtons: React.FC = () => {
const { openWindow } = useWindowManager();
const { user } = useCurrentUser();
const isAdmin = !!user?.is_admin;
const onLogout = useCallback(async () => {
if (!confirm('Log out?')) return;
try { await logout(); } catch { window.location.href = '/login'; }
}, []);
return (
<div className="ml-tool-links">
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('agent', 'Overlord Assistant')}>🤖 Assistant</span>
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
title="Opens the player dashboard in a new tab"
onClick={() => window.open('/?view=dashboard', '_blank', 'noopener')}>👥 Dashboard </span>
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('queststatus', 'Quest Status')}>📜 Quests</span>
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('issues', 'Issues Board')}>📋 Issues</span>
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('vitalsharing', 'Vital Sharing')}>🤝 Vitals</span>
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('combatpicker', 'Combat Stats')}> Combat</span>
{isAdmin && (
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('adminusers', 'Admin · Users')}>🛡 Admin</span>
)}
<span
className="ml-tool-link ml-tool-link-logout"
style={{ cursor: 'pointer' }}
onClick={onLogout}
title={user ? `Logged in as ${user.username}` : 'Log out'}
>
🚪 Log out{user ? ` (${user.username})` : ''}
</span>
</div>
);
};

View file

@ -1,31 +0,0 @@
import React from 'react';
export type SortKey = 'name' | 'kph' | 'skills' | 'srares' | 'tkills' | 'kpr';
const SORTS: { key: SortKey; label: string }[] = [
{ key: 'name', label: 'Name' },
{ key: 'kph', label: 'KPH' },
{ key: 'skills', label: 'S.Kills' },
{ key: 'srares', label: 'S.Rares' },
{ key: 'tkills', label: 'T.Kills' },
{ key: 'kpr', label: 'KPR' },
];
interface Props {
value: SortKey;
onChange: (key: SortKey) => void;
}
export const SortButtons: React.FC<Props> = ({ value, onChange }) => (
<div className="ml-sort-buttons">
{SORTS.map(s => (
<button
key={s.key}
className={`ml-sort-btn ${value === s.key ? 'active' : ''}`}
onClick={() => onChange(s.key)}
>
{s.label}
</button>
))}
</div>
);

View file

@ -1,223 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
import {
listAdminUsers,
createAdminUser,
updateAdminUser,
deleteAdminUser,
type AdminUser,
} from '../../api/endpoints';
import { useCurrentUser } from '../../hooks/useCurrentUser';
interface Props {
id: string;
zIndex: number;
}
function fmtCreated(iso: string): string {
try {
const d = new Date(iso);
return d.toISOString().slice(0, 10);
} catch {
return iso;
}
}
export const AdminUsersWindow: React.FC<Props> = ({ id, zIndex }) => {
const { user: me } = useCurrentUser();
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Add-user form state
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newIsAdmin, setNewIsAdmin] = useState(false);
const [creating, setCreating] = useState(false);
// Per-row "reset password" state
const [pwEditingId, setPwEditingId] = useState<number | null>(null);
const [pwValue, setPwValue] = useState('');
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await listAdminUsers();
setUsers(res.users ?? []);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void refresh(); }, [refresh]);
const onCreate = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!newUsername.trim() || newPassword.length < 4) {
setError('Username required and password must be at least 4 chars');
return;
}
setCreating(true);
setError(null);
try {
await createAdminUser(newUsername.trim(), newPassword, newIsAdmin);
setNewUsername(''); setNewPassword(''); setNewIsAdmin(false);
await refresh();
} catch (e) {
setError(String(e));
} finally {
setCreating(false);
}
}, [newUsername, newPassword, newIsAdmin, refresh]);
const onToggleAdmin = useCallback(async (u: AdminUser) => {
setError(null);
try {
await updateAdminUser(u.id, { is_admin: !u.is_admin });
await refresh();
} catch (e) {
setError(String(e));
}
}, [refresh]);
const onSavePassword = useCallback(async (id: number) => {
if (pwValue.length < 4) {
setError('Password must be at least 4 characters');
return;
}
setError(null);
try {
await updateAdminUser(id, { password: pwValue });
setPwEditingId(null);
setPwValue('');
} catch (e) {
setError(String(e));
}
}, [pwValue]);
const onDelete = useCallback(async (u: AdminUser) => {
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return;
setError(null);
try {
await deleteAdminUser(u.id);
await refresh();
} catch (e) {
setError(String(e));
}
}, [refresh]);
return (
<DraggableWindow id={id} title="🛡️ Admin · Users" zIndex={zIndex} width={620} height={540}>
<div className="ml-admin">
{error && <div className="ml-admin-error">{error}</div>}
<section className="ml-admin-section">
<h3>Add user</h3>
<form onSubmit={onCreate} className="ml-admin-create">
<input
type="text"
placeholder="Username"
value={newUsername}
onChange={e => setNewUsername(e.target.value)}
disabled={creating}
autoComplete="off"
/>
<input
type="password"
placeholder="Password (min 4)"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
disabled={creating}
autoComplete="new-password"
/>
<label>
<input
type="checkbox"
checked={newIsAdmin}
onChange={e => setNewIsAdmin(e.target.checked)}
disabled={creating}
/>
admin
</label>
<button type="submit" disabled={creating || !newUsername.trim() || newPassword.length < 4}>
{creating ? 'Adding…' : 'Add'}
</button>
</form>
</section>
<section className="ml-admin-section">
<h3>Users {loading && <span className="ml-admin-muted">(loading)</span>}</h3>
<table className="ml-admin-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Admin</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => {
const isMe = me != null && me.username.toLowerCase() === u.username.toLowerCase();
return (
<tr key={u.id}>
<td>{u.id}</td>
<td>
{u.username}
{isMe && <span className="ml-admin-muted"> (you)</span>}
</td>
<td>
<button
className="ml-admin-toggle"
onClick={() => onToggleAdmin(u)}
title="Click to toggle admin"
>
{u.is_admin ? '✓' : ''}
</button>
</td>
<td>{fmtCreated(u.created_at)}</td>
<td>
{pwEditingId === u.id ? (
<span className="ml-admin-pw-edit">
<input
type="text"
placeholder="New password"
value={pwValue}
onChange={e => setPwValue(e.target.value)}
autoFocus
/>
<button onClick={() => onSavePassword(u.id)}>Save</button>
<button onClick={() => { setPwEditingId(null); setPwValue(''); }}>
Cancel
</button>
</span>
) : (
<>
<button onClick={() => { setPwEditingId(u.id); setPwValue(''); }}>
Reset PW
</button>
{!isMe && (
<button className="ml-admin-danger" onClick={() => onDelete(u)}>
Delete
</button>
)}
</>
)}
</td>
</tr>
);
})}
{users.length === 0 && !loading && (
<tr><td colSpan={5} className="ml-admin-muted">No users.</td></tr>
)}
</tbody>
</table>
</section>
</div>
</DraggableWindow>
);
};

View file

@ -1,180 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
import {
agentAsk,
agentNewSession,
agentSessionHistory,
type AgentHistoryMessage,
} from '../../api/endpoints';
interface Props {
id: string;
zIndex: number;
}
interface ChatMsg {
role: 'user' | 'assistant' | 'error';
text: string;
}
const SESSION_KEY = 'overlord_agent_session_id';
/** UUID is preferred but crypto.randomUUID is only available in secure contexts. */
function newUuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// RFC4122-ish fallback
const r = (n: number) => Math.floor(Math.random() * n);
return `${r(0x100000000).toString(16).padStart(8, '0')}-${r(0x10000).toString(16).padStart(4, '0')}-4${r(0x1000).toString(16).padStart(3, '0')}-${(8 + r(4)).toString(16)}${r(0x1000).toString(16).padStart(3, '0')}-${r(0x1000000000000).toString(16).padStart(12, '0')}`;
}
function loadSessionId(): string {
try {
const stored = localStorage.getItem(SESSION_KEY);
if (stored) return stored;
} catch { /* ignore */ }
const fresh = newUuid();
try { localStorage.setItem(SESSION_KEY, fresh); } catch { /* ignore */ }
return fresh;
}
export const AgentWindow: React.FC<Props> = ({ id, zIndex }) => {
const [sessionId, setSessionId] = useState<string>(() => loadSessionId());
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [hydrating, setHydrating] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
// Rehydrate from server-side session JSONL on mount / session change.
useEffect(() => {
let cancelled = false;
setHydrating(true);
agentSessionHistory(sessionId)
.then(res => {
if (cancelled) return;
const msgs: ChatMsg[] = (res.messages ?? []).map((m: AgentHistoryMessage) => ({
role: m.role,
text: m.text,
}));
setMessages(msgs);
})
.catch(() => {
if (!cancelled) setMessages([]);
})
.finally(() => {
if (!cancelled) setHydrating(false);
});
return () => { cancelled = true; };
}, [sessionId]);
// Auto-scroll to bottom on new messages.
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages.length, loading]);
const send = useCallback(async () => {
const text = input.trim();
if (!text || loading) return;
setInput('');
setMessages(prev => [...prev, { role: 'user', text }]);
setLoading(true);
try {
const res = await agentAsk(text, sessionId);
setMessages(prev => [
...prev,
{ role: res.is_error ? 'error' : 'assistant', text: res.result || '(no response)' },
]);
} catch (err) {
setMessages(prev => [
...prev,
{ role: 'error', text: `Request failed: ${String(err)}` },
]);
} finally {
setLoading(false);
}
}, [input, loading, sessionId]);
const newChat = useCallback(async () => {
if (loading) return;
let fresh = '';
try {
const res = await agentNewSession();
fresh = res.session_id;
} catch {
fresh = newUuid();
}
try { localStorage.setItem(SESSION_KEY, fresh); } catch { /* ignore */ }
setSessionId(fresh);
setMessages([]);
setInput('');
}, [loading]);
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void send();
}
}, [send]);
return (
<DraggableWindow id={id} title="🤖 Overlord Assistant" zIndex={zIndex} width={520} height={620}>
<div className="ml-agent">
<div className="ml-agent-toolbar">
<button className="ml-agent-btn" onClick={newChat} disabled={loading}>+ New Chat</button>
<span className="ml-agent-session" title={sessionId}>{sessionId.slice(0, 8)}</span>
</div>
<div className="ml-agent-messages" ref={scrollRef}>
{hydrating && messages.length === 0 && (
<div className="ml-agent-empty">Loading conversation</div>
)}
{!hydrating && messages.length === 0 && (
<div className="ml-agent-empty">
Ask anything about the live game state players, kills, inventory,
suitbuilder, recent rares, etc.
</div>
)}
{messages.map((m, i) => (
<div key={i} className={`ml-agent-msg ml-agent-${m.role}`}>
<div className="ml-agent-role">
{m.role === 'user' ? 'You' : m.role === 'assistant' ? 'Overlord' : 'Error'}
</div>
<div className="ml-agent-text">{m.text}</div>
</div>
))}
{loading && (
<div className="ml-agent-msg ml-agent-assistant">
<div className="ml-agent-role">Overlord</div>
<div className="ml-agent-text ml-agent-thinking">Thinking</div>
</div>
)}
</div>
<form
className="ml-agent-form"
onSubmit={e => { e.preventDefault(); void send(); }}
>
<textarea
className="ml-agent-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={onKeyDown}
placeholder={loading ? 'Waiting for response…' : 'Type a message — Enter to send, Shift+Enter for newline'}
disabled={loading}
rows={2}
/>
<button
type="submit"
className="ml-agent-send"
disabled={loading || !input.trim()}
>
Send
</button>
</form>
</div>
</DraggableWindow>
);
};

View file

@ -1,282 +0,0 @@
import React, { useEffect, useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Props { id: string; charName: string; zIndex: number; vitals?: any; liveStats?: any; }
// Property ID maps — verbatim from v1 script.js lines 1843-1876
const TS_AUGMENTATIONS: Record<number, string> = {
218:'Reinforcement of the Lugians',219:"Bleeargh's Fortitude",220:"Oswald's Enhancement",
221:"Siraluun's Blessing",222:'Enduring Calm',223:'Steadfast Will',
224:"Ciandra's Essence",225:"Yoshi's Essence",226:"Jibril's Essence",
227:"Celdiseth's Essence",228:"Koga's Essence",229:'Shadow of the Seventh Mule',
230:'Might of the Seventh Mule',231:'Clutch of the Miser',232:'Enduring Enchantment',
233:'Critical Protection',234:'Quick Learner',235:"Ciandra's Fortune",
236:'Charmed Smith',237:'Innate Renewal',238:"Archmage's Endurance",
239:'Enhancement of the Blade Turner',240:'Enhancement of the Arrow Turner',
241:'Enhancement of the Mace Turner',242:'Caustic Enhancement',243:'Fierce Impaler',
244:'Iron Skin of the Invincible',245:'Eye of the Remorseless',246:'Hand of the Remorseless',
294:'Master of the Steel Circle',295:'Master of the Focused Eye',
296:'Master of the Five Fold Path',297:'Frenzy of the Slayer',
298:'Iron Skin of the Invincible',299:'Jack of All Trades',
300:'Infused Void Magic',301:'Infused War Magic',
302:'Infused Life Magic',309:'Infused Item Magic',
310:'Infused Creature Magic',326:'Clutch of the Miser',
328:'Enduring Enchantment',
};
const TS_AURAS: Record<number, string> = {
333:'Valor / Destruction',334:'Protection',335:'Glory / Retribution',
336:'Temperance / Hardening',338:'Aetheric Vision',339:'Mana Flow',
340:'Mana Infusion',342:'Purity',343:'Craftsman',344:'Specialization',365:'World',
};
const TS_RATINGS: Record<number, string> = {
370:'Damage',371:'Damage Resistance',372:'Critical',373:'Critical Resistance',
374:'Critical Damage',375:'Critical Damage Resistance',376:'Healing Boost',379:'Vitality',
};
const TS_SOCIETY: Record<number, string> = { 287:'Celestial Hand',288:'Eldrytch Web',289:'Radiant Blood' };
const TS_MASTERIES: Record<number, string> = { 354:'Melee',355:'Ranged',362:'Summoning' };
const TS_MASTERY_NAMES: Record<number, string> = { 1:'Unarmed',2:'Swords',3:'Axes',4:'Maces',5:'Spears',6:'Daggers',7:'Staves',8:'Bows',9:'Crossbows',10:'Thrown',11:'Two-Handed',12:'Void',13:'War',14:'Life' };
const TS_GENERAL: Record<number, string> = { 181:'Chess Rank',192:'Fishing Skill',199:'Total Augmentations',322:'Aetheria Slots',390:'Enlightenment' };
function societyRank(v: number): string {
if (v >= 1001) return 'Master';
if (v >= 301) return 'Lord';
if (v >= 151) return 'Knight';
if (v >= 31) return 'Adept';
return 'Initiate';
}
const gold = '#af7a30';
const navy = '#000022';
export const CharacterWindow: React.FC<Props> = ({ id, charName, zIndex, vitals, liveStats }) => {
const [fetchedData, setFetchedData] = useState<any>(null);
const [leftTab, setLeftTab] = useState(0);
const [rightTab, setRightTab] = useState(0);
// Initial fetch from API
useEffect(() => {
apiFetch<any>(`/character-stats/${encodeURIComponent(charName)}`).then(setFetchedData).catch(() => {});
}, [charName]);
// Use live WS data if available (more current), fall back to API fetch
const data = liveStats || fetchedData;
const fmt = (n: any) => n != null ? Number(n).toLocaleString() : '\u2014';
const sd = data?.stats_data || data || {};
const attrs = sd.attributes || {};
const skills = sd.skills || {};
const vit = sd.vitals || {};
const titles = sd.titles || [];
const props = sd.properties || {};
// Group skills
const specSkills = Object.entries(skills).filter(([,v]:any) => v?.training === 'Specialized').sort(([a],[b]) => a.localeCompare(b));
const trainedSkills = Object.entries(skills).filter(([,v]:any) => v?.training === 'Trained').sort(([a],[b]) => a.localeCompare(b));
// Property-based data
const augs = Object.entries(props).filter(([id,v]) => TS_AUGMENTATIONS[parseInt(id)] && Number(v) > 0).map(([id,v]) => ({ name: TS_AUGMENTATIONS[parseInt(id)], uses: Number(v) }));
const auras = Object.entries(props).filter(([id,v]) => TS_AURAS[parseInt(id)] && Number(v) > 0).map(([id,v]) => ({ name: TS_AURAS[parseInt(id)], uses: Number(v) }));
const ratings = Object.entries(props).filter(([id,v]) => TS_RATINGS[parseInt(id)] && Number(v) > 0).map(([id,v]) => ({ name: TS_RATINGS[parseInt(id)], value: Number(v) }));
const generalRows: Array<{name:string;value:any}> = [];
if (data?.birth) generalRows.push({ name: 'Birth', value: data.birth });
if (data?.deaths != null) generalRows.push({ name: 'Deaths', value: fmt(data.deaths) });
Object.entries(props).forEach(([id,v]) => { const nid = parseInt(id); if (TS_GENERAL[nid]) generalRows.push({ name: TS_GENERAL[nid], value: v }); });
const masteryRows: Array<{name:string;value:string}> = [];
Object.entries(props).forEach(([id,v]) => { const nid = parseInt(id); if (TS_MASTERIES[nid]) masteryRows.push({ name: TS_MASTERIES[nid], value: TS_MASTERY_NAMES[Number(v)] || `Unknown (${v})` }); });
const societyRows: Array<{name:string;rank:string;value:number}> = [];
Object.entries(props).forEach(([id,v]) => { const nid = parseInt(id); if (TS_SOCIETY[nid] && Number(v) > 0) societyRows.push({ name: TS_SOCIETY[nid], rank: societyRank(Number(v)), value: Number(v) }); });
const tabStyle = (active: boolean): React.CSSProperties => ({
padding: '5px 8px', fontSize: 12, fontWeight: 'bold', color: '#fff', cursor: 'pointer', userSelect: 'none',
borderTop: `2px solid ${active ? gold : navy}`, borderLeft: `2px solid ${active ? gold : navy}`, borderRight: `2px solid ${active ? gold : navy}`,
background: active ? 'rgba(0,100,0,0.4)' : 'transparent',
});
const boxStyle: React.CSSProperties = { background: '#000', border: `2px solid ${gold}`, maxHeight: 400, overflowY: 'auto', overflowX: 'hidden' };
const colNameStyle: React.CSSProperties = { background: '#222', fontWeight: 'bold', fontSize: 12, padding: '2px 6px' };
const cellL: React.CSSProperties = { padding: '2px 6px', background: 'rgba(0,100,0,0.4)', whiteSpace: 'nowrap' };
const cellR: React.CSSProperties = { padding: '2px 6px', background: 'rgba(0,0,100,0.4)', textAlign: 'right', whiteSpace: 'nowrap' };
const cellCreation: React.CSSProperties = { padding: '2px 6px', color: '#ccc' };
return (
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={740} height={600}>
<div style={{ background: navy, color: '#fff', font: '14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', overflowY: 'auto', padding: '10px 15px 15px', flex: 1 }}>
{/* Header */}
<div style={{ marginBottom: 10 }}>
<h1 style={{ margin: '0 0 2px', fontSize: 28, fontWeight: 'bold' }}>
{charName}
<span style={{ fontSize: '200%', color: '#fff27f', float: 'right' }}>{data?.level || ''}</span>
</h1>
<div style={{ fontSize: '85%', color: 'gold' }}>
{[data?.gender, data?.race].filter(Boolean).join(' ') || 'Awaiting character data...'}
</div>
</div>
{/* XP / Luminance */}
<div style={{ fontSize: '85%', margin: '6px 0 10px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 20px' }}>
<div>Total XP: {fmt(data?.total_xp)}</div>
<div style={{ textAlign: 'right' }}>Unassigned XP: {fmt(data?.unassigned_xp)}</div>
<div>Luminance: {data?.luminance_earned != null ? `${fmt(data.luminance_earned)} / ${fmt(data.luminance_total)}` : '\u2014'}</div>
<div style={{ textAlign: 'right' }}>Deaths: {fmt(data?.deaths)}</div>
</div>
{/* Tab row: two side-by-side containers */}
<div style={{ display: 'flex', gap: 13, flexWrap: 'wrap' }}>
{/* Left tabs */}
<div style={{ width: 320 }}>
<div style={{ height: 30, display: 'flex' }}>
{['Attributes', 'Skills', 'Titles'].map((t, i) => (
<div key={t} style={tabStyle(leftTab === i)} onClick={() => setLeftTab(i)}>{t}</div>
))}
</div>
<div style={boxStyle}>
{leftTab === 0 && (
<>
{/* Vitals bars */}
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: 8, borderBottom: `2px solid ${gold}` }}>
{[
{ label: 'Health', pct: vitals?.health_percentage ?? 0, cur: vitals?.health_current, max: vitals?.health_max, bg: '#cc3333' },
{ label: 'Stamina', pct: vitals?.stamina_percentage ?? 0, cur: vitals?.stamina_current, max: vitals?.stamina_max, bg: '#ccaa33' },
{ label: 'Mana', pct: vitals?.mana_percentage ?? 0, cur: vitals?.mana_current, max: vitals?.mana_max, bg: '#3366cc' },
].map(v => (
<div key={v.label} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 55, fontSize: 12, color: '#ccc' }}>{v.label}</span>
<div style={{ flex: 1, height: 14, overflow: 'hidden', position: 'relative', border: `1px solid ${gold}` }}>
<div style={{ height: '100%', width: `${v.pct}%`, background: v.bg, transition: 'width 0.5s ease' }} />
</div>
<span style={{ width: 80, textAlign: 'right', fontSize: 12, color: '#ccc' }}>{v.cur ?? '\u2014'} / {v.max ?? '\u2014'}</span>
</div>
))}
</div>
{/* Attributes table */}
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<thead><tr><td style={colNameStyle}>Attribute</td><td style={colNameStyle}>Creation</td><td style={colNameStyle}>Base</td></tr></thead>
<tbody>
{['strength','endurance','coordination','quickness','focus','self'].map(a => (
<tr key={a}><td style={cellL}>{a.charAt(0).toUpperCase() + a.slice(1)}</td><td style={cellCreation}>{attrs[a]?.creation ?? '\u2014'}</td><td style={cellR}>{attrs[a]?.base ?? '\u2014'}</td></tr>
))}
</tbody>
</table>
{/* Vitals base table */}
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<thead><tr><td style={colNameStyle}>Vital</td><td style={colNameStyle}>Base</td></tr></thead>
<tbody>
{['health','stamina','mana'].map(v => (
<tr key={v}><td style={cellL}>{v.charAt(0).toUpperCase() + v.slice(1)}</td><td style={cellR}>{vit[v]?.base ?? '\u2014'}</td></tr>
))}
</tbody>
</table>
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<tbody><tr><td style={cellL}>Skill Credits</td><td style={cellR}>{fmt(sd.skill_credits)}</td></tr></tbody>
</table>
</>
)}
{leftTab === 1 && (
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<thead><tr><td style={colNameStyle}>Skill</td><td style={colNameStyle}>Level</td></tr></thead>
<tbody>
{specSkills.map(([k, v]: any) => (
<tr key={k}><td style={{ padding: '2px 6px', background: 'linear-gradient(to right, #392067, #392067, black)' }}>{k.replace(/_/g,' ').replace(/\b\w/g, (c:string) => c.toUpperCase())}</td>
<td style={{ ...cellR, background: 'linear-gradient(to right, #392067, #392067, black)' }}>{v.base}</td></tr>
))}
{trainedSkills.map(([k, v]: any) => (
<tr key={k}><td style={{ padding: '2px 6px', background: 'linear-gradient(to right, #0f3c3e, #0f3c3e, black)' }}>{k.replace(/_/g,' ').replace(/\b\w/g, (c:string) => c.toUpperCase())}</td>
<td style={{ ...cellR, background: 'linear-gradient(to right, #0f3c3e, #0f3c3e, black)' }}>{v.base}</td></tr>
))}
{specSkills.length === 0 && trainedSkills.length === 0 && <tr><td colSpan={2} style={{ padding: 10, color: '#666', fontStyle: 'italic', textAlign: 'center' }}>No skill data</td></tr>}
</tbody>
</table>
)}
{leftTab === 2 && (
<div style={{ padding: '6px 10px', fontSize: 13 }}>
{titles.length > 0 ? titles.map((t: string, i: number) => <div key={i} style={{ padding: '1px 0' }}>{t}</div>) :
<div style={{ color: '#666', fontStyle: 'italic', textAlign: 'center', padding: 10 }}>No titles</div>}
</div>
)}
</div>
</div>
{/* Right tabs */}
<div style={{ width: 320 }}>
<div style={{ height: 30, display: 'flex' }}>
{['Augmentations', 'Ratings', 'Other'].map((t, i) => (
<div key={t} style={tabStyle(rightTab === i)} onClick={() => setRightTab(i)}>{t}</div>
))}
</div>
<div style={boxStyle}>
{rightTab === 0 && (
augs.length || auras.length ? (
<>
{augs.length > 0 && (
<><div style={{ background: '#222', padding: '4px 8px', fontWeight: 'bold', fontSize: 13, borderBottom: `1px solid ${gold}` }}>Augmentations</div>
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<thead><tr><td style={colNameStyle}>Name</td><td style={colNameStyle}>Uses</td></tr></thead>
<tbody>{augs.map(a => <tr key={a.name}><td style={{ padding: '2px 6px' }}>{a.name}</td><td style={{ padding: '2px 6px', textAlign: 'right' }}>{a.uses}</td></tr>)}</tbody>
</table></>
)}
{auras.length > 0 && (
<><div style={{ background: '#222', padding: '4px 8px', fontWeight: 'bold', fontSize: 13, borderBottom: `1px solid ${gold}` }}>Auras</div>
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<thead><tr><td style={colNameStyle}>Name</td><td style={colNameStyle}>Uses</td></tr></thead>
<tbody>{auras.map(a => <tr key={a.name}><td style={{ padding: '2px 6px' }}>{a.name}</td><td style={{ padding: '2px 6px', textAlign: 'right' }}>{a.uses}</td></tr>)}</tbody>
</table></>
)}
</>
) : <div style={{ color: '#666', fontStyle: 'italic', textAlign: 'center', padding: 10 }}>No augmentation data</div>
)}
{rightTab === 1 && (
ratings.length > 0 ? (
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<thead><tr><td style={colNameStyle}>Rating</td><td style={colNameStyle}>Value</td></tr></thead>
<tbody>{ratings.map(r => <tr key={r.name}><td style={{ padding: '2px 6px' }}>{r.name}</td><td style={{ padding: '2px 6px', textAlign: 'right' }}>{r.value}</td></tr>)}</tbody>
</table>
) : <div style={{ color: '#666', fontStyle: 'italic', textAlign: 'center', padding: 10 }}>No rating data</div>
)}
{rightTab === 2 && (
<div>
{generalRows.length > 0 && (
<><div style={{ background: '#222', padding: '4px 8px', fontWeight: 'bold', fontSize: 13, borderBottom: `1px solid ${gold}` }}>General</div>
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<tbody>{generalRows.map(r => <tr key={r.name}><td style={{ padding: '2px 6px' }}>{r.name}</td><td style={{ padding: '2px 6px', textAlign: 'right' }}>{r.value}</td></tr>)}</tbody>
</table></>
)}
{masteryRows.length > 0 && (
<><div style={{ background: '#222', padding: '4px 8px', fontWeight: 'bold', fontSize: 13, borderBottom: `1px solid ${gold}` }}>Masteries</div>
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<tbody>{masteryRows.map(m => <tr key={m.name}><td style={{ padding: '2px 6px' }}>{m.name}</td><td style={{ padding: '2px 6px', textAlign: 'right' }}>{m.value}</td></tr>)}</tbody>
</table></>
)}
{societyRows.length > 0 && (
<><div style={{ background: '#222', padding: '4px 8px', fontWeight: 'bold', fontSize: 13, borderBottom: `1px solid ${gold}` }}>Society</div>
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<tbody>{societyRows.map(s => <tr key={s.name}><td style={{ padding: '2px 6px' }}>{s.name}</td><td style={{ padding: '2px 6px', textAlign: 'right' }}>{s.rank} ({s.value})</td></tr>)}</tbody>
</table></>
)}
{generalRows.length === 0 && masteryRows.length === 0 && societyRows.length === 0 &&
<div style={{ color: '#666', fontStyle: 'italic', textAlign: 'center', padding: 10 }}>No additional data</div>
}
</div>
)}
</div>
</div>
</div>
{/* Allegiance section */}
{data?.allegiance && (
<div style={{ marginTop: 5, border: `2px solid ${gold}`, background: '#000' }}>
<div style={{ background: '#222', padding: '4px 8px', fontWeight: 'bold', fontSize: 13, borderBottom: `1px solid ${gold}` }}>Allegiance</div>
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<tbody>
{data.allegiance.name && <tr><td style={{ padding: '2px 6px', color: '#ccc', width: 100 }}>Name</td><td style={{ padding: '2px 6px' }}>{data.allegiance.name}</td></tr>}
{data.allegiance.monarch?.name && <tr><td style={{ padding: '2px 6px', color: '#ccc' }}>Monarch</td><td style={{ padding: '2px 6px' }}>{data.allegiance.monarch.name}</td></tr>}
{data.allegiance.patron?.name && <tr><td style={{ padding: '2px 6px', color: '#ccc' }}>Patron</td><td style={{ padding: '2px 6px' }}>{data.allegiance.patron.name}</td></tr>}
{data.allegiance.rank != null && <tr><td style={{ padding: '2px 6px', color: '#ccc' }}>Rank</td><td style={{ padding: '2px 6px' }}>{data.allegiance.rank}</td></tr>}
{data.allegiance.followers != null && <tr><td style={{ padding: '2px 6px', color: '#ccc' }}>Followers</td><td style={{ padding: '2px 6px' }}>{data.allegiance.followers}</td></tr>}
</tbody>
</table>
</div>
)}
</div>
</DraggableWindow>
);
};

View file

@ -1,140 +0,0 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { DraggableWindow } from './DraggableWindow';
interface ChatMsg {
text: string;
color?: number;
timestamp: string;
}
const CHAT_COLORS: Record<number, string> = {
0:'#00FF00', 2:'#FFFFFF', 3:'#FF0000', 4:'#FFFFFF', 5:'#33CCFF', 6:'#CCFF99',
7:'#00FFFF', 14:'#FFD700', 15:'#FF69B4', 17:'#AAAAFF', 18:'#88FF88',
21:'#FF8888', 22:'#FFAA66',
};
const MAX_HISTORY = 50;
const HISTORY_KEY = (name: string) => `mo-chat-history-${name}`;
function loadHistory(charName: string): string[] {
try {
const raw = localStorage.getItem(HISTORY_KEY(charName));
return raw ? JSON.parse(raw) : [];
} catch { return []; }
}
function saveHistory(charName: string, history: string[]) {
try {
localStorage.setItem(HISTORY_KEY(charName), JSON.stringify(history.slice(-MAX_HISTORY)));
} catch { /* quota exceeded — ignore */ }
}
interface Props {
id: string;
charName: string;
zIndex: number;
messages: ChatMsg[];
socket: WebSocket | null;
}
export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages, socket }) => {
const msgsRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState('');
const [hasNewBelow, setHasNewBelow] = useState(false);
const historyRef = useRef<string[]>(loadHistory(charName));
const historyIndexRef = useRef(-1);
const savedInputRef = useRef('');
const userScrolledRef = useRef(false);
useEffect(() => {
const el = msgsRef.current;
if (!el) return;
if (!userScrolledRef.current) {
el.scrollTop = el.scrollHeight;
setHasNewBelow(false);
} else {
setHasNewBelow(true);
}
}, [messages.length]);
const handleScroll = useCallback(() => {
const el = msgsRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
userScrolledRef.current = !atBottom;
if (atBottom) setHasNewBelow(false);
}, []);
const scrollToBottom = useCallback(() => {
const el = msgsRef.current;
if (el) { el.scrollTop = el.scrollHeight; userScrolledRef.current = false; setHasNewBelow(false); }
}, []);
const handleSend = useCallback((e: React.FormEvent) => {
e.preventDefault();
const text = input.trim();
if (!text || !socket || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ player_name: charName, command: text }));
// Add to history
historyRef.current.push(text);
if (historyRef.current.length > MAX_HISTORY) historyRef.current.shift();
saveHistory(charName, historyRef.current);
historyIndexRef.current = -1;
savedInputRef.current = '';
setInput('');
// Snap back to bottom on send
userScrolledRef.current = false;
}, [input, socket, charName]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
const history = historyRef.current;
if (history.length === 0) return;
if (e.key === 'ArrowUp') {
e.preventDefault();
if (historyIndexRef.current === -1) {
// Starting to browse — save current input
savedInputRef.current = input;
historyIndexRef.current = history.length - 1;
} else if (historyIndexRef.current > 0) {
historyIndexRef.current--;
}
setInput(history[historyIndexRef.current]);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndexRef.current === -1) return; // not browsing
if (historyIndexRef.current < history.length - 1) {
historyIndexRef.current++;
setInput(history[historyIndexRef.current]);
} else {
// Past the end — restore saved input
historyIndexRef.current = -1;
setInput(savedInputRef.current);
}
}
}, [input]);
return (
<DraggableWindow id={id} title={`Chat: ${charName}`} zIndex={zIndex} width={600} height={300}>
<div className="ml-chat-messages" ref={msgsRef} onScroll={handleScroll}>
{messages.map((m, i) => (
<div key={i} className="ml-chat-line" style={{ color: CHAT_COLORS[m.color ?? 2] ?? '#ddd' }}>
{m.text}
</div>
))}
</div>
{hasNewBelow && (
<div onClick={scrollToBottom} style={{ padding: '3px 0', textAlign: 'center', fontSize: '0.65rem', color: '#6af', background: '#1a2a3a', cursor: 'pointer', borderTop: '1px solid #334' }}>
New messages below
</div>
)}
<form className="ml-chat-form" onSubmit={handleSend}>
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown} placeholder="Enter chat..." />
</form>
</DraggableWindow>
);
};

View file

@ -1,29 +0,0 @@
import React from 'react';
import { DraggableWindow } from './DraggableWindow';
import { useWindowManager } from '../../contexts/WindowManagerContext';
import type { CharacterState } from '../../types';
interface Props { id: string; zIndex: number; characters: Map<string, CharacterState>; }
export const CombatPickerWindow: React.FC<Props> = ({ id, zIndex, characters }) => {
const { openWindow } = useWindowManager();
const chars = Array.from(characters.keys()).sort();
return (
<DraggableWindow id={id} title="Combat Stats — Select Character" zIndex={zIndex} width={300} height={400}>
<div style={{ flex: 1, overflowY: 'auto', padding: 6 }}>
{chars.length === 0 ? (
<div style={{ padding: 12, color: '#666', textAlign: 'center', fontSize: '0.8rem' }}>No characters online</div>
) : chars.map(name => (
<div key={name}
style={{ padding: '5px 8px', cursor: 'pointer', borderBottom: '1px solid #222', color: '#ccc', fontSize: '0.82rem' }}
onMouseEnter={e => (e.currentTarget.style.background = '#2a2a2a')}
onMouseLeave={e => (e.currentTarget.style.background = '')}
onClick={() => openWindow(`combat-${name}`, `Combat: ${name}`, name)}>
{name}
</div>
))}
</div>
</DraggableWindow>
);
};

View file

@ -1,204 +0,0 @@
import React, { useEffect, useState, useMemo } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Props { id: string; charName: string; zIndex: number; }
const ELEMENTS = ['Typeless','Slash','Pierce','Bludgeon','Fire','Cold','Acid','Electric'];
function getDmg(side: any, atkType: string, el: string): number {
return (side?.[atkType]?.[el]?.total_normal_damage ?? 0) + (side?.[atkType]?.[el]?.total_crit_damage ?? 0);
}
function flatten(side: any) {
let r = { attacks: 0, failed: 0, crits: 0, normalDmg: 0, maxNormal: 0, critDmg: 0, maxCrit: 0 };
if (!side) return r;
for (const byEl of Object.values(side) as any[]) {
for (const s of Object.values(byEl) as any[]) {
r.attacks += s.total_attacks ?? 0;
r.failed += s.failed_attacks ?? 0;
r.crits += s.crits ?? 0;
r.normalDmg += s.total_normal_damage ?? 0;
r.maxNormal = Math.max(r.maxNormal, s.max_normal_damage ?? 0);
r.critDmg += s.total_crit_damage ?? 0;
r.maxCrit = Math.max(r.maxCrit, s.max_crit_damage ?? 0);
}
}
return r;
}
function flattenType(side: any, type: string) {
let r = { attacks: 0, failed: 0 };
const byEl = side?.[type];
if (!byEl) return r;
for (const s of Object.values(byEl) as any[]) { r.attacks += s.total_attacks ?? 0; r.failed += s.failed_attacks ?? 0; }
return r;
}
export const CombatStatsWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
const [data, setData] = useState<any>(null);
const [mode, setMode] = useState<'session' | 'lifetime'>('session');
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
apiFetch<any>(`/combat-stats/${encodeURIComponent(charName)}`).then(setData).catch(() => {});
const iv = setInterval(() => {
apiFetch<any>(`/combat-stats/${encodeURIComponent(charName)}`).then(setData).catch(() => {});
}, 10000);
return () => clearInterval(iv);
}, [charName]);
const state = data?.[mode];
const monsters = state?.monsters ?? {};
const names = Object.keys(monsters).filter(n => n !== '__cloak_surges__').sort();
// Aggregate for selected or all
const agg = useMemo(() => {
let offense: any = {}, defense: any = {}, aeth = 0, cloak = 0;
const list = selected ? [monsters[selected]].filter(Boolean) : names.map(n => monsters[n]);
for (const m of list) {
if (!m) continue;
for (const [at, byEl] of Object.entries(m.offense ?? {})) {
if (!offense[at]) offense[at] = {};
for (const [el, s] of Object.entries(byEl as any)) {
if (!offense[at][el]) offense[at][el] = { total_attacks:0, failed_attacks:0, crits:0, total_normal_damage:0, max_normal_damage:0, total_crit_damage:0, max_crit_damage:0 };
const t = offense[at][el]; const src = s as any;
t.total_attacks += src.total_attacks ?? 0; t.failed_attacks += src.failed_attacks ?? 0; t.crits += src.crits ?? 0;
t.total_normal_damage += src.total_normal_damage ?? 0; t.max_normal_damage = Math.max(t.max_normal_damage, src.max_normal_damage ?? 0);
t.total_crit_damage += src.total_crit_damage ?? 0; t.max_crit_damage = Math.max(t.max_crit_damage, src.max_crit_damage ?? 0);
}
}
for (const [at, byEl] of Object.entries(m.defense ?? {})) {
if (!defense[at]) defense[at] = {};
for (const [el, s] of Object.entries(byEl as any)) {
if (!defense[at][el]) defense[at][el] = { total_attacks:0, failed_attacks:0, crits:0, total_normal_damage:0, max_normal_damage:0, total_crit_damage:0, max_crit_damage:0 };
const t = defense[at][el]; const src = s as any;
t.total_attacks += src.total_attacks ?? 0; t.failed_attacks += src.failed_attacks ?? 0;
t.total_normal_damage += src.total_normal_damage ?? 0; t.max_normal_damage = Math.max(t.max_normal_damage, src.max_normal_damage ?? 0);
t.total_crit_damage += src.total_crit_damage ?? 0; t.max_crit_damage = Math.max(t.max_crit_damage, src.max_crit_damage ?? 0);
}
}
aeth += m.aetheria_surges ?? 0;
cloak += m.cloak_surges ?? 0;
}
if (monsters['__cloak_surges__'] && !selected) cloak += monsters['__cloak_surges__'].cloak_surges ?? 0;
return { offense, defense, aeth, cloak };
}, [monsters, names, selected]);
const off = flatten(agg.offense);
const defMM = flattenType(agg.defense, 'MeleeMissile');
const defMag = flattenType(agg.defense, 'Magic');
const hitRate = off.attacks > 0 ? ((off.attacks - off.failed) / off.attacks * 100).toFixed(0) : '0';
const evadeRate = defMM.attacks > 0 ? (defMM.failed / defMM.attacks * 100).toFixed(0) : '0';
const resistRate = defMag.attacks > 0 ? (defMag.failed / defMag.attacks * 100).toFixed(0) : '0';
const hits = off.attacks - off.failed;
const normalHits = hits - off.crits;
const avgN = normalHits > 0 ? Math.round(off.normalDmg / normalHits) : 0;
const avgC = off.crits > 0 ? Math.round(off.critDmg / off.crits) : 0;
const critPct = hits > 0 ? (off.crits / hits * 100).toFixed(1) : '0';
const fmtN = (n: number) => n === 0 ? '' : n.toLocaleString();
return (
<DraggableWindow id={id} title={`Combat: ${charName}`} zIndex={zIndex} width={640} height={520}>
{/* Toggle + Clear */}
<div style={{ display: 'flex', gap: 4, padding: '4px 8px', borderBottom: '1px solid #333', alignItems: 'center' }}>
<button className={`ml-stats-range-btn ${mode === 'session' ? 'active' : ''}`} onClick={() => setMode('session')}>Session</button>
<button className={`ml-stats-range-btn ${mode === 'lifetime' ? 'active' : ''}`} onClick={() => setMode('lifetime')}>Lifetime</button>
<div style={{ flex: 1 }} />
{mode === 'session' && (
<button style={{ fontSize: '0.6rem', padding: '2px 8px', background: 'rgba(204,68,68,0.15)', color: '#c66', border: '1px solid rgba(204,68,68,0.3)', borderRadius: 3, cursor: 'pointer' }}
onClick={() => { if (confirm('Clear current session stats?')) { /* Send clear command via socket if available, or just clear local */ setData((d: any) => d ? { ...d, session: { total_damage_given: 0, total_damage_received: 0, total_kills: 0, total_aetheria_surges: 0, total_cloak_surges: 0, monsters: {} } } : d); } }}>
Clear Session
</button>
)}
</div>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Monster list (left) */}
<div style={{ width: 240, borderRight: '1px solid #333', overflowY: 'auto', fontSize: '0.72rem' }}>
<div style={{ display: 'flex', padding: '3px 6px', borderBottom: '1px solid #333', color: '#777', fontSize: '0.65rem', fontWeight: 600 }}>
<span style={{ width: 14 }}></span><span style={{ flex: 1 }}>Monster</span>
<span style={{ width: 40, textAlign: 'right' }}>Kills</span><span style={{ width: 55, textAlign: 'right' }}>Dmg</span>
</div>
{/* All row */}
<div style={{ display: 'flex', padding: '3px 6px', cursor: 'pointer', background: selected === null ? '#2a3a4a' : '', borderBottom: '1px solid #222', color: '#ddd' }}
onClick={() => setSelected(null)}>
<span style={{ width: 14, color: '#888' }}>{selected === null ? '*' : ''}</span>
<span style={{ flex: 1 }}>All</span>
<span style={{ width: 40, textAlign: 'right' }}>{fmtN(state?.total_kills ?? 0)}</span>
<span style={{ width: 55, textAlign: 'right' }}>{fmtN(state?.total_damage_given ?? 0)}</span>
</div>
{names.map(n => {
const m = monsters[n];
return (
<div key={n} style={{ display: 'flex', padding: '2px 6px', cursor: 'pointer', background: selected === n ? '#2a3a4a' : '',
borderBottom: '1px solid #1a1a1a', color: '#ccc' }} onClick={() => setSelected(n)}>
<span style={{ width: 14, color: '#888' }}>{selected === n ? '*' : ''}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{n}</span>
<span style={{ width: 40, textAlign: 'right' }}>{fmtN(m.kill_count)}</span>
<span style={{ width: 55, textAlign: 'right' }}>{fmtN(m.damage_given)}</span>
</div>
);
})}
</div>
{/* Breakdown grid (right) */}
<div style={{ flex: 1, overflowY: 'auto', padding: 6, fontSize: '0.72rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ color: '#777', fontSize: '0.65rem' }}>
<th style={{ textAlign: 'left', padding: '1px 4px' }}></th>
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Given M/M</th>
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Given Mag</th>
<th style={{ width: 4 }}></th>
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Recv M/M</th>
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Recv Mag</th>
<th style={{ width: 4 }}></th>
<th style={{ textAlign: 'left', padding: '1px 3px' }}>Stats</th>
<th style={{ textAlign: 'right', padding: '1px 3px' }}></th>
</tr>
</thead>
<tbody>
{ELEMENTS.map((el, i) => {
const stats = [
['Evades', defMM.attacks > 0 ? `${fmtN(defMM.attacks)} (${evadeRate}%)` : ''],
['Resists', defMag.attacks > 0 ? `${fmtN(defMag.attacks)} (${resistRate}%)` : ''],
['A.Surges', agg.aeth > 0 ? `${fmtN(agg.aeth)}` : ''],
['C.Surges', agg.cloak > 0 ? `${fmtN(agg.cloak)}` : ''],
['', ''], ['', ''],
['Av/Mx', avgN > 0 ? `${fmtN(avgN)} / ${fmtN(off.maxNormal)}` : ''],
['Crits', off.crits > 0 ? `${fmtN(off.crits)} (${critPct}%)` : ''],
][i] ?? ['', ''];
return (
<tr key={el}>
<td style={{ padding: '1px 4px', color: '#888' }}>{el}</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.offense, 'MeleeMissile', el))}</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.offense, 'Magic', el))}</td>
<td></td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.defense, 'MeleeMissile', el))}</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.defense, 'Magic', el))}</td>
<td></td>
<td style={{ padding: '1px 3px', color: '#777', fontWeight: 600, fontSize: '0.65rem' }}>{stats[0]}</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{stats[1]}</td>
</tr>
);
})}
<tr>
<td colSpan={9} style={{ height: 4 }}></td>
</tr>
<tr>
<td style={{ padding: '1px 4px', color: '#888', fontWeight: 600 }}>Total</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.offense, 'MeleeMissile', e), 0))}</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.offense, 'Magic', e), 0))}</td>
<td></td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.defense, 'MeleeMissile', e), 0))}</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.defense, 'Magic', e), 0))}</td>
<td></td>
<td style={{ padding: '1px 3px', color: '#777', fontWeight: 600, fontSize: '0.65rem' }}>Total</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(off.normalDmg + off.critDmg)}</td>
</tr>
</tbody>
</table>
</div>
</div>
</DraggableWindow>
);
};

View file

@ -1,80 +0,0 @@
import React, { useRef, useCallback, useEffect, useState } from 'react';
import { useWindowManager } from '../../contexts/WindowManagerContext';
interface Props {
id: string;
title: string;
zIndex: number;
width?: number;
height?: number;
children: React.ReactNode;
}
export const DraggableWindow: React.FC<Props> = ({ id, title, zIndex, width = 700, height = 340, children }) => {
const { closeWindow, bringToFront } = useWindowManager();
const winRef = useRef<HTMLDivElement>(null);
const dragRef = useRef({ dragging: false, sx: 0, sy: 0, ox: 0, oy: 0 });
const resizeRef = useRef({ resizing: false, sx: 0, sy: 0, sw: 0, sh: 0 });
const posRef = useRef({ x: 420, y: 10 + Math.random() * 40 });
const [size, setSize] = useState({ w: width, h: height });
const onHeaderDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
bringToFront(id);
const rect = winRef.current?.getBoundingClientRect();
if (!rect) return;
dragRef.current = { dragging: true, sx: e.clientX, sy: e.clientY, ox: rect.left, oy: rect.top };
}, [id, bringToFront]);
const onResizeDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
resizeRef.current = { resizing: true, sx: e.clientX, sy: e.clientY, sw: size.w, sh: size.h };
}, [size.w, size.h]);
useEffect(() => {
const onMove = (e: MouseEvent) => {
// Drag
const d = dragRef.current;
if (d.dragging && winRef.current) {
posRef.current.x = d.ox + (e.clientX - d.sx);
posRef.current.y = d.oy + (e.clientY - d.sy);
winRef.current.style.left = `${posRef.current.x}px`;
winRef.current.style.top = `${posRef.current.y}px`;
}
// Resize
const r = resizeRef.current;
if (r.resizing) {
const newW = Math.max(300, r.sw + (e.clientX - r.sx));
const newH = Math.max(200, r.sh + (e.clientY - r.sy));
setSize({ w: newW, h: newH });
}
};
const onUp = () => {
dragRef.current.dragging = false;
resizeRef.current.resizing = false;
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
}, []);
return (
<div
ref={winRef}
className="ml-window"
style={{ zIndex, width: size.w, height: size.h, left: posRef.current.x, top: posRef.current.y }}
onMouseDown={() => bringToFront(id)}
>
<div className="ml-window-header" onMouseDown={onHeaderDown}>
<span className="ml-window-title">{title}</span>
<button className="ml-window-close" onClick={() => closeWindow(id)}>&times;</button>
</div>
<div className="ml-window-content">
{children}
</div>
{/* Resize handle */}
<div className="ml-window-resize" onMouseDown={onResizeDown} />
</div>
);
};

View file

@ -1,421 +0,0 @@
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Props { id: string; charName: string; zIndex: number; inventoryVersion?: number; equipmentCantrips?: any; }
// ── Item normalization (handles both inventory-service snake_case and plugin PascalCase) ──
function normalizeItem(raw: any): any {
if (!raw) return raw;
const v = (val: any) => (val !== undefined && val !== null && val !== -1 && val !== -1.0) ? val : undefined;
const iv = raw.IntValues || {};
return {
item_id: raw.item_id ?? raw.Id ?? 0,
name: raw.name ?? raw.Name ?? (raw.StringValues?.['1']) ?? 'Unknown',
icon: raw.icon ?? raw.Icon ?? 0,
object_class: raw.object_class ?? raw.ObjectClass ?? 0,
current_wielded_location: raw.current_wielded_location ?? v(raw.CurrentWieldedLocation) ?? v(Number(iv['10'])) ?? 0,
container_id: raw.container_id ?? raw.ContainerId ?? 0,
items_capacity: raw.items_capacity ?? v(raw.ItemsCapacity) ?? v(Number(iv['6'])) ?? raw.enhanced_properties?.ItemSlots_Decal ?? undefined,
value: raw.value ?? v(raw.Value) ?? v(Number(iv['19'])) ?? 0,
burden: raw.burden ?? v(raw.Burden) ?? v(Number(iv['5'])) ?? 0,
armor_level: raw.armor_level ?? v(raw.ArmorLevel),
max_damage: raw.max_damage ?? v(raw.MaxDamage),
material: raw.material ?? raw.material_name ?? raw.Material ?? undefined,
item_set: raw.item_set ?? raw.ItemSet ?? undefined,
imbue: raw.imbue ?? raw.Imbue ?? undefined,
tinks: raw.tinks ?? v(raw.Tinks),
workmanship: raw.workmanship ?? v(raw.Workmanship),
equip_skill: raw.equip_skill ?? raw.equip_skill_name ?? raw.EquipSkill ?? undefined,
wield_level: raw.wield_level ?? v(raw.WieldLevel),
skill_level: raw.skill_level ?? v(raw.SkillLevel),
lore_requirement: raw.lore_requirement ?? v(raw.LoreRequirement),
attack_bonus: raw.attack_bonus ?? v(raw.AttackBonus),
melee_defense_bonus: raw.melee_defense_bonus ?? v(raw.MeleeDefenseBonus),
magic_defense_bonus: raw.magic_defense_bonus ?? v(raw.MagicDBonus),
damage_bonus: raw.damage_bonus ?? v(raw.DamageBonus),
damage_rating: raw.damage_rating ?? v(raw.DamRating),
crit_rating: raw.crit_rating ?? v(raw.CritRating),
heal_boost_rating: raw.heal_boost_rating ?? v(raw.HealBoostRating),
current_mana: raw.current_mana ?? v(Number(iv['218103815'])) ?? undefined,
max_mana: raw.max_mana ?? v(Number(iv['218103814'])) ?? undefined,
spellcraft: raw.spellcraft ?? undefined,
damage_range: raw.damage_range ?? undefined,
damage_type: raw.damage_type ?? undefined,
speed_text: raw.speed_text ?? undefined,
mana_display: raw.mana_display ?? undefined,
spells: raw.spells ?? undefined,
icon_overlay_id: raw.icon_overlay_id ?? v(Number(iv['218103849'])) ?? undefined,
icon_underlay_id: raw.icon_underlay_id ?? v(Number(iv['218103850'])) ?? undefined,
_raw: raw,
};
}
// ── Icon helpers ──
function iconHex(raw: number): string {
if (!raw || raw <= 0) return '06000133';
return (raw + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
// ── Equipment slots ──
const EQUIP_SLOTS: Record<number, { name: string; row: number; col: number }> = {
32768:{name:'Neck',row:1,col:1},1:{name:'Head',row:1,col:3},268435456:{name:'Sigil',row:1,col:5},536870912:{name:'Sigil',row:1,col:6},1073741824:{name:'Sigil',row:1,col:7},
67108864:{name:'Trinket',row:2,col:1},2048:{name:'U.Arm',row:2,col:2},512:{name:'Chest',row:2,col:3},134217728:{name:'Cloak',row:2,col:7},
65536:{name:'Brace L',row:3,col:1},4096:{name:'L.Arm',row:3,col:2},1024:{name:'Abdomen',row:3,col:3},8192:{name:'U.Leg',row:3,col:4},131072:{name:'Brace R',row:3,col:5},2:{name:'Shirt',row:3,col:7},
262144:{name:'Ring L',row:4,col:1},32:{name:'Hands',row:4,col:2},16384:{name:'L.Leg',row:4,col:4},524288:{name:'Ring R',row:4,col:5},4:{name:'Pants',row:4,col:7},
256:{name:'Feet',row:5,col:4},
2097152:{name:'Shield',row:6,col:1},1048576:{name:'Melee',row:6,col:3},4194304:{name:'Missile',row:6,col:3},16777216:{name:'Held',row:6,col:3},33554432:{name:'2H',row:6,col:3},8388608:{name:'Ammo',row:6,col:7},
};
// Slot colors matching v1
const SLOT_COLORS: Record<string, string> = {};
const purpleSlots = [32768,67108864,65536,131072,262144,524288];
const blueSlots = [1,512,2048,1024,4096,8192,16384,32,256];
const tealSlots = [2,4,134217728,268435456,536870912,1073741824];
const darkblueSlots = [2097152,1048576,4194304,16777216,33554432,8388608];
// Map slot keys to colors
(() => {
const seen = new Set<string>();
Object.entries(EQUIP_SLOTS).forEach(([maskStr, def]) => {
const k = `${def.row}-${def.col}`;
const m = parseInt(maskStr);
if (!seen.has(k)) {
seen.add(k);
if (purpleSlots.includes(m)) SLOT_COLORS[k] = '#3a2555';
else if (blueSlots.includes(m)) SLOT_COLORS[k] = '#1e2e55';
else if (tealSlots.includes(m)) SLOT_COLORS[k] = '#1e3e3e';
else if (darkblueSlots.includes(m)) SLOT_COLORS[k] = '#142040';
else SLOT_COLORS[k] = '#2a2a2a';
}
});
})();
const gold = '#af7a30';
function ItemIcon({ item, size = 36 }: { item: any; size?: number }) {
const s: React.CSSProperties = { position: 'absolute', top: 0, left: 0, width: size, height: size, border: 'none', background: 'transparent', imageRendering: 'pixelated' };
const underlay = item.icon_underlay_id && item.icon_underlay_id > 100 ? `/icons/${iconHex(item.icon_underlay_id)}.png` : null;
const overlay = item.icon_overlay_id && item.icon_overlay_id > 100 ? `/icons/${iconHex(item.icon_overlay_id)}.png` : null;
return (
<div style={{ width: size, height: size, position: 'relative' }}>
{underlay && <img src={underlay} alt="" style={{ ...s, zIndex: 1 }} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
<img src={`/icons/${iconHex(item.icon)}.png`} alt={item.name} style={{ ...s, zIndex: 2 }} onError={e => { (e.target as HTMLImageElement).src = '/icons/06000133.png'; }} />
{overlay && <img src={overlay} alt="" style={{ ...s, zIndex: 3 }} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
</div>
);
}
function ItemTooltip({ item, x, y }: { item: any; x: number; y: number }) {
const isV = (val: any) => val !== undefined && val !== null && val !== -1 && val !== -1.0;
const fmt = (n: number) => n.toLocaleString();
const pct = (v: number) => `${((v - 1) * 100).toFixed(1)}%`;
return (
<div style={{ position: 'fixed', left: x + 14, top: y + 14, background: 'rgba(0,0,0,0.96)', border: '1px solid #555', borderRadius: 4, padding: '8px 12px', zIndex: 99999, minWidth: 200, maxWidth: 340, fontSize: 13, color: '#ddd', pointerEvents: 'none', lineHeight: 1.6, fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif' }}>
<div style={{ color: '#ffcc00', fontWeight: 'bold', fontSize: 14, marginBottom: 4 }}>{item.name}</div>
<div style={{ color: '#aaa' }}>Value: {fmt(item.value)} &middot; Burden: {item.burden}</div>
{item.workmanship && <div style={{ color: '#aaa' }}>Workmanship: {item.workmanship}</div>}
{item.material && <div style={{ color: '#88ff88' }}>Material: {item.material}</div>}
{isV(item.armor_level) && <div style={{ color: '#88ff88' }}>Armor Level: {item.armor_level}</div>}
{isV(item.max_damage) && <div style={{ color: '#88ff88' }}>Max Damage: {item.max_damage}</div>}
{item.damage_range && <div style={{ color: '#88ff88' }}>Damage: {item.damage_range}{item.damage_type ? `, ${item.damage_type}` : ''}</div>}
{isV(item.attack_bonus) && item.attack_bonus !== 1 && <div style={{ color: '#88ff88' }}>Attack: +{pct(item.attack_bonus)}</div>}
{isV(item.melee_defense_bonus) && item.melee_defense_bonus !== 1 && <div style={{ color: '#88ff88' }}>Melee Def: +{pct(item.melee_defense_bonus)}</div>}
{isV(item.magic_defense_bonus) && item.magic_defense_bonus !== 1 && <div style={{ color: '#88ff88' }}>Magic Def: +{pct(item.magic_defense_bonus)}</div>}
{item.equip_skill && <div style={{ color: '#ddd' }}>Skill: {item.equip_skill}</div>}
{isV(item.wield_level) && <div style={{ color: '#ffaa00' }}>Wield Level: {item.wield_level}</div>}
{isV(item.lore_requirement) && <div style={{ color: '#ffaa00' }}>Lore: {item.lore_requirement}</div>}
{item.imbue && <div style={{ color: '#88ff88' }}>Imbue: {item.imbue}</div>}
{item.item_set && <div style={{ color: '#88ff88' }}>Set: {item.item_set}</div>}
{isV(item.tinks) && <div style={{ color: '#88ff88' }}>Tinks: {item.tinks}</div>}
{isV(item.damage_rating) && <div>Damage Rating: {item.damage_rating}</div>}
{isV(item.crit_rating) && <div>Crit Rating: {item.crit_rating}</div>}
{isV(item.heal_boost_rating) && <div>Heal Boost: {item.heal_boost_rating}</div>}
{item.spellcraft && <div style={{ color: '#dda0dd' }}>Spellcraft: {item.spellcraft}</div>}
{isV(item.current_mana) && isV(item.max_mana) && <div style={{ color: '#98d7ff' }}>Mana: {item.current_mana} / {item.max_mana}</div>}
{item.spells?.spells?.length > 0 && <div style={{ color: '#4a90e2', marginTop: 4, fontSize: 12 }}>Spells: {item.spells.spells.map((s: any) => s.name).join(', ')}</div>}
</div>
);
}
function PackIcon({ iconSrc, isActive, fillPct, label, onClick }: {
iconSrc: string; isActive: boolean; fillPct: number; label: string; onClick: () => void;
}) {
const fillColor = fillPct > 90 ? '#b7432c' : fillPct > 70 ? '#d8a431' : '#00ff00';
return (
<div onClick={onClick} title={label}
style={{ display: 'flex', alignItems: 'flex-start', gap: 2, cursor: 'pointer', flexShrink: 0, marginTop: 3, position: 'relative' }}>
{isActive && <span style={{ position: 'absolute', left: -11, top: 8, color: gold, fontSize: 10 }}></span>}
<div style={{ width: 30, height: 30, border: isActive ? '1px solid #00ff00' : '1px solid #333', boxShadow: isActive ? '0 0 4px #00ff00' : 'none', background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<img src={iconSrc} alt="" style={{ width: 26, height: 26, objectFit: 'contain', imageRendering: 'pixelated' }}
onError={e => { (e.target as HTMLImageElement).src = '/icons/06001080.png'; }} />
</div>
<div style={{ width: 7, height: 30, background: '#222', border: '1px solid #666', position: 'relative', overflow: 'hidden', borderRadius: 2 }}
title={`${Math.round(fillPct)}% full`}>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: `${fillPct}%`, background: fillColor, minHeight: fillPct > 0 ? 2 : 0 }} />
</div>
</div>
);
}
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex, inventoryVersion, equipmentCantrips }) => {
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [activePack, setActivePack] = useState<number | null>(null);
const [tooltip, setTooltip] = useState<{ item: any; x: number; y: number } | null>(null);
const [charStats, setCharStats] = useState<any>(null);
const debounceRef = useRef<number>(0);
const initialLoadDone = useRef(false);
// Initial fetch
useEffect(() => {
setLoading(true);
Promise.all([
apiFetch<any>(`/inventory/${encodeURIComponent(charName)}?limit=1000`).catch(() => ({ items: [] })),
apiFetch<any>(`/character-stats/${encodeURIComponent(charName)}`).catch(() => null),
]).then(([inv, stats]) => {
setItems((inv.items ?? []).map(normalizeItem));
setCharStats(stats);
initialLoadDone.current = true;
}).finally(() => setLoading(false));
}, [charName]);
// Debounced re-fetch on inventory_delta (no loading flash)
useEffect(() => {
if (!initialLoadDone.current || !inventoryVersion) return;
clearTimeout(debounceRef.current);
debounceRef.current = window.setTimeout(() => {
apiFetch<any>(`/inventory/${encodeURIComponent(charName)}?limit=1000&_t=${Date.now()}`)
.then(inv => setItems((inv.items ?? []).map(normalizeItem)))
.catch(() => {});
}, 2000); // 2s debounce — batch rapid deltas
return () => clearTimeout(debounceRef.current);
}, [charName, inventoryVersion]);
const handleHover = useCallback((item: any | null, e?: React.MouseEvent) => {
if (item && e) setTooltip({ item, x: e.clientX, y: e.clientY });
else setTooltip(null);
}, []);
const slotPositions = useMemo(() => {
const seen = new Set<string>();
const slots: Array<{ key: string; row: number; col: number; mask: number; name: string }> = [];
Object.entries(EQUIP_SLOTS).forEach(([maskStr, def]) => {
const k = `${def.row}-${def.col}`;
if (!seen.has(k)) { seen.add(k); slots.push({ key: k, ...def, mask: parseInt(maskStr) }); }
});
return slots;
}, []);
const { equippedMap, containers, packItems } = useMemo(() => {
const equippedMap = new Map<string, any>();
const containers: any[] = [];
const containerIds = new Set<number>();
const packItems = new Map<number, any[]>();
items.forEach(item => { if (item.object_class === 10) { containers.push(item); containerIds.add(item.item_id); } });
containers.sort((a: any, b: any) => (a.item_id >>> 0) - (b.item_id >>> 0));
// Find body container ID (worn items share a container_id that isn't a pack)
let bodyContainerId: number | null = null;
items.forEach(item => {
if (item.current_wielded_location > 0 && item.container_id && !containerIds.has(item.container_id)) {
bodyContainerId = item.container_id;
}
});
items.forEach(item => {
if (containerIds.has(item.item_id)) return;
const wielded = item.current_wielded_location;
if (wielded > 0) {
const isArmor = item.object_class === 2;
if (isArmor) {
// Armor: ALL matching slots
Object.entries(EQUIP_SLOTS).forEach(([maskStr, def]) => {
if ((wielded & parseInt(maskStr)) === parseInt(maskStr)) {
const key = `${def.row}-${def.col}`;
if (!equippedMap.has(key)) equippedMap.set(key, item);
}
});
} else {
// Non-armor: exact match first, then first bit overlap
let placed = false;
if (EQUIP_SLOTS[wielded]) {
const def = EQUIP_SLOTS[wielded];
const key = `${def.row}-${def.col}`;
if (!equippedMap.has(key)) { equippedMap.set(key, item); placed = true; }
}
if (!placed) {
for (const [maskStr, def] of Object.entries(EQUIP_SLOTS)) {
if ((wielded & parseInt(maskStr)) === parseInt(maskStr)) {
const key = `${def.row}-${def.col}`;
if (!equippedMap.has(key)) { equippedMap.set(key, item); placed = true; break; }
}
}
}
}
} else {
let cid = item.container_id || 0;
if (bodyContainerId && cid === bodyContainerId) cid = 0;
if (!packItems.has(cid)) packItems.set(cid, []);
packItems.get(cid)!.push(item);
}
});
return { equippedMap, containers, packItems };
}, [items]);
// Main backpack: key 0, OR the largest non-container group if bodyContainerId wasn't detected
let mainItems = packItems.get(0) ?? [];
let mainPackKey: number = 0;
if (mainItems.length === 0) {
// bodyContainerId wasn't detected — find the biggest group that isn't a container
let biggest = 0;
for (const [cid, items] of packItems.entries()) {
if (!containers.some((c: any) => c.item_id === cid) && items.length > biggest) {
biggest = items.length;
mainPackKey = cid;
}
}
mainItems = packItems.get(mainPackKey) ?? [];
}
const activeItems = activePack !== null ? (packItems.get(activePack) ?? []) : mainItems;
// Burden
const burdenUnits = charStats?.burden_units ?? charStats?.stats_data?.burden_units ?? 0;
const encumbranceCap = charStats?.encumbrance_capacity ?? charStats?.stats_data?.encumbrance_capacity ?? 0;
const burdenPct = encumbranceCap > 0 ? Math.min(200, (burdenUnits / encumbranceCap) * 100) : 0;
const burdenColor = burdenPct > 150 ? '#b7432c' : burdenPct > 100 ? '#d8a431' : '#2e8b57';
if (loading) {
return <DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={572} height={720}>
<div style={{ padding: 20, color: '#666', fontStyle: 'italic' }}>Loading inventory...</div>
</DraggableWindow>;
}
return (
<DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={572} height={720}>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden', background: 'rgba(14,14,14,0.96)', fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', fontSize: 13 }}>
{/* LEFT: Equipment + Items */}
<div style={{ width: 316, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ position: 'relative', height: 270, minHeight: 270, background: '#0a0a0a', borderBottom: `1px solid ${gold}` }}>
{slotPositions.map(slot => {
const item = equippedMap.get(slot.key);
const slotBg = SLOT_COLORS[slot.key] ?? '#2a2a2a';
return (
<div key={slot.key}
style={{
position: 'absolute', left: (slot.col - 1) * 44 + 4, top: (slot.row - 1) * 44 + 4,
width: 36, height: 36, background: item ? '#5a5a62' : slotBg,
border: item ? '2px solid #00ffff' : '2px outset #6a6a72',
boxShadow: item ? '0 0 5px #00ffff, inset 0 0 5px rgba(0,255,255,0.2)' : 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: item ? 'pointer' : 'default',
}}
onMouseEnter={e => item && handleHover(item, e)}
onMouseMove={e => item && handleHover(item, e)}
onMouseLeave={() => handleHover(null)}>
{item ? <ItemIcon item={item} size={32} /> :
<img src="/icons/06000133.png" alt="" style={{ width: 28, height: 28, opacity: 0.15, filter: 'grayscale(100%)', imageRendering: 'pixelated' }} />}
</div>
);
})}
</div>
<div style={{ padding: '3px 6px', fontSize: 11, color: '#ccc', background: '#111', borderBottom: `1px solid ${gold}` }}>
Contents of {activePack !== null ? (containers.find((c: any) => c.item_id === activePack)?.name ?? 'Pack') : 'Backpack'}
</div>
<div style={{ flex: 1, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(6, 36px)', gridAutoRows: 36, gap: 2, padding: 4, alignContent: 'start' }}>
{activeItems.map((item: any, i: number) => (
<div key={item.item_id ?? i}
style={{ width: 36, height: 36, background: 'linear-gradient(135deg, #3d007a 0%, #1a0033 100%)', border: '1px solid #4a148c', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
onMouseEnter={e => handleHover(item, e)}
onMouseMove={e => handleHover(item, e)}
onMouseLeave={() => handleHover(null)}>
<ItemIcon item={item} size={32} />
</div>
))}
{Array.from({ length: Math.max(0, 24 - activeItems.length) }).map((_, i) => (
<div key={`e${i}`} style={{ width: 36, height: 36, background: '#0a0a0a', border: '1px solid #1a1a1a' }} />
))}
</div>
</div>
{/* SIDEBAR: Burden + Packs */}
<div style={{ width: 42, display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '4px 2px', borderLeft: `1px solid ${gold}`, borderRight: `1px solid ${gold}` }}>
<div style={{ textAlign: 'center', fontSize: 8, color: '#ccc', marginBottom: 2 }}>
{encumbranceCap > 0 ? `${Math.floor(burdenPct)}%` : 'Burden'}
</div>
<div style={{ width: 14, height: 40, background: '#111', border: '1px solid #555', position: 'relative', overflow: 'hidden', marginBottom: 6, flexShrink: 0 }}
title={encumbranceCap > 0 ? `${burdenUnits.toLocaleString()} / ${encumbranceCap.toLocaleString()}` : `Burden: ${items.reduce((s: number, i: any) => s + (i.burden ?? 0), 0).toLocaleString()}`}>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: `${burdenPct / 2}%`, background: burdenColor, transition: 'height 0.3s' }} />
</div>
<PackIcon iconSrc="/icons/0600127E.png" isActive={activePack === null}
fillPct={mainItems.length > 0 ? Math.min(100, (mainItems.length / 102) * 100) : 0}
label={`Backpack (${mainItems.length}/102)`} onClick={() => setActivePack(null)} />
{containers.map((c: any) => {
const cid = c.item_id;
// Count items directly from normalized items array instead of relying on packItems map
const childCount = items.filter((i: any) => i.container_id === cid && i.item_id !== cid).length;
const cap = c.items_capacity || 24;
const pct = cap > 0 ? Math.min(100, (childCount / cap) * 100) : 0;
return <PackIcon key={cid} iconSrc={`/icons/${iconHex(c.icon)}.png`} isActive={activePack === cid}
fillPct={pct}
label={`${c.name} (${childCount}/${cap})`} onClick={() => setActivePack(cid)} />;
})}
</div>
{/* RIGHT: Mana panel */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 160 }}>
<div style={{ padding: '4px 8px', fontSize: '0.72rem', fontWeight: 600, color: '#aaa', background: '#111', borderBottom: `1px solid ${gold}` }}>Mana</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '2px 0' }}>
{(() => {
// Merge real cantrip state data if available
const cantripItems = equipmentCantrips?.items ?? [];
const cantripMap: Map<number, any> = new Map(cantripItems.map((c: any) => [c.item_id, c]));
const snapshotTime = equipmentCantrips?.timestamp ? new Date(equipmentCantrips.timestamp).getTime() : 0;
const elapsed = snapshotTime > 0 ? Math.max(0, (Date.now() - snapshotTime) / 1000) : 0;
return Array.from(equippedMap.values())
.map((item: any) => {
const cantrip = cantripMap.get(item.item_id);
const curMana = cantrip?.current_mana ?? item.current_mana ?? 0;
const maxMana = cantrip?.max_mana ?? item.max_mana ?? 0;
const rawRemaining = cantrip?.mana_time_remaining_seconds ?? null;
const liveRemaining = rawRemaining != null ? Math.max(0, rawRemaining - elapsed) : null;
const state = cantrip?.state ?? (curMana > 0 ? 'active' : 'not_active');
return { ...item, current_mana: curMana, max_mana: maxMana, liveRemaining, manaState: state };
})
.filter((i: any) => i.current_mana > 0 || i.max_mana > 0)
.sort((a: any, b: any) => (a.liveRemaining ?? 999999) - (b.liveRemaining ?? 999999))
.map((item: any, i: number) => {
const stateColor = item.manaState === 'active' ? '#4c4' : item.manaState === 'not_active' ? '#c44' : '#da8';
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 4px', borderBottom: '1px solid #1a1a1a', cursor: 'pointer' }}
onMouseEnter={e => handleHover(item, e)} onMouseMove={e => handleHover(item, e)} onMouseLeave={() => handleHover(null)}>
<div style={{ width: 20, height: 20, flexShrink: 0 }}><ItemIcon item={item} size={20} /></div>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: stateColor, flexShrink: 0 }} />
<div style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '0.68rem', color: '#ccc' }}>{item.name}</div>
<div style={{ fontSize: '0.65rem', color: '#88bbff', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{item.current_mana}/{item.max_mana}</div>
<div style={{ fontSize: '0.63rem', color: '#9c9', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', minWidth: 42, textAlign: 'right' }}>
{item.liveRemaining != null ? formatSeconds(item.liveRemaining) : ''}
</div>
</div>
);
});
})()}
{Array.from(equippedMap.values()).filter((i: any) => (i.current_mana > 0 || i.max_mana > 0)).length === 0 && (
<div style={{ padding: 12, color: '#555', textAlign: 'center', fontSize: '0.7rem' }}>No mana items equipped</div>
)}
</div>
</div>
</div>
{tooltip && <ItemTooltip item={tooltip.item} x={tooltip.x} y={tooltip.y} />}
</DraggableWindow>
);
};
function formatSeconds(totalSeconds: number): string {
if (totalSeconds <= 0) return '0h00m';
const s = Math.floor(totalSeconds);
const hours = Math.floor(s / 3600);
const minutes = Math.floor((s % 3600) / 60);
return `${hours}h${String(minutes).padStart(2, '0')}m`;
}

View file

@ -1,191 +0,0 @@
import React, { useEffect, useState, useCallback } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Comment { id: number; text: string; author: string; created: string; }
interface Issue {
id: number; title: string; description: string; category: string;
created: string; resolved: boolean; author: string; comments?: Comment[];
}
interface Props { id: string; zIndex: number; }
const CATS: Record<string, { label: string; color: string }> = {
plugin: { label: 'Plugin', color: '#8844cc' },
overlord: { label: 'Overlord', color: '#4488cc' },
nav: { label: 'Nav', color: '#44aa44' },
macro: { label: 'Macro', color: '#cc8844' },
other: { label: 'Other', color: '#888888' },
};
const inputStyle: React.CSSProperties = { padding: '3px 6px', fontSize: '0.8rem', border: '1px solid #555', background: '#2a2a2a', color: '#ddd', borderRadius: 0 };
const selectStyle: React.CSSProperties = { ...inputStyle, fontSize: '0.75rem' };
const btnBlue: React.CSSProperties = { padding: '4px 12px', background: '#4a80c0', color: '#fff', border: '1px solid #336699', cursor: 'pointer', fontSize: '0.75rem' };
const btnGray: React.CSSProperties = { padding: '3px 8px', background: '#444', color: '#ccc', border: '1px solid #555', cursor: 'pointer', fontSize: '0.7rem' };
export const IssuesWindow: React.FC<Props> = ({ id, zIndex }) => {
const [issues, setIssues] = useState<Issue[]>([]);
const [title, setTitle] = useState('');
const [desc, setDesc] = useState('');
const [category, setCategory] = useState('plugin');
const [editingId, setEditingId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editDesc, setEditDesc] = useState('');
const [editCat, setEditCat] = useState('');
const [commentText, setCommentText] = useState<Record<number, string>>({});
const refresh = useCallback(async () => {
try {
const data = await apiFetch<{ issues: Issue[] }>('/issues');
setIssues((data.issues ?? []).sort((a, b) => (a.resolved ? 1 : 0) - (b.resolved ? 1 : 0)));
} catch { /* ignore */ }
}, []);
useEffect(() => { refresh(); }, [refresh]);
const apiCall = async (url: string, opts: RequestInit) => {
await fetch(`/api${url}`, { ...opts, credentials: 'include', headers: { 'Content-Type': 'application/json', ...opts.headers } });
refresh();
};
const addIssue = async () => {
if (!title.trim()) return;
await apiCall('/issues', { method: 'POST', body: JSON.stringify({ title: title.trim(), description: desc.trim(), category }) });
setTitle(''); setDesc('');
};
const startEdit = (issue: Issue) => {
if (editingId === issue.id) { setEditingId(null); return; }
setEditingId(issue.id);
setEditTitle(issue.title);
setEditDesc(issue.description || '');
setEditCat(issue.category || 'other');
};
const saveEdit = async (issueId: number) => {
if (!editTitle.trim()) return;
await apiCall(`/issues/${issueId}`, { method: 'PATCH', body: JSON.stringify({ title: editTitle.trim(), description: editDesc.trim(), category: editCat }) });
setEditingId(null);
};
const addComment = async (issueId: number) => {
const text = (commentText[issueId] || '').trim();
if (!text) return;
await apiCall(`/issues/${issueId}/comments`, { method: 'POST', body: JSON.stringify({ text }) });
setCommentText(prev => ({ ...prev, [issueId]: '' }));
};
return (
<DraggableWindow id={id} title="Issues Board" zIndex={zIndex} width={540} height={520}>
{/* Issue list */}
<div style={{ flex: 1, overflowY: 'auto', padding: 6, fontSize: '0.8rem' }}>
{issues.length === 0 && (
<div style={{ padding: 10, color: '#888', textAlign: 'center' }}>No open issues</div>
)}
{issues.map(issue => {
const cat = CATS[issue.category] || CATS.other;
const date = issue.created ? new Date(issue.created).toLocaleDateString('sv-SE') : '';
const comments = issue.comments || [];
return (
<div key={issue.id} style={{ padding: '6px 8px', marginBottom: 4, background: '#1f1f1f', borderRadius: 3, border: '1px solid #333', opacity: issue.resolved ? 0.55 : 1 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.65rem', padding: '1px 6px', borderRadius: 3, background: cat.color, color: '#fff', fontWeight: 600 }}>{cat.label}</span>
<strong style={{ fontSize: '0.8rem', flex: 1 }}>{issue.title}</strong>
<span style={{ fontSize: '0.65rem', color: '#888' }}>by {issue.author || 'User'}</span>
<span style={{ color: '#666', fontSize: '0.65rem' }}>{date}</span>
</div>
{/* Description */}
{issue.description && <div style={{ color: '#999', marginTop: 3, fontSize: '0.75rem' }}>{issue.description}</div>}
{/* Action buttons */}
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
{issue.resolved ? (
<>
<button style={{ ...btnGray, fontSize: '0.65rem' }}
onClick={() => apiCall(`/issues/${issue.id}`, { method: 'PATCH', body: JSON.stringify({ resolved: false }) })}>
Reopen
</button>
<button style={{ ...btnGray, fontSize: '0.65rem', color: '#c66' }}
onClick={() => { if (confirm(`Delete issue "${issue.title}"?`)) apiCall(`/issues/${issue.id}`, { method: 'DELETE' }); }}>
🗑 Delete
</button>
</>
) : (
<button style={{ ...btnGray, fontSize: '0.65rem', background: 'rgba(68,204,68,0.15)', color: '#4c4', border: '1px solid rgba(68,204,68,0.3)' }}
onClick={() => apiCall(`/issues/${issue.id}`, { method: 'PATCH', body: JSON.stringify({ resolved: true }) })}>
Resolve
</button>
)}
<button style={{ ...btnGray, fontSize: '0.65rem' }} onClick={() => startEdit(issue)}> Edit</button>
</div>
{/* Inline edit form */}
{editingId === issue.id && (
<div style={{ marginTop: 4, padding: 4, background: '#222', borderRadius: 3 }}>
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<input value={editTitle} onChange={e => setEditTitle(e.target.value)} style={{ ...inputStyle, flex: 1 }} />
<select value={editCat} onChange={e => setEditCat(e.target.value)} style={selectStyle}>
<option value="plugin">Plugin</option><option value="overlord">Overlord</option>
<option value="nav">Nav</option><option value="macro">Macro</option><option value="other">Other</option>
</select>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<textarea value={editDesc} onChange={e => setEditDesc(e.target.value)} rows={2}
style={{ ...inputStyle, flex: 1, fontSize: '0.75rem', resize: 'vertical' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<button style={{ ...btnBlue, fontSize: '0.7rem', padding: '3px 8px' }} onClick={() => saveEdit(issue.id)}>Save</button>
<button style={{ ...btnGray }} onClick={() => setEditingId(null)}>Cancel</button>
</div>
</div>
</div>
)}
{/* Comments section */}
<div style={{ marginTop: 4, paddingTop: 4, borderTop: '1px solid #2a2a2a' }}>
{comments.length === 0 ? (
<div style={{ color: '#555', fontSize: '0.7rem', padding: '2px 0' }}>No comments yet</div>
) : (
comments.map(c => (
<div key={c.id} style={{ marginBottom: 3, fontSize: '0.72rem' }}>
<span style={{ color: '#8ac', fontWeight: 500 }}>{c.author || 'Anonymous'}</span>
<span style={{ color: '#555', marginLeft: 6, fontSize: '0.6rem' }}>
{c.created ? new Date(c.created).toLocaleDateString('sv-SE') : ''}
</span>
<div style={{ color: '#bbb', marginTop: 1 }}>{c.text}</div>
</div>
))
)}
{/* Add comment */}
<div style={{ display: 'flex', gap: 4, marginTop: 3 }}>
<input value={commentText[issue.id] || ''} onChange={e => setCommentText(prev => ({ ...prev, [issue.id]: e.target.value }))}
placeholder="Add a comment..." style={{ ...inputStyle, flex: 1, fontSize: '0.75rem' }}
onKeyDown={e => { if (e.key === 'Enter') addComment(issue.id); }} />
<button style={{ ...btnBlue, fontSize: '0.7rem', padding: '3px 8px' }} onClick={() => addComment(issue.id)}>Post</button>
</div>
</div>
</div>
);
})}
</div>
{/* Add issue form (bottom) */}
<div style={{ padding: 6, borderTop: '1px solid #333' }}>
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="Issue title..."
style={{ ...inputStyle, flex: 1 }} onKeyDown={e => { if (e.key === 'Enter') addIssue(); }} />
<select value={category} onChange={e => setCategory(e.target.value)} style={selectStyle}>
<option value="plugin">Plugin</option><option value="overlord">Overlord</option>
<option value="nav">Nav</option><option value="macro">Macro</option><option value="other">Other</option>
</select>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<textarea value={desc} onChange={e => setDesc(e.target.value)} placeholder="Description (optional)..."
rows={2} style={{ ...inputStyle, flex: 1, fontSize: '0.75rem', resize: 'vertical' }} />
<button style={{ ...btnBlue, alignSelf: 'flex-end' }} onClick={addIssue}>Add</button>
</div>
</div>
</DraggableWindow>
);
};

View file

@ -1,150 +0,0 @@
import React, { useState, useMemo } from 'react';
import { DraggableWindow } from './DraggableWindow';
import type { CharacterState } from '../../types';
interface WindowProps { id: string; zIndex: number; characters: Map<string, CharacterState>; }
interface ContentProps { characters: Map<string, CharacterState>; }
type SortCol = 'name' | 'kills' | 'kph' | 'rares' | 'deaths' | 'uptime' | 'state';
/**
* The actual sortable-table view. Pure presentational pass in `characters`.
* Used by both the in-app draggable window AND the new-tab fullscreen page.
* Don't add window-chrome / sidebar concerns here.
*/
export const PlayerDashboardContent: React.FC<ContentProps> = ({ characters }) => {
const [sortCol, setSortCol] = useState<SortCol>('kph');
const [sortAsc, setSortAsc] = useState(false);
// Click-to-highlight one row at a time. Click again to unhighlight.
// Helps when watching a specific character across a long list.
const [selectedName, setSelectedName] = useState<string | null>(null);
const players = useMemo(() => {
const list = Array.from(characters.values()).filter(c => c.telemetry).map(c => {
const t = c.telemetry!;
return {
name: c.name,
kills: t.kills ?? 0,
kph: parseInt(t.kills_per_hour) || 0,
totalKills: t.total_kills ?? 0,
rares: t.total_rares ?? 0,
sessionRares: t.session_rares ?? 0,
deaths: parseInt(t.deaths as string) || 0,
totalDeaths: parseInt(t.total_deaths as string) || 0,
uptime: t.onlinetime?.replace(/^00\./, '') ?? '',
state: t.vt_state ?? 'idle',
tapers: parseInt(t.prismatic_taper_count as string) || 0,
hp: c.vitals?.health_percentage ?? 0,
vitae: c.vitals?.vitae ?? 0,
};
});
list.sort((a, b) => {
let cmp = 0;
switch (sortCol) {
case 'name': cmp = a.name.localeCompare(b.name); break;
case 'kills': cmp = a.kills - b.kills; break;
case 'kph': cmp = a.kph - b.kph; break;
case 'rares': cmp = a.rares - b.rares; break;
case 'deaths': cmp = a.totalDeaths - b.totalDeaths; break;
case 'uptime': cmp = a.uptime.localeCompare(b.uptime); break;
case 'state': cmp = a.state.localeCompare(b.state); break;
}
return sortAsc ? cmp : -cmp;
});
return list;
}, [characters, sortCol, sortAsc]);
const toggleSort = (col: SortCol) => {
if (sortCol === col) setSortAsc(!sortAsc);
else { setSortCol(col); setSortAsc(false); }
};
const thStyle = (col: SortCol): React.CSSProperties => ({
padding: '4px 6px', cursor: 'pointer', userSelect: 'none',
color: sortCol === col ? '#6af' : '#888',
fontSize: '0.65rem', fontWeight: 600, whiteSpace: 'nowrap',
borderBottom: '1px solid #444',
});
const arrow = (col: SortCol) => sortCol === col ? (sortAsc ? ' ▲' : ' ▼') : '';
return (
<div style={{ flex: 1, overflow: 'auto', fontSize: '0.73rem' }}>
{/* width:auto so each column sizes to content without this, width:100%
forced the leftmost text column (Character) to absorb all extra slack
and look way wider than its longest name actually needs. */}
<table style={{ width: 'auto', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
<th style={{ ...thStyle('name'), textAlign: 'left' }} onClick={() => toggleSort('name')}>Character{arrow('name')}</th>
<th style={{ ...thStyle('state'), textAlign: 'center' }} onClick={() => toggleSort('state')}>State{arrow('state')}</th>
<th style={{ ...thStyle('kph'), textAlign: 'right' }} onClick={() => toggleSort('kph')}>KPH{arrow('kph')}</th>
<th style={{ ...thStyle('kills'), textAlign: 'right' }} onClick={() => toggleSort('kills')}>Session{arrow('kills')}</th>
<th style={{ textAlign: 'right', padding: '4px 6px', color: '#888', fontSize: '0.65rem', fontWeight: 600, borderBottom: '1px solid #444' }}>Total</th>
<th style={{ ...thStyle('rares'), textAlign: 'right' }} onClick={() => toggleSort('rares')}>Rares{arrow('rares')}</th>
<th style={{ ...thStyle('deaths'), textAlign: 'right' }} onClick={() => toggleSort('deaths')}>Deaths{arrow('deaths')}</th>
<th style={{ ...thStyle('uptime'), textAlign: 'right' }} onClick={() => toggleSort('uptime')}>Uptime{arrow('uptime')}</th>
<th style={{ textAlign: 'right', padding: '4px 6px', color: '#888', fontSize: '0.65rem', fontWeight: 600, borderBottom: '1px solid #444' }}>HP%</th>
<th style={{ textAlign: 'right', padding: '4px 6px', color: '#888', fontSize: '0.65rem', fontWeight: 600, borderBottom: '1px solid #444' }}>Vitae</th>
<th style={{ textAlign: 'right', padding: '4px 6px', color: '#888', fontSize: '0.65rem', fontWeight: 600, borderBottom: '1px solid #444' }}>Tapers</th>
</tr>
</thead>
<tbody>
{players.map(p => {
const stateLC = p.state.toLowerCase();
const isActive = stateLC === 'combat' || stateLC === 'hunt';
const isSelected = selectedName === p.name;
return (
<tr
key={p.name}
onClick={() => setSelectedName(isSelected ? null : p.name)}
style={{
borderBottom: '1px solid #1a1a1a',
cursor: 'pointer',
background: isSelected ? 'rgba(102, 170, 255, 0.18)' : undefined,
outline: isSelected ? '1px solid rgba(102, 170, 255, 0.55)' : undefined,
outlineOffset: '-1px',
}}
>
<td style={{ padding: '3px 10px 3px 6px', color: '#ccc', fontWeight: 500, whiteSpace: 'nowrap' }}>{p.name}</td>
<td style={{ textAlign: 'center', padding: '3px 6px' }}>
<span style={{ fontSize: '0.6rem', padding: '1px 6px', borderRadius: 3,
background: isActive ? 'rgba(68,204,68,0.15)' : stateLC === 'idle' || stateLC === 'default' ? 'rgba(100,100,100,0.2)' : 'rgba(204,68,68,0.15)',
color: isActive ? '#4c4' : stateLC === 'idle' || stateLC === 'default' ? '#888' : '#c44',
}}>{p.state}</span>
</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#4c4', fontVariantNumeric: 'tabular-nums' }}>{p.kph.toLocaleString()}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#ccc', fontVariantNumeric: 'tabular-nums' }}>{p.kills.toLocaleString()}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#888', fontVariantNumeric: 'tabular-nums' }}>{p.totalKills.toLocaleString()}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#fc0', fontVariantNumeric: 'tabular-nums' }}>{p.rares}{p.sessionRares > 0 ? ` (${p.sessionRares})` : ''}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: p.totalDeaths > 0 ? '#c66' : '#555', fontVariantNumeric: 'tabular-nums' }}>{p.totalDeaths}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#888', fontVariantNumeric: 'tabular-nums' }}>{p.uptime}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', fontVariantNumeric: 'tabular-nums',
color: p.hp > 80 ? '#4c4' : p.hp > 40 ? '#ca0' : '#c44' }}>{p.hp.toFixed(0)}%</td>
<td style={{ textAlign: 'right', padding: '3px 6px', fontVariantNumeric: 'tabular-nums',
color: p.vitae > 0 ? '#f66' : '#333' }}>{p.vitae > 0 ? `${p.vitae}%` : ''}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#888', fontVariantNumeric: 'tabular-nums' }}>{p.tapers.toLocaleString()}</td>
</tr>
);
})}
</tbody>
</table>
{players.length === 0 && (
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>No characters online</div>
)}
</div>
);
};
/**
* In-app draggable window wrapper. Kept for backward compatibility the
* sidebar button now opens the dashboard in a new tab via
* PlayerDashboardFullPage, so this component is no longer reachable
* via the default UI but still registered in WindowRenderer.
*/
export const PlayerDashboardWindow: React.FC<WindowProps> = ({ id, zIndex, characters }) => (
<DraggableWindow id={id} title="Player Dashboard" zIndex={zIndex} width={850} height={500}>
<PlayerDashboardContent characters={characters} />
</DraggableWindow>
);

View file

@ -1,84 +0,0 @@
import React, { useEffect, useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Props { id: string; zIndex: number; }
interface QuestData {
quest_data: Record<string, Record<string, string>>;
tracked_quests: string[];
player_count: number;
}
export const QuestStatusWindow: React.FC<Props> = ({ id, zIndex }) => {
const [data, setData] = useState<QuestData | null>(null);
useEffect(() => {
const fetch = async () => {
try { setData(await apiFetch<QuestData>('/quest-status')); } catch {}
};
fetch();
const iv = setInterval(fetch, 30000);
return () => clearInterval(iv);
}, []);
const characters = data ? Object.keys(data.quest_data).sort() : [];
// Collect ALL unique quest names across all characters
const allQuests = new Set<string>();
if (data) {
for (const quests of Object.values(data.quest_data)) {
for (const q of Object.keys(quests)) allQuests.add(q);
}
}
const questNames = Array.from(allQuests).sort();
return (
<DraggableWindow id={id} title="Quest Status" zIndex={zIndex} width={780} height={500}>
<div style={{ flex: 1, overflow: 'auto', fontSize: '0.72rem' }}>
{!data ? (
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>Loading quest data...</div>
) : characters.length === 0 ? (
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>No quest data available</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
<th style={{ textAlign: 'left', padding: '4px 8px', borderBottom: '1px solid #444', color: '#888', fontSize: '0.65rem', fontWeight: 600, minWidth: 140 }}>Character</th>
{questNames.map(q => (
<th key={q} style={{ textAlign: 'center', padding: '4px 6px', borderBottom: '1px solid #444', color: '#888', fontSize: '0.6rem', fontWeight: 600, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={q}>
{q.replace(' Timer', '').replace(' Pickup', '')}
</th>
))}
</tr>
</thead>
<tbody>
{characters.map(char => {
const quests = data.quest_data[char] || {};
return (
<tr key={char} style={{ borderBottom: '1px solid #222' }}>
<td style={{ padding: '3px 8px', color: '#ccc', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 160 }}>{char}</td>
{questNames.map(q => {
const val = quests[q];
const isReady = val === 'READY';
return (
<td key={q} style={{
textAlign: 'center', padding: '3px 6px',
color: isReady ? '#4c4' : val ? '#ca0' : '#333',
fontWeight: isReady ? 600 : 400,
fontSize: isReady ? '0.7rem' : '0.68rem',
}}>
{val || '\u2014'}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
)}
</div>
</DraggableWindow>
);
};

View file

@ -1,391 +0,0 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { DraggableWindow } from './DraggableWindow';
const CANVAS_SIZE = 300;
const DEFAULT_RANGE = 0.5; // AC units, ~120m
// ── Dungeon tile system (verbatim from v1) ──
const UB_TILE_COLORS: Record<string, { r: number; g: number; b: number }> = {
walls: { r: 0, g: 0, b: 255 }, innerWalls: { r: 127, g: 127, b: 255 },
rampedWalls: { r: 77, g: 255, b: 255 }, floors: { r: 0, g: 127, b: 255 },
stairs: { r: 0, g: 63, b: 255 },
};
const DUNGEON_COLORS: Record<string, { r: number; g: number; b: number }> = {
walls: { r: 140, g: 140, b: 180 }, innerWalls: { r: 100, g: 100, b: 140 },
rampedWalls: { r: 120, g: 160, b: 120 }, floors: { r: 60, g: 80, b: 60 },
stairs: { r: 180, g: 160, b: 80 },
};
function processTileImage(img: HTMLImageElement): HTMLCanvasElement {
const c = document.createElement('canvas');
c.width = 10; c.height = 10;
const ctx = c.getContext('2d')!;
ctx.drawImage(img, 0, 0, 10, 10);
const imageData = ctx.getImageData(0, 0, 10, 10);
const d = imageData.data;
for (let i = 0; i < d.length; i += 4) {
const r = d[i], g = d[i + 1], b = d[i + 2];
if (r > 240 && g > 240 && b > 240) { d[i + 3] = 0; continue; }
let matched = false;
for (const [key, src] of Object.entries(UB_TILE_COLORS)) {
if (Math.abs(r - src.r) < 15 && Math.abs(g - src.g) < 15 && Math.abs(b - src.b) < 15) {
const dst = DUNGEON_COLORS[key]; d[i] = dst.r; d[i + 1] = dst.g; d[i + 2] = dst.b;
matched = true; break;
}
}
if (!matched && r < 15 && g < 15 && b < 15) d[i + 3] = 100;
}
ctx.putImageData(imageData, 0, 0);
return c;
}
function cellRotation(rot: number): number {
if (rot === 1) return Math.PI;
if (rot < -0.70 && rot > -0.8) return Math.PI / 2;
if (rot > 0.70 && rot < 0.8) return -Math.PI / 2;
return 0;
}
let dungeonTileCanvases: Record<string, HTMLCanvasElement> | null = null;
function loadDungeonTiles() {
if (dungeonTileCanvases) return;
dungeonTileCanvases = {};
fetch('/dungeon_tiles.json').then(r => r.json()).then((data: Record<string, string>) => {
Object.entries(data).forEach(([envId, dataUrl]) => {
const img = new Image();
img.onload = () => { dungeonTileCanvases![envId] = processTileImage(img); };
img.src = dataUrl;
});
}).catch(() => {});
}
const RADAR_COLORS: Record<string, string> = {
Monster: '#ff4444', Player: '#4488ff', NPC: '#44cc44', Vendor: '#44cc44',
Portal: '#aa44ff', Corpse: '#ff8800', Container: '#cccc44', Door: '#888888',
};
function compassDir(angleDeg: number): string {
const a = ((angleDeg % 360) + 360) % 360;
const dirs = ['N','NE','E','SE','S','SW','W','NW'];
return dirs[Math.round(a / 45) % 8];
}
interface NearbyObject {
id: number; name: string; object_class?: string; type?: string;
ew?: number; ns?: number; distance?: number; bearing?: number;
raw_x?: number; raw_y?: number;
_px?: number; _py?: number;
}
interface Props {
id: string; charName: string; zIndex: number;
socket: WebSocket | null;
radarData: any; // full nearby_objects message
}
export const RadarWindow: React.FC<Props> = ({ id, charName, zIndex, socket, radarData }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rangeRef = useRef(DEFAULT_RANGE);
const [range, setRange] = useState(DEFAULT_RANGE);
const [selectedId, setSelectedId] = useState<number | null>(null);
const mapImgRef = useRef<HTMLImageElement | null>(null);
const objectsRef = useRef<NearbyObject[]>([]);
// Load map image + dungeon tiles once
useEffect(() => {
const img = new Image();
img.src = '/dereth.png';
img.onload = () => { mapImgRef.current = img; };
loadDungeonTiles();
}, []);
// Send start_radar on open, stop_radar on close
useEffect(() => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ player_name: charName, command: 'start_radar' }));
}
return () => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ player_name: charName, command: 'stop_radar' }));
}
};
}, [charName, socket]);
// Scroll to zoom
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 1.25 : 0.8;
rangeRef.current = Math.max(0.02, Math.min(5.0, rangeRef.current * factor));
setRange(rangeRef.current);
}, []);
// Click to select
const handleCanvasClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
let closestObj: NearbyObject | null = null;
let closestDist = 20;
objectsRef.current.forEach(obj => {
if (obj._px === undefined) return;
const d = Math.sqrt((mx - obj._px) ** 2 + (my - obj._py!) ** 2);
if (d < closestDist) { closestDist = d; closestObj = obj; }
});
setSelectedId(closestObj ? (closestObj as NearbyObject).id : null);
}, []);
// Render canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !radarData) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const size = CANVAS_SIZE;
const cx = size / 2, cy = size / 2;
const objects: NearbyObject[] = radarData.objects ?? [];
const playerEW = radarData.player_ew ?? 0;
const playerNS = radarData.player_ns ?? 0;
const heading = radarData.player_heading ?? 0;
const isDungeon = radarData.is_dungeon ?? false;
const playerX = radarData.player_x ?? 0;
const playerY = radarData.player_y ?? 0;
const currentRange = rangeRef.current;
const scale = isDungeon ? (size / 2) / (currentRange * 240) : (size / 2) / currentRange;
const headingRad = heading * Math.PI / 180;
// Clear + dark circle background
ctx.clearRect(0, 0, size, size);
ctx.fillStyle = '#111';
ctx.beginPath();
ctx.arc(cx, cy, cx, 0, Math.PI * 2);
ctx.fill();
// Clip to circle
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, cx - 1, 0, Math.PI * 2);
ctx.clip();
// Dungeon tile rendering (verbatim from v1 lines 3858-3909)
const landblock = radarData.landblock ?? null;
const playerRawZ = radarData.player_raw_z ?? 0;
if (isDungeon && landblock && (window as any).__dungeonMapCache?.[landblock]) {
const dmap = (window as any).__dungeonMapCache[landblock];
const playerRoundedZ = Math.floor((playerRawZ + 3) / 6) * 6;
ctx.translate(cx, cy);
ctx.rotate(-(heading - 180) * Math.PI / 180);
const cellSize = 10 * scale;
const hasTiles = dungeonTileCanvases && Object.keys(dungeonTileCanvases).length > 0;
const sortedLevels = (dmap.z_levels || []).slice().sort((a: any, b: any) =>
(a.z === playerRoundedZ ? 1 : 0) - (b.z === playerRoundedZ ? 1 : 0));
sortedLevels.forEach((level: any) => {
const isCurrentFloor = level.z === playerRoundedZ;
ctx.globalAlpha = isCurrentFloor ? 0.85 : 0.12;
(level.cells || []).forEach((cell: any) => {
const dx = -(cell.x - playerX) * scale;
const dy = (cell.y - playerY) * scale;
const tileCanvas = hasTiles ? dungeonTileCanvases![String(cell.env_id)] : null;
if (tileCanvas) {
ctx.save();
ctx.translate(dx, dy);
ctx.rotate(cellRotation(cell.rotation));
ctx.drawImage(tileCanvas, -cellSize / 2, -cellSize / 2, cellSize, cellSize);
ctx.restore();
} else {
ctx.fillStyle = isCurrentFloor ? '#3a5a3a' : '#1a2a1a';
ctx.fillRect(dx - cellSize / 2, dy - cellSize / 2, cellSize, cellSize);
}
});
});
ctx.globalAlpha = 1.0;
ctx.setTransform(1, 0, 0, 1, 0, 0);
} else if (!isDungeon && mapImgRef.current) {
// Semi-transparent overworld map background
const mapImg = mapImgRef.current;
const pixPerCoord = mapImg.naturalWidth / 204.2;
const mapCenterX = (playerEW + 102.1) * pixPerCoord;
const mapCenterY = (102.1 - playerNS) * pixPerCoord;
ctx.globalAlpha = 0.4;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-headingRad);
const srcSize = currentRange * pixPerCoord * 2;
ctx.drawImage(mapImg, mapCenterX - srcSize / 2, mapCenterY - srcSize / 2, srcSize, srcSize, -cx, -cy, size, size);
ctx.restore();
ctx.globalAlpha = 1.0;
}
ctx.restore();
// Range rings (4)
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
for (let i = 1; i <= 4; i++) {
ctx.beginPath();
ctx.arc(cx, cy, (cx / 4) * i, 0, Math.PI * 2);
ctx.stroke();
}
// Crosshairs
ctx.beginPath();
ctx.moveTo(cx, 0); ctx.lineTo(cx, size);
ctx.moveTo(0, cy); ctx.lineTo(size, cy);
ctx.stroke();
// Compass labels
ctx.font = 'bold 12px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
[{ l: 'N', a: 0 }, { l: 'E', a: Math.PI / 2 }, { l: 'S', a: Math.PI }, { l: 'W', a: -Math.PI / 2 }].forEach(({ l, a }) => {
const ra = a - headingRad;
ctx.fillStyle = l === 'N' ? '#cc4444' : '#888';
ctx.fillText(l, cx + Math.sin(ra) * (cx - 12), cy - Math.cos(ra) * (cx - 12));
});
// Facing line
ctx.strokeStyle = '#666';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx, cy - cx * 0.85);
ctx.stroke();
// Entity dots
const rotAngle = isDungeon ? (Math.PI - headingRad) : headingRad;
const cosA = Math.cos(rotAngle), sinA = Math.sin(rotAngle);
objects.forEach(obj => {
let dX: number, dY: number;
if (isDungeon && obj.raw_x !== undefined) {
dX = -(obj.raw_x - playerX);
dY = (obj.raw_y! - playerY);
} else {
dX = (obj.ew ?? 0) - playerEW;
dY = (obj.ns ?? 0) - playerNS;
}
const dx = dX * cosA - dY * sinA;
const dy = isDungeon ? (dX * sinA + dY * cosA) : -(dX * sinA + dY * cosA);
const px = cx + dx * scale;
const py = cy + dy * scale;
const distFromCenter = Math.sqrt((px - cx) ** 2 + (py - cy) ** 2);
if (distFromCenter > cx - 4) return;
obj._px = px;
obj._py = py;
const objClass = obj.object_class ?? obj.type ?? '';
const color = RADAR_COLORS[objClass] ?? '#888';
const isSel = obj.id === selectedId;
const dotSize = isSel ? 6 : (objClass === 'Monster' || objClass === 'Player') ? 4 : 3;
if (isSel) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(px, py, dotSize + 3, 0, Math.PI * 2);
ctx.stroke();
}
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
ctx.fill();
if (objClass === 'Player' || objClass === 'Portal' || isSel) {
ctx.fillStyle = isSel ? '#fff' : color;
ctx.font = '9px monospace';
ctx.textAlign = 'left';
ctx.fillText(obj.name, px + 6, py + 3);
}
});
objectsRef.current = objects;
// Player dot (center)
ctx.fillStyle = '#ffcc00';
ctx.beginPath();
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.stroke();
}, [radarData, range, selectedId]);
// Entity list with distance + direction
const entities = (radarData?.objects ?? []).map((obj: any) => {
const pEW = radarData?.player_ew ?? 0;
const pNS = radarData?.player_ns ?? 0;
const isDungeon = radarData?.is_dungeon ?? false;
const pX = radarData?.player_x ?? 0;
const pY = radarData?.player_y ?? 0;
let dX: number, dY: number, dist: number;
if (isDungeon && obj.raw_x !== undefined) {
dX = -(obj.raw_x - pX); dY = obj.raw_y - pY;
dist = Math.sqrt(dX * dX + dY * dY);
} else {
dX = (obj.ew ?? 0) - pEW; dY = (obj.ns ?? 0) - pNS;
dist = Math.sqrt(dX * dX + dY * dY) * 240;
}
const angle = Math.atan2(dX, dY) * 180 / Math.PI;
return { ...obj, dist, dir: compassDir(angle) };
}).sort((a: any, b: any) => a.dist - b.dist);
const rangeMeters = Math.round(range * 240);
return (
<DraggableWindow id={id} title={`Radar: ${charName}`} zIndex={zIndex} width={360} height={560}>
{/* Controls */}
<div style={{ padding: '4px 8px', display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', color: '#888', borderBottom: '1px solid #333', background: '#1a1a1a' }}>
<span>Range: ~{rangeMeters}m</span>
<span style={{ fontSize: '0.65rem', color: '#555' }}>Scroll to zoom</span>
</div>
{/* Canvas */}
<canvas ref={canvasRef} width={CANVAS_SIZE} height={CANVAS_SIZE}
style={{ display: 'block', margin: '0 auto', borderBottom: '1px solid #333', cursor: 'crosshair', flexShrink: 0 }}
onWheel={handleWheel} onClick={handleCanvasClick} />
{/* Entity list */}
<div style={{ flex: 1, overflowY: 'auto', fontSize: '0.72rem', minHeight: 0 }}>
{/* Header */}
<div style={{ display: 'flex', padding: '3px 6px', borderBottom: '1px solid #333', color: '#666', fontSize: '0.65rem', fontWeight: 600 }}>
<span style={{ width: 8 }}></span>
<span style={{ flex: 1, marginLeft: 6 }}>Name</span>
<span style={{ width: 55, textAlign: 'left' }}>Type</span>
<span style={{ width: 40, textAlign: 'right' }}>Dist</span>
<span style={{ width: 24, textAlign: 'center' }}>Dir</span>
</div>
{entities.length === 0 && (
<div style={{ padding: 12, color: '#555', textAlign: 'center', fontSize: '0.7rem' }}>
Waiting for radar data...
</div>
)}
{entities.map((obj: any) => {
const objClass = obj.object_class ?? obj.type ?? '';
const color = RADAR_COLORS[objClass] ?? '#888';
const isSel = obj.id === selectedId;
return (
<div key={obj.id} onClick={() => setSelectedId(isSel ? null : obj.id)}
style={{
display: 'flex', alignItems: 'center', padding: '2px 6px',
borderBottom: '1px solid #1a1a1a', cursor: 'pointer', color: '#ccc',
background: isSel ? '#1a2a3a' : '', borderLeft: isSel ? '2px solid #4488ff' : '2px solid transparent',
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }}></span>
<span style={{ flex: 1, marginLeft: 6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{obj.name}</span>
<span style={{ width: 55, color: '#888', fontSize: '0.65rem' }}>{objClass}</span>
<span style={{ width: 40, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
{obj.dist < 1000 ? `${Math.round(obj.dist)}m` : `${(obj.dist / 1000).toFixed(1)}km`}
</span>
<span style={{ width: 24, textAlign: 'center', color: '#666' }}>{obj.dir}</span>
</div>
);
})}
</div>
</DraggableWindow>
);
};

View file

@ -1,54 +0,0 @@
import React, { useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
interface Props { id: string; charName: string; zIndex: number; }
const PANELS = [
{ title: 'Kills per Hour', id: 1 },
{ title: 'Memory (MB)', id: 2 },
{ title: 'CPU (%)', id: 3 },
{ title: 'Mem Handles', id: 4 },
];
const TIME_RANGES = [
{ label: '1H', value: 'now-1h' },
{ label: '6H', value: 'now-6h' },
{ label: '24H', value: 'now-24h' },
{ label: '7D', value: 'now-7d' },
];
export const StatsWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
const [timeRange, setTimeRange] = useState('now-24h');
const iframeUrl = (panelId: number) =>
`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${panelId}&var-character=${encodeURIComponent(charName)}&from=${timeRange}&to=now&theme=light`;
return (
<DraggableWindow id={id} title={`Stats: ${charName}`} zIndex={zIndex} width={750} height={480}>
<div className="ml-stats-controls">
{TIME_RANGES.map(r => (
<button
key={r.value}
className={`ml-stats-range-btn ${timeRange === r.value ? 'active' : ''}`}
onClick={() => setTimeRange(r.value)}
>
{r.label}
</button>
))}
</div>
<div className="ml-stats-grid">
{PANELS.map(p => (
<div key={p.id} className="ml-stats-panel">
<iframe
src={iframeUrl(p.id)}
width="100%"
height="100%"
frameBorder="0"
title={p.title}
/>
</div>
))}
</div>
</DraggableWindow>
);
};

View file

@ -1,74 +0,0 @@
import React, { useEffect, useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Peer {
character_name: string; plugin_connected: boolean; subscribed: boolean;
tags: string[];
vitals?: { current_health: number; max_health: number; current_stamina: number; max_stamina: number; current_mana: number; max_mana: number };
position?: { ns: number; ew: number; z: number };
}
interface Props { id: string; zIndex: number; }
export const VitalSharingWindow: React.FC<Props> = ({ id, zIndex }) => {
const [peers, setPeers] = useState<Peer[]>([]);
useEffect(() => {
const fetch = async () => {
try {
const data = await apiFetch<{ peers: Peer[] }>('/vital-sharing/peers');
setPeers(data.peers ?? []);
} catch { /* ignore */ }
};
fetch();
const interval = setInterval(fetch, 5000);
return () => clearInterval(interval);
}, []);
const pct = (cur: number, max: number) => max > 0 ? Math.min(100, (cur / max) * 100) : 0;
return (
<DraggableWindow id={id} title="Vital Sharing Network" zIndex={zIndex} width={520} height={450}>
<div style={{ flex: 1, overflowY: 'auto', padding: 6, fontSize: '0.75rem' }}>
{peers.length === 0 ? (
<div style={{ padding: 16, color: '#666', textAlign: 'center' }}>No vital-sharing peers connected</div>
) : peers.map(p => (
<div key={p.character_name} style={{ padding: '6px 8px', marginBottom: 4, background: '#1f1f1f',
borderRadius: 3, border: '1px solid #333' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
<span style={{ color: p.plugin_connected ? '#4c4' : '#a33', fontSize: '0.8rem' }}></span>
<strong style={{ flex: 1 }}>{p.character_name}</strong>
{p.subscribed && <span style={{ color: '#6bf', fontSize: '0.65rem' }}>[subscribed]</span>}
</div>
<div style={{ color: '#666', fontSize: '0.68rem', marginBottom: 3 }}>
tags: {p.tags?.join(', ') || 'none'}
</div>
{p.vitals && p.vitals.max_health > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{[
{ label: 'HP', cur: p.vitals.current_health, max: p.vitals.max_health, bg: '#330000', fill: '#c44' },
{ label: 'STA', cur: p.vitals.current_stamina, max: p.vitals.max_stamina, bg: '#331a00', fill: '#ca0' },
{ label: 'MANA', cur: p.vitals.current_mana, max: p.vitals.max_mana, bg: '#001433', fill: '#48f' },
].map(bar => (
<div key={bar.label} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 32, color: '#888', fontSize: '0.65rem' }}>{bar.label}</span>
<div style={{ flex: 1, height: 6, background: bar.bg, borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: `${pct(bar.cur, bar.max)}%`, height: '100%', background: bar.fill, borderRadius: 3 }} />
</div>
<span style={{ width: 60, textAlign: 'right', fontSize: '0.65rem', color: '#888' }}>{bar.cur}/{bar.max}</span>
</div>
))}
</div>
)}
{p.position && (
<div style={{ color: '#555', fontSize: '0.65rem', marginTop: 2 }}>
{p.position.ns?.toFixed(1)}N, {p.position.ew?.toFixed(1)}E
</div>
)}
</div>
))}
</div>
</DraggableWindow>
);
};

View file

@ -1,79 +0,0 @@
import React, { useMemo, lazy, Suspense } from 'react';
import { useWindowManager } from '../../contexts/WindowManagerContext';
import { ChatWindow } from './ChatWindow'; // Chat is always fast — keep eager
const StatsWindow = lazy(() => import('./StatsWindow').then(m => ({ default: m.StatsWindow })));
const CharacterWindow = lazy(() => import('./CharacterWindow').then(m => ({ default: m.CharacterWindow })));
const InventoryWindow = lazy(() => import('./InventoryWindow').then(m => ({ default: m.InventoryWindow })));
const RadarWindow = lazy(() => import('./RadarWindow').then(m => ({ default: m.RadarWindow })));
const CombatStatsWindow = lazy(() => import('./CombatStatsWindow').then(m => ({ default: m.CombatStatsWindow })));
const CombatPickerWindow = lazy(() => import('./CombatPickerWindow').then(m => ({ default: m.CombatPickerWindow })));
const IssuesWindow = lazy(() => import('./IssuesWindow').then(m => ({ default: m.IssuesWindow })));
const VitalSharingWindow = lazy(() => import('./VitalSharingWindow').then(m => ({ default: m.VitalSharingWindow })));
const QuestStatusWindow = lazy(() => import('./QuestStatusWindow').then(m => ({ default: m.QuestStatusWindow })));
const PlayerDashboardWindow = lazy(() => import('./PlayerDashboardWindow').then(m => ({ default: m.PlayerDashboardWindow })));
const AgentWindow = lazy(() => import('./AgentWindow').then(m => ({ default: m.AgentWindow })));
const AdminUsersWindow = lazy(() => import('./AdminUsersWindow').then(m => ({ default: m.AdminUsersWindow })));
import type { CharacterState } from '../../types';
interface Props {
characters: Map<string, CharacterState>;
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
nearbyObjects: Map<string, any>;
/** Per-character inventory counters. InventoryWindow watches only its
* own character's value so unrelated deltas don't reset its debounce. */
inventoryVersions: Map<string, number>;
equipmentCantrips: Map<string, any>;
characterStats: Map<string, any>;
socket: WebSocket | null;
}
export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMessages, nearbyObjects, inventoryVersions, equipmentCantrips, characterStats, socket }) => {
const { windows } = useWindowManager();
return (
<Suspense fallback={null}>
{windows.map(w => {
const charName = w.charName ?? '';
const prefix = w.id.split('-')[0];
switch (prefix) {
case 'chat':
return <ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
messages={chatMessages.get(charName) ?? []} socket={socket} />;
case 'stats':
return <StatsWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
case 'char':
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
vitals={characters.get(charName)?.vitals ?? undefined}
liveStats={characterStats.get(charName)} />;
case 'inv':
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
inventoryVersion={inventoryVersions.get(charName) ?? 0} equipmentCantrips={equipmentCantrips.get(charName)} />;
case 'radar':
return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
socket={socket} radarData={nearbyObjects.get(charName) ?? null} />;
case 'combat':
return <CombatStatsWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
case 'combatpicker':
return <CombatPickerWindow key={w.id} id={w.id} zIndex={w.zIndex} characters={characters} />;
case 'issues':
return <IssuesWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
case 'vitalsharing':
return <VitalSharingWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
case 'queststatus':
return <QuestStatusWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
case 'playerdash':
return <PlayerDashboardWindow key={w.id} id={w.id} zIndex={w.zIndex} characters={characters} />;
case 'agent':
return <AgentWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
case 'adminusers':
return <AdminUsersWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
default:
return null;
}
})}
</Suspense>
);
});
WindowRenderer.displayName = 'WindowRenderer';

View file

@ -1,49 +0,0 @@
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
interface WindowState {
id: string;
title: string;
charName?: string;
zIndex: number;
}
interface WindowManagerValue {
windows: WindowState[];
openWindow: (id: string, title: string, charName?: string) => void;
closeWindow: (id: string) => void;
bringToFront: (id: string) => void;
}
const Ctx = createContext<WindowManagerValue>({
windows: [],
openWindow: () => {},
closeWindow: () => {},
bringToFront: () => {},
});
export const WindowManagerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [windows, setWindows] = useState<WindowState[]>([]);
const zRef = useRef(10000);
const openWindow = useCallback((id: string, title: string, charName?: string) => {
setWindows(prev => {
const existing = prev.find(w => w.id === id);
if (existing) {
return prev.map(w => w.id === id ? { ...w, zIndex: ++zRef.current } : w);
}
return [...prev, { id, title, charName, zIndex: ++zRef.current }];
});
}, []);
const closeWindow = useCallback((id: string) => {
setWindows(prev => prev.filter(w => w.id !== id));
}, []);
const bringToFront = useCallback((id: string) => {
setWindows(prev => prev.map(w => w.id === id ? { ...w, zIndex: ++zRef.current } : w));
}, []);
return <Ctx.Provider value={{ windows, openWindow, closeWindow, bringToFront }}>{children}</Ctx.Provider>;
};
export const useWindowManager = () => useContext(Ctx);

View file

@ -1,25 +0,0 @@
import { useEffect, useState } from 'react';
import { getCurrentUser, type CurrentUser } from '../api/endpoints';
/**
* Returns the currently-logged-in dashboard user, or null if not logged in /
* not yet loaded. Useful for conditionally showing admin-only UI bits.
*
* Fetches `/me` once on mount. Cheap the endpoint just decodes the
* session cookie and returns {username, is_admin}.
*/
export function useCurrentUser(): { user: CurrentUser | null; loading: boolean } {
const [user, setUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
getCurrentUser()
.then(u => { if (!cancelled) setUser(u); })
.catch(() => { if (!cancelled) setUser(null); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
return { user, loading };
}

View file

@ -1,217 +0,0 @@
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useWebSocket } from './useWebSocket';
import { getLive, getCombatStats, getServerHealth, getTotalRares, getTotalKills } from '../api/endpoints';
import type {
CharacterState, TelemetrySnapshot, VitalsMessage, CombatStatsMessage,
RareMessage, ServerHealth, WSMessage,
} from '../types';
export interface DashboardState {
characters: Map<string, CharacterState>;
serverHealth: ServerHealth | null;
totalRares: number;
totalKills: number;
recentRares: RareMessage[];
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
nearbyObjects: Map<string, any>;
/** Per-character inventory version counter bumps when that character
* receives an inventory_delta. Open windows watch only their own
* character's counter so deltas for unrelated chars don't reset their
* debounce timer. */
inventoryVersions: Map<string, number>;
equipmentCantrips: Map<string, any>;
characterStats: Map<string, any>;
deathAlerts: Array<{ character_name: string; vitae: number; timestamp: string }>;
socketRef: React.RefObject<WebSocket | null>;
}
export function useLiveData(): DashboardState {
const [characters, setCharacters] = useState<Map<string, CharacterState>>(new Map());
const [serverHealth, setServerHealth] = useState<ServerHealth | null>(null);
const [totalRares, setTotalRares] = useState(0);
const [totalKills, setTotalKills] = useState(0);
const [recentRares, setRecentRares] = useState<RareMessage[]>([]);
const chatMessagesRef = useRef(new Map<string, Array<{ text: string; color?: number; timestamp: string }>>());
const [chatVersion, setChatVersion] = useState(0);
const [inventoryVersions, setInventoryVersions] = useState<Map<string, number>>(new Map());
const equipmentCantripRef = useRef(new Map<string, any>());
const [equipCantripVersion, setEquipCantripVersion] = useState(0);
const characterStatsRef = useRef(new Map<string, any>());
const [charStatsVersion, setCharStatsVersion] = useState(0);
const [deathAlerts, setDeathAlerts] = useState<Array<{ character_name: string; vitae: number; timestamp: string }>>([]);
const [nearbyObjects, setNearbyObjects] = useState<Map<string, any>>(new Map());
const charsRef = useRef(characters);
charsRef.current = characters;
// Helper to update a single character's state
const updateChar = useCallback((name: string, updater: (prev: CharacterState) => CharacterState) => {
setCharacters(prev => {
const next = new Map(prev);
const existing = next.get(name) ?? { name, telemetry: null, vitals: null, combat: null, lastUpdate: 0 };
next.set(name, updater(existing));
return next;
});
}, []);
// WebSocket message handler
const handleWS = useCallback((msg: WSMessage) => {
if (!msg.type) return;
if (msg.type === 'telemetry') {
const t = msg as TelemetrySnapshot & { type: string };
updateChar(t.character_name, s => ({ ...s, telemetry: t, lastUpdate: Date.now() }));
} else if (msg.type === 'vitals') {
const v = msg as VitalsMessage;
// Detect death: vitae went from 0 to > 0
const prev = charsRef.current.get(v.character_name)?.vitals;
if (prev && (prev.vitae ?? 0) === 0 && (v.vitae ?? 0) > 0) {
setDeathAlerts(a => [...a, { character_name: v.character_name, vitae: v.vitae, timestamp: new Date().toISOString() }].slice(-50));
}
updateChar(v.character_name, s => ({ ...s, vitals: v, lastUpdate: Date.now() }));
} else if (msg.type === 'combat_stats') {
const c = msg as CombatStatsMessage;
updateChar(c.character_name, s => ({ ...s, combat: c, lastUpdate: Date.now() }));
} else if (msg.type === 'rare') {
const r = msg as RareMessage;
setRecentRares(prev => [r, ...prev].slice(0, 50));
} else if (msg.type === 'inventory_delta') {
const d = msg as unknown as { character_name: string };
// Bump ONLY this character's inventory version so an open window for
// that character re-fetches. Deltas for other characters don't touch
// it, which keeps the 2s debounce in InventoryWindow from being reset
// forever by unrelated chatter.
if (d.character_name) {
setInventoryVersions(prev => {
const next = new Map(prev);
next.set(d.character_name, (next.get(d.character_name) ?? 0) + 1);
return next;
});
}
} else if (msg.type === 'character_stats') {
// Store full character stats for CharacterWindow live updates
const cs = msg as unknown as { character_name: string };
characterStatsRef.current.set(cs.character_name, msg);
setCharStatsVersion(v => v + 1);
} else if (msg.type === 'equipment_cantrip_state') {
const ecs = msg as unknown as { character_name: string; items: any[]; timestamp: string };
equipmentCantripRef.current.set(ecs.character_name, ecs);
setEquipCantripVersion(v => v + 1);
} else if (msg.type === 'dungeon_map') {
// Cache dungeon map data for radar rendering (stored on window for canvas access)
const dm = msg as unknown as { landblock: string; z_levels: any[] };
if (dm.landblock) {
if (!(window as any).__dungeonMapCache) (window as any).__dungeonMapCache = {};
(window as any).__dungeonMapCache[dm.landblock] = dm;
}
} else if (msg.type === 'nearby_objects') {
const no = msg as unknown as { character_name: string; objects: any[]; is_dungeon?: boolean; landblock?: number };
setNearbyObjects(prev => {
const next = new Map(prev);
next.set(no.character_name, no);
return next;
});
// Request dungeon map if in dungeon and not cached
if (no.is_dungeon && no.landblock && !(window as any).__dungeonMapCache?.[no.landblock]) {
const ws = socketRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'request_dungeon_map', landblock: no.landblock }));
}
}
} else if (msg.type === 'chat') {
const m = msg as unknown as { character_name: string; text: string; color?: number; timestamp: string };
const arr = chatMessagesRef.current.get(m.character_name) ?? [];
arr.push({ text: m.text, color: m.color, timestamp: m.timestamp });
if (arr.length > 1000) arr.splice(0, arr.length - 1000);
chatMessagesRef.current.set(m.character_name, arr);
// Bump version to notify open chat windows (batched by React)
setChatVersion(v => v + 1);
}
}, [updateChar]);
const socketRef = useWebSocket(handleWS);
// HTTP polls as fallback/initial load
useEffect(() => {
const fetchLive = async () => {
try {
const data = await getLive();
setCharacters(prev => {
const next = new Map(prev);
for (const p of data.players ?? []) {
const existing = next.get(p.character_name);
next.set(p.character_name, {
name: p.character_name,
telemetry: p,
vitals: existing?.vitals ?? null,
combat: existing?.combat ?? null,
lastUpdate: Date.now(),
});
}
// Remove stale characters not in /live response
for (const key of next.keys()) {
if (!data.players?.some(p => p.character_name === key)) {
next.delete(key);
}
}
return next;
});
} catch { /* ignore */ }
};
fetchLive();
const id = setInterval(fetchLive, 5000);
return () => clearInterval(id);
}, []);
// Combat stats poll
useEffect(() => {
const fetch = async () => {
try {
const data = await getCombatStats();
for (const s of data.stats ?? []) {
updateChar(s.character_name, prev => ({
...prev,
combat: { ...s, type: 'combat_stats' },
}));
}
} catch { /* ignore */ }
};
fetch();
const id = setInterval(fetch, 30000);
return () => clearInterval(id);
}, [updateChar]);
// Server health poll
useEffect(() => {
const fetch = async () => {
try { setServerHealth(await getServerHealth()); } catch { /* ignore */ }
};
fetch();
const id = setInterval(fetch, 30000);
return () => clearInterval(id);
}, []);
// Global counters poll
useEffect(() => {
const fetch = async () => {
try {
const [rares, kills] = await Promise.all([getTotalRares(), getTotalKills()]);
setTotalRares((rares as any).all_time ?? 0);
setTotalKills((kills as any).total ?? 0);
} catch { /* ignore */ }
};
fetch();
const id = setInterval(fetch, 300000);
return () => clearInterval(id);
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
const chatMessages = useMemo(() => chatMessagesRef.current, [chatVersion]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const equipmentCantrips = useMemo(() => equipmentCantripRef.current, [equipCantripVersion]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const characterStats = useMemo(() => characterStatsRef.current, [charStatsVersion]);
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages, nearbyObjects, inventoryVersions, equipmentCantrips, characterStats, deathAlerts, socketRef };
}

View file

@ -1,47 +0,0 @@
import { useRef, useCallback } from 'react';
// Matches v1 script.js PALETTE — 60 distinct high-contrast colors
const PALETTE = [
// Original colorblind-friendly (10)
'#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd',
'#8c564b','#e377c2','#7f7f7f','#bcbd22','#17becf',
// Extended high-contrast (10)
'#ff4444','#44ff44','#4444ff','#ffff44','#ff44ff',
'#44ffff','#ff8844','#88ff44','#4488ff','#ff4488',
// Darker variants (10)
'#cc3333','#33cc33','#3333cc','#cccc33','#cc33cc',
'#33cccc','#cc6633','#66cc33','#3366cc','#cc3366',
// Brighter variants (10)
'#ff6666','#66ff66','#6666ff','#ffff66','#ff66ff',
'#66ffff','#ffaa66','#aaff66','#66aaff','#ff66aa',
// Additional distinct (10)
'#990099','#009900','#000099','#990000','#009999',
'#999900','#aa5500','#55aa00','#0055aa','#aa0055',
// Light pastels (10)
'#ffaaaa','#aaffaa','#aaaaff','#ffffaa','#ffaaff',
'#aaffff','#ffccaa','#ccffaa','#aaccff','#ffaacc',
];
function hashColor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;
return `hsl(${Math.abs(h) % 360}, 72%, 50%)`;
}
export function usePlayerColors() {
const mapRef = useRef(new Map<string, string>());
const idxRef = useRef(0);
const getColor = useCallback((name: string): string => {
let c = mapRef.current.get(name);
if (!c) {
c = idxRef.current < PALETTE.length
? PALETTE[idxRef.current++]
: hashColor(name);
mapRef.current.set(name, c);
}
return c;
}, []);
return getColor;
}

View file

@ -1,46 +0,0 @@
import { useEffect, useRef, useCallback } from 'react';
import { wsUrl } from '../api/client';
import type { WSMessage } from '../types';
type MessageHandler = (msg: WSMessage) => void;
export function useWebSocket(onMessage: MessageHandler): React.RefObject<WebSocket | null> {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<number>(0);
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(wsUrl());
wsRef.current = ws;
ws.addEventListener('message', (evt) => {
try {
const msg = JSON.parse(evt.data) as WSMessage;
onMessageRef.current(msg);
} catch { /* ignore parse errors */ }
});
ws.addEventListener('close', () => {
wsRef.current = null;
reconnectTimer.current = window.setTimeout(connect, 2000);
});
ws.addEventListener('error', () => {
ws.close();
});
}, []);
useEffect(() => {
connect();
return () => {
clearTimeout(reconnectTimer.current);
wsRef.current?.close();
wsRef.current = null;
};
}, [connect]);
return wsRef;
}

View file

@ -1,14 +0,0 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
// Register service worker for asset caching
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}

View file

@ -1,976 +0,0 @@
/*
Map Layout faithful reproduction of v1 style.css
Scoped under .ml-* prefix to avoid conflicts with dashboard
*/
/* ── Layout ───────────────────────────────────────────── */
.ml-layout {
display: flex;
height: 100vh;
overflow: hidden;
background: #111;
color: #eee;
font-family: "Segoe UI", sans-serif;
}
/* ── Sidebar ──────────────────────────────────────────── */
.ml-sidebar {
width: 400px;
min-width: 400px;
background: #1a1a1a;
border-right: 2px solid #333;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 12px 14px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.ml-sidebar::-webkit-scrollbar { display: none; }
.ml-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.ml-sidebar-title {
font-size: 0.85rem;
font-weight: 600;
color: #88f;
}
.ml-view-toggle {
font-size: 0.7rem;
padding: 3px 10px;
background: #333;
color: #aaa;
border: 1px solid #555;
border-radius: 3px;
cursor: pointer;
}
.ml-view-toggle:hover { background: #444; color: #fff; }
/* ── Server status ────────────────────────────────────── */
.ml-server-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0 8px;
font-size: 0.75rem;
color: #aaa;
}
.ml-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.ml-status-dot.online { background: #4c4; animation: ml-pulse 2s ease-in-out infinite; }
.ml-status-dot.offline { background: #c44; }
.ml-status-detail { color: #888; font-size: 0.7rem; }
.ml-status-latency { margin-left: auto; color: #888; }
/* ── Tool links ───────────────────────────────────────── */
.ml-tool-links {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.ml-tool-link {
font-size: 0.68rem;
color: #8ac;
text-decoration: none;
padding: 2px 6px;
background: rgba(68, 136, 255, 0.08);
border: 1px solid rgba(68, 136, 255, 0.15);
border-radius: 3px;
transition: all 0.15s;
}
.ml-tool-link:hover { background: rgba(68, 136, 255, 0.18); color: #adf; }
@keyframes ml-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* ── Aggregate counters ───────────────────────────────── */
.ml-counters {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.ml-counter {
flex: 1;
text-align: center;
padding: 6px 4px;
border-radius: 4px;
background: #222;
border: 1px solid #333;
}
.ml-counter-val {
display: block;
font-size: 1rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.ml-counter-lbl {
display: block;
font-size: 0.6rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.ml-counter.rares .ml-counter-val { color: #ffcc00; }
.ml-counter.kph .ml-counter-val { color: #4af; }
.ml-counter.kph { border-color: #234; animation: ml-kph-glow 3s ease-in-out infinite; }
.ml-counter.kph.ultra { background: linear-gradient(135deg, #112, #221); animation: ml-kph-glow 1.5s ease-in-out infinite; }
.ml-counter.kills .ml-counter-val { color: #f66; }
@keyframes ml-kph-glow {
0%, 100% { box-shadow: 0 0 4px rgba(68, 170, 255, 0.2); }
50% { box-shadow: 0 0 12px rgba(68, 170, 255, 0.5); }
}
/* ── Sort buttons ─────────────────────────────────────── */
.ml-sort-buttons {
display: flex;
gap: 2px;
margin: 8px 0;
}
.ml-sort-btn {
flex: 1;
padding: 4px 0;
font-size: 0.65rem;
font-weight: 600;
background: #2a2a2a;
color: #888;
border: 1px solid #444;
border-radius: 3px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.ml-sort-btn:hover { background: #333; color: #ccc; }
.ml-sort-btn.active { background: #334; color: #88f; border-color: #88f; }
/* ── Filter input ─────────────────────────────────────── */
.ml-filter {
width: 100%;
padding: 5px 8px;
font-size: 0.78rem;
background: #222;
color: #eee;
border: 1px solid #444;
border-radius: 3px;
outline: none;
margin-bottom: 8px;
box-sizing: border-box;
}
.ml-filter:focus { border-color: #88f; }
.ml-filter::placeholder { color: #666; }
/* ── Player list ──────────────────────────────────────── */
.ml-player-list {
list-style: none;
margin: 0;
padding: 0;
flex: 1;
overflow-y: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.ml-player-list::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.ml-player-row {
padding: 6px 8px;
border-bottom: 1px solid #2a2a2a;
border-left: 3px solid transparent;
cursor: pointer;
transition: background 0.1s;
}
.ml-player-row:hover { background: #252525; }
.ml-player-row.ml-player-selected { background: #2a3344; }
.ml-pr-name {
font-size: 0.82rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ml-pr-coords {
font-size: 0.65rem;
color: #888;
margin-bottom: 3px;
}
/* ── Vital bars ───────────────────────────────────────── */
.ml-pr-vitals {
display: flex;
gap: 3px;
margin-bottom: 4px;
}
.ml-vital-bar {
flex: 1;
height: 4px;
border-radius: 2px;
overflow: hidden;
}
.ml-vital-bar.hp { background: #330000; }
.ml-vital-bar.sta { background: #331a00; }
.ml-vital-bar.mana { background: #001433; }
.ml-vital-bar.hp .ml-vital-fill { background: linear-gradient(90deg, #ff4444, #ff6666); }
.ml-vital-bar.sta .ml-vital-fill { background: linear-gradient(90deg, #ffaa00, #ffcc44); }
.ml-vital-bar.mana .ml-vital-fill { background: linear-gradient(90deg, #4488ff, #66aaff); }
.ml-vital-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease-out;
}
/* ── Stats grid (3 columns aligned) ───────────────────── */
.ml-pr-header {
display: flex;
justify-content: space-between;
align-items: baseline;
cursor: pointer;
}
.ml-pr-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1px 8px;
font-size: 0.68rem;
color: #aaa;
margin-bottom: 4px;
}
.ml-gs {
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
gap: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ml-suffix {
font-size: 0.58rem;
color: #666;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.ml-taper-icon {
width: 14px;
height: 14px;
margin-right: 2px;
vertical-align: text-bottom;
}
.ml-meta-pill {
font-size: 0.6rem;
padding: 0 6px;
border-radius: 3px;
background: #333;
color: #888;
text-align: center;
justify-self: end;
}
/* ── Action buttons ───────────────────────────────────── */
.ml-pr-buttons {
display: flex;
gap: 3px;
margin-top: 4px;
}
.ml-btn {
padding: 2px 8px;
font-size: 0.63rem;
font-weight: 500;
border: 1px solid #3a3a3a;
border-radius: 4px;
background: #2a2a2a;
color: #999;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
letter-spacing: 0.2px;
}
.ml-btn:hover { background: #383838; color: #ddd; border-color: #555; }
.ml-btn.accent {
background: rgba(68, 136, 255, 0.12);
color: #6aadff;
border-color: rgba(68, 136, 255, 0.3);
}
.ml-btn.accent:hover {
background: rgba(68, 136, 255, 0.22);
color: #8ec5ff;
border-color: rgba(68, 136, 255, 0.5);
}
.ml-meta-pill.active { background: rgba(68, 204, 68, 0.15); color: #4c4; }
.ml-meta-pill.other { background: rgba(204, 68, 68, 0.15); color: #c44; }
/* ── Map container ────────────────────────────────────── */
.ml-map-container {
flex: 1;
position: relative;
overflow: hidden;
background: #000;
cursor: grab;
}
.ml-map-container:active { cursor: grabbing; }
.ml-map-group {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}
.ml-map-img {
display: block;
user-select: none;
-webkit-user-drag: none;
}
/* ── Player dots ──────────────────────────────────────── */
.ml-dots-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.ml-dot {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
transform: translate(-50%, -50%);
border: 1px solid rgba(0, 0, 0, 0.5);
pointer-events: all;
cursor: pointer;
z-index: 5;
}
.ml-dot:hover {
width: 10px;
height: 10px;
z-index: 10;
}
.ml-dot.ml-dot-selected {
width: 10px;
height: 10px;
z-index: 10;
animation: ml-blink 0.6s step-end infinite;
}
@keyframes ml-blink { 50% { opacity: 0; } }
/* ── Version display ──────────────────────────────────── */
.ml-version {
font-size: 0.65rem;
color: #aaa;
margin-bottom: 2px;
}
/* ── Agent (AI assistant) chat window ─────────────────── */
.ml-agent {
display: flex;
flex-direction: column;
height: 100%;
font-size: 0.85rem;
}
.ml-agent-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid #333;
background: #1a1a1a;
}
.ml-agent-btn {
background: #2a2a3a;
color: #ddd;
border: 1px solid #444;
border-radius: 3px;
padding: 3px 8px;
font-size: 0.75rem;
cursor: pointer;
}
.ml-agent-btn:hover:not(:disabled) { background: #353550; border-color: #88f; }
.ml-agent-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ml-agent-session {
font-family: monospace;
font-size: 0.7rem;
color: #888;
margin-left: auto;
}
.ml-agent-messages {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ml-agent-empty {
color: #888;
font-style: italic;
text-align: center;
padding: 20px;
line-height: 1.5;
}
.ml-agent-msg {
display: flex;
flex-direction: column;
gap: 2px;
max-width: 92%;
}
.ml-agent-user { align-self: flex-end; }
.ml-agent-assistant, .ml-agent-error { align-self: flex-start; }
.ml-agent-role {
font-size: 0.65rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #888;
}
.ml-agent-user .ml-agent-role { color: #88f; text-align: right; }
.ml-agent-assistant .ml-agent-role { color: #6fd07a; }
.ml-agent-error .ml-agent-role { color: #d66; }
.ml-agent-text {
padding: 7px 10px;
border-radius: 6px;
background: #232333;
color: #e8e8e8;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.4;
}
.ml-agent-user .ml-agent-text { background: #2a3a55; color: #fff; }
.ml-agent-error .ml-agent-text { background: #3a1c1c; color: #ffaaaa; }
.ml-agent-thinking {
color: #888;
font-style: italic;
}
.ml-agent-form {
display: flex;
gap: 6px;
padding: 6px 8px;
border-top: 1px solid #333;
background: #1a1a1a;
}
.ml-agent-input {
flex: 1;
resize: none;
background: #111;
color: #eee;
border: 1px solid #444;
border-radius: 3px;
padding: 5px 7px;
font-family: inherit;
font-size: 0.85rem;
line-height: 1.3;
}
.ml-agent-input:focus { outline: 1px solid #88f; border-color: #88f; }
.ml-agent-input:disabled { opacity: 0.6; }
.ml-agent-send {
background: #2a3a55;
color: #fff;
border: 1px solid #4466aa;
border-radius: 3px;
padding: 0 14px;
font-size: 0.85rem;
cursor: pointer;
}
.ml-agent-send:hover:not(:disabled) { background: #34507a; }
.ml-agent-send:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Tooltip ──────────────────────────────────────────── */
.ml-tooltip {
position: absolute;
background: rgba(0, 30, 60, 0.92);
color: #eee;
padding: 6px 10px;
border-radius: 4px;
font-size: 0.75rem;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
border: 1px solid #335;
}
/* ── Coordinate display ───────────────────────────────── */
.ml-coords {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0, 50, 100, 0.85);
color: #eee;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.75rem;
pointer-events: none;
z-index: 100;
font-variant-numeric: tabular-nums;
}
/* ── Map toggles ──────────────────────────────────────── */
.ml-toggles {
display: flex;
gap: 12px;
margin-bottom: 8px;
font-size: 0.72rem;
}
.ml-toggle-label {
display: flex;
align-items: center;
gap: 4px;
color: #aaa;
cursor: pointer;
}
.ml-toggle-label input { accent-color: #4488ff; }
/* ── Trail SVG overlay ────────────────────────────────── */
.ml-trails-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* ── Heatmap canvas overlay ───────────────────────────── */
.ml-heatmap-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
opacity: 0.8;
}
/* ── Portal markers ───────────────────────────────────── */
.ml-portals-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.ml-portal-icon {
position: absolute;
width: 6px;
height: 6px;
transform: translate(-50%, -50%);
pointer-events: all;
cursor: help;
}
.ml-portal-icon::before {
content: '🌀';
font-size: 10px;
position: absolute;
transform: translate(-50%, -50%);
}
/* ── Draggable windows ────────────────────────────────── */
.ml-window {
position: fixed;
background: #1a1a1a;
border: 1px solid #444;
border-radius: 6px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.ml-window-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 12px;
background: linear-gradient(135deg, #2a3a5a, #1a2a40);
cursor: move;
user-select: none;
border-bottom: 1px solid #334;
}
.ml-window-title {
font-size: 0.8rem;
font-weight: 600;
color: #aaccff;
}
.ml-window-close {
background: none;
border: none;
color: #888;
font-size: 1.1rem;
cursor: pointer;
line-height: 1;
padding: 0 4px;
}
.ml-window-close:hover { color: #f66; }
.ml-window-content {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
}
.ml-window-resize {
position: absolute;
bottom: 0;
right: 0;
width: 14px;
height: 14px;
cursor: nwse-resize;
opacity: 0.3;
background: linear-gradient(135deg, transparent 50%, #888 50%, transparent 52%, #888 65%, transparent 67%, #888 80%);
}
.ml-window-resize:hover { opacity: 0.6; }
/* ── Stats window (Grafana iframes) ───────────────────── */
.ml-stats-controls {
display: flex;
gap: 4px;
padding: 6px 10px;
border-bottom: 1px solid #333;
}
.ml-stats-range-btn {
padding: 3px 10px;
font-size: 0.7rem;
background: #2a2a2a;
color: #888;
border: 1px solid #444;
border-radius: 3px;
cursor: pointer;
}
.ml-stats-range-btn.active { background: rgba(68,136,255,0.15); color: #6aadff; border-color: rgba(68,136,255,0.3); }
.ml-stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 4px;
flex: 1;
}
.ml-stats-panel {
min-height: 200px;
background: #fff;
border-radius: 3px;
overflow: hidden;
}
.ml-stats-panel iframe {
border: none;
}
/* ── Chat window ──────────────────────────────────────── */
.ml-chat-messages {
flex: 1;
overflow-y: auto;
padding: 6px 10px;
font-size: 0.75rem;
font-family: 'Consolas', 'Courier New', monospace;
line-height: 1.4;
}
.ml-chat-line {
word-break: break-word;
}
.ml-chat-form {
display: flex;
border-top: 1px solid #333;
padding: 4px;
}
.ml-chat-input {
flex: 1;
background: #222;
color: #eee;
border: 1px solid #444;
border-radius: 3px;
padding: 4px 8px;
font-size: 0.78rem;
outline: none;
}
.ml-chat-input:focus { border-color: #4488ff; }
.ml-chat-input::placeholder { color: #666; }
/* ── Rare notifications ───────────────────────────────── */
.ml-rare-notifications {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 99999;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.ml-rare-notif {
background: linear-gradient(135deg, #1a0a2e, #2a1040);
border: 2px solid #ffcc00;
border-radius: 8px;
padding: 16px 32px;
text-align: center;
animation: ml-notif-in 0.5s ease-out;
box-shadow: 0 0 40px rgba(255, 204, 0, 0.3);
}
.ml-rare-notif.exiting {
animation: ml-notif-out 0.5s ease-in forwards;
}
.ml-rare-notif-title {
font-size: 1.4rem;
font-weight: 800;
color: #ffcc00;
text-shadow: 0 0 20px rgba(255, 204, 0, 0.5);
margin-bottom: 4px;
}
.ml-rare-notif-name {
font-size: 1.1rem;
font-weight: 600;
color: #fff;
margin-bottom: 4px;
}
.ml-rare-notif-by {
font-size: 0.75rem;
color: #888;
}
.ml-rare-notif-char {
font-size: 1rem;
font-weight: 700;
color: #ffcc00;
}
@keyframes ml-notif-in {
from { transform: translateY(-40px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes ml-notif-out {
to { transform: translateY(-60px); opacity: 0; }
}
/* ── Fireworks ────────────────────────────────────────── */
.ml-fireworks {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 99998;
}
.ml-firework-particle {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
animation: ml-particle 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
@keyframes ml-particle {
0% { transform: translate(0, 0) scale(1); opacity: 1; }
100% { transform: translate(var(--dx), var(--dy)) scale(0); opacity: 0; }
}
/* ── Sidebar logout link — visually distinct from window-opener links ── */
.ml-tool-link-logout {
margin-top: 4px;
color: #d88;
border-top: 1px dashed #444;
padding-top: 4px;
font-size: 0.78rem;
}
.ml-tool-link-logout:hover { color: #f88; background: rgba(150, 60, 60, 0.15); }
/* ── Admin · Users window ──────────────────────────────── */
.ml-admin {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px 10px;
gap: 10px;
font-size: 0.85rem;
overflow-y: auto;
}
.ml-admin-section {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
padding: 8px 10px;
}
.ml-admin-section h3 {
margin: 0 0 6px;
font-size: 0.9rem;
color: #cfcfff;
font-weight: 600;
}
.ml-admin-error {
background: #3a1c1c;
border: 1px solid #803333;
color: #ffaaaa;
padding: 6px 9px;
border-radius: 4px;
font-family: monospace;
font-size: 0.78rem;
white-space: pre-wrap;
}
.ml-admin-muted { color: #888; font-style: italic; }
.ml-admin-create {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.ml-admin-create input[type=text],
.ml-admin-create input[type=password] {
background: #111;
color: #eee;
border: 1px solid #444;
border-radius: 3px;
padding: 4px 7px;
font-size: 0.82rem;
flex: 1 1 140px;
min-width: 100px;
}
.ml-admin-create label {
display: inline-flex;
align-items: center;
gap: 4px;
color: #ccc;
font-size: 0.8rem;
}
.ml-admin button {
background: #2a2a3a;
color: #ddd;
border: 1px solid #444;
border-radius: 3px;
padding: 3px 9px;
font-size: 0.78rem;
cursor: pointer;
margin-right: 4px;
}
.ml-admin button:hover:not(:disabled) { background: #353550; border-color: #88f; }
.ml-admin button:disabled { opacity: 0.45; cursor: not-allowed; }
.ml-admin-danger { color: #ffaaaa; border-color: #803333 !important; }
.ml-admin-danger:hover:not(:disabled) { background: #3a1c1c !important; }
.ml-admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.ml-admin-table th, .ml-admin-table td {
text-align: left;
padding: 4px 6px;
border-bottom: 1px solid #2a2a2a;
vertical-align: middle;
}
.ml-admin-table th { color: #aaa; font-weight: 600; text-transform: uppercase; font-size: 0.68rem; letter-spacing: 0.04em; }
.ml-admin-table tbody tr:hover { background: rgba(255, 255, 255, 0.025); }
.ml-admin-toggle {
font-family: monospace;
font-weight: bold;
min-width: 28px;
text-align: center;
}
.ml-admin-pw-edit {
display: inline-flex;
gap: 4px;
align-items: center;
}
.ml-admin-pw-edit input {
background: #111;
color: #eee;
border: 1px solid #88f;
border-radius: 3px;
padding: 3px 6px;
font-family: monospace;
font-size: 0.78rem;
width: 160px;
}
/* ── Fullscreen Player Dashboard (new-tab variant) ───── */
.ml-dashboard-page {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
background: #111;
color: #ddd;
font-family: inherit;
}
.ml-dashboard-header {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 16px;
background: #1a1a1a;
border-bottom: 1px solid #333;
font-size: 0.9rem;
}
.ml-dashboard-title {
font-weight: 600;
color: #cfcfff;
font-size: 1rem;
}
.ml-dashboard-count {
color: #6af;
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
}
.ml-dashboard-version {
font-family: monospace;
font-size: 0.7rem;
color: #888;
}
.ml-dashboard-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 8px 12px;
}
/* ── Mobile ───────────────────────────────────────────── */
@media (max-width: 768px) {
.ml-layout { flex-direction: column; }
.ml-sidebar { width: 100%; min-width: 100%; max-height: 40vh; border-right: none; border-bottom: 2px solid #333; }
.ml-map-container { min-height: 60vh; }
}

View file

@ -1,109 +0,0 @@
// Matches the telemetry payload from the plugin + /live endpoint join
export interface TelemetrySnapshot {
character_name: string;
char_tag: string;
session_id: string;
timestamp: string;
ew: number;
ns: number;
z: number;
kills: number;
kills_per_hour: string;
onlinetime: string;
deaths: string;
total_deaths: string;
prismatic_taper_count: string;
vt_state: string;
mem_mb: number;
cpu_pct: number;
// Joined from DB in /live
total_rares?: number;
session_rares?: number;
total_kills?: number;
}
export interface VitalsMessage {
type: 'vitals';
character_name: string;
health_current: number;
health_max: number;
health_percentage: number;
stamina_current: number;
stamina_max: number;
stamina_percentage: number;
mana_current: number;
mana_max: number;
mana_percentage: number;
vitae: number;
}
export interface RareMessage {
type: 'rare';
character_name: string;
name: string;
timestamp: string;
}
export interface CombatStatsMessage {
type: 'combat_stats';
character_name: string;
session_id: string;
session: CombatSessionState | null;
lifetime: CombatSessionState | null;
}
export interface CombatSessionState {
total_damage_given: number;
total_damage_received: number;
total_kills: number;
total_aetheria_surges: number;
total_cloak_surges: number;
session_start: string;
monsters: Record<string, MonsterRecord>;
}
export interface MonsterRecord {
name: string;
kill_count: number;
damage_given: number;
damage_received: number;
aetheria_surges: number;
cloak_surges: number;
offense: Record<string, Record<string, DamageStats>>;
defense: Record<string, Record<string, DamageStats>>;
}
export interface DamageStats {
total_attacks: number;
failed_attacks: number;
crits: number;
total_normal_damage: number;
max_normal_damage: number;
total_crit_damage: number;
max_crit_damage: number;
damage: number;
}
export interface ServerHealth {
status: string;
latency_ms: number | null;
player_count: number | null;
uptime_seconds: number | null;
last_check: string | null;
}
// Merged live state for each character shown in the dashboard
export interface CharacterState {
name: string;
telemetry: TelemetrySnapshot | null;
vitals: VitalsMessage | null;
combat: CombatStatsMessage | null;
lastUpdate: number; // timestamp ms
}
export type WSMessage =
| (TelemetrySnapshot & { type: 'telemetry' })
| VitalsMessage
| CombatStatsMessage
| RareMessage
| { type: string; [key: string]: unknown };

View file

@ -1,31 +0,0 @@
// Matches v1 script.js MAP_BOUNDS (UtilityBelt's coordinate system)
export const MAP_BOUNDS = {
west: -102.1,
east: 102.1,
north: 102.1,
south: -102.1,
};
export function worldToPx(ew: number, ns: number, imgW: number, imgH: number) {
const x = ((ew - MAP_BOUNDS.west) / (MAP_BOUNDS.east - MAP_BOUNDS.west)) * imgW;
const y = ((MAP_BOUNDS.north - ns) / (MAP_BOUNDS.north - MAP_BOUNDS.south)) * imgH;
return { x, y };
}
export function pxToWorld(
screenX: number, screenY: number,
scale: number, offX: number, offY: number,
imgW: number, imgH: number,
) {
const mapX = (screenX - offX) / scale;
const mapY = (screenY - offY) / scale;
const ew = MAP_BOUNDS.west + (mapX / imgW) * (MAP_BOUNDS.east - MAP_BOUNDS.west);
const ns = MAP_BOUNDS.north - (mapY / imgH) * (MAP_BOUNDS.north - MAP_BOUNDS.south);
return { ew, ns };
}
export function formatCoord(ns: number, ew: number): string {
const nsDir = ns >= 0 ? 'N' : 'S';
const ewDir = ew >= 0 ? 'E' : 'W';
return `${Math.abs(ns).toFixed(1)}${nsDir}, ${Math.abs(ew).toFixed(1)}${ewDir}`;
}

View file

@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}

View file

@ -1 +0,0 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/endpoints.ts","./src/components/effects/deathnotification.tsx","./src/components/effects/rarenotification.tsx","./src/components/map/heatmapcanvas.tsx","./src/components/map/maplayout.tsx","./src/components/map/mapview.tsx","./src/components/map/playerdots.tsx","./src/components/map/portalmarkers.tsx","./src/components/map/sidebar.tsx","./src/components/map/trailssvg.tsx","./src/components/sidebar/playerlist.tsx","./src/components/sidebar/playerrow.tsx","./src/components/sidebar/sidebarwindowbuttons.tsx","./src/components/sidebar/sortbuttons.tsx","./src/components/windows/characterwindow.tsx","./src/components/windows/chatwindow.tsx","./src/components/windows/combatpickerwindow.tsx","./src/components/windows/combatstatswindow.tsx","./src/components/windows/draggablewindow.tsx","./src/components/windows/inventorywindow.tsx","./src/components/windows/issueswindow.tsx","./src/components/windows/playerdashboardwindow.tsx","./src/components/windows/queststatuswindow.tsx","./src/components/windows/radarwindow.tsx","./src/components/windows/statswindow.tsx","./src/components/windows/vitalsharingwindow.tsx","./src/components/windows/windowrenderer.tsx","./src/contexts/windowmanagercontext.tsx","./src/hooks/uselivedata.ts","./src/hooks/useplayercolors.ts","./src/hooks/usewebsocket.ts","./src/types/index.ts","./src/utils/coordinates.ts"],"version":"5.8.3"}

View file

@ -1,30 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/',
build: {
outDir: '../static/_build', // temp dir, deploy script copies to static/
emptyOutDir: true,
chunkSizeWarningLimit: 300,
rollupOptions: {
output: {
manualChunks: {
react: ['react', 'react-dom'],
},
},
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8765',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
ws: true,
},
},
},
})

View file

@ -125,14 +125,6 @@ class ItemCombatStats(Base):
gear_pk_damage_rating = Column(Integer) gear_pk_damage_rating = Column(Integer)
gear_pk_damage_resist_rating = Column(Integer) gear_pk_damage_resist_rating = Column(Integer)
# Base values (with active spell buffs reversed)
base_armor_level = Column(Integer)
base_max_damage = Column(Integer)
base_attack_bonus = Column(Float)
base_melee_defense_bonus = Column(Float)
base_elemental_damage_vs_monsters = Column(Float)
base_mana_conversion_bonus = Column(Float)
class ItemRequirements(Base): class ItemRequirements(Base):
"""Wield requirements and skill prerequisites.""" """Wield requirements and skill prerequisites."""
__tablename__ = 'item_requirements' __tablename__ = 'item_requirements'

File diff suppressed because it is too large Load diff

View file

@ -1,63 +0,0 @@
{
"_comment": "Spell effect mappings extracted from MosswartMassacre/Shared/Constants/Dictionaries.cs. Used to reverse-engineer base item values from buffed values.",
"int_effects": {
"_key_names": {"218103842": "MaxDamage", "28": "ArmorLevel"},
"1616": {"key": 218103842, "change": 20, "bonus": 0, "name": "Blood Drinker VI"},
"2096": {"key": 218103842, "change": 22, "bonus": 0, "name": "Infected Caress"},
"5183": {"key": 218103842, "change": 24, "bonus": 0, "name": "Incantation of Blood Drinker"},
"4395": {"key": 218103842, "change": 24, "bonus": 0, "name": "Incantation of Blood Drinker"},
"2598": {"key": 218103842, "change": 2, "bonus": 2, "name": "Minor Blood Thirst"},
"2586": {"key": 218103842, "change": 4, "bonus": 4, "name": "Major Blood Thirst"},
"4661": {"key": 218103842, "change": 7, "bonus": 7, "name": "Epic Blood Thirst"},
"6089": {"key": 218103842, "change": 10, "bonus": 10, "name": "Legendary Blood Thirst"},
"3688": {"key": 218103842, "change": 300, "bonus": 0, "name": "Prodigal Blood Drinker"},
"1486": {"key": 28, "change": 200, "bonus": 0, "name": "Impenetrability VI"},
"2108": {"key": 28, "change": 220, "bonus": 0, "name": "Brogard's Defiance"},
"4407": {"key": 28, "change": 240, "bonus": 0, "name": "Incantation of Impenetrability"},
"2604": {"key": 28, "change": 20, "bonus": 20, "name": "Minor Impenetrability"},
"2592": {"key": 28, "change": 40, "bonus": 40, "name": "Major Impenetrability"},
"4667": {"key": 28, "change": 60, "bonus": 60, "name": "Epic Impenetrability"},
"6095": {"key": 28, "change": 80, "bonus": 80, "name": "Legendary Impenetrability"}
},
"double_effects": {
"_key_names": {"152": "ElementalDamageVsMonsters", "167772172": "AttackBonus", "29": "MeleeDefenseBonus", "144": "ManaCBonus"},
"3258": {"key": 152, "change": 0.06, "bonus": 0, "name": "Spirit Drinker VI"},
"3259": {"key": 152, "change": 0.07, "bonus": 0, "name": "Infected Spirit Caress"},
"5182": {"key": 152, "change": 0.08, "bonus": 0, "name": "Incantation of Spirit Drinker"},
"4414": {"key": 152, "change": 0.08, "bonus": 0, "name": "Incantation of Spirit Drinker"},
"3251": {"key": 152, "change": 0.01, "bonus": 0.01, "name": "Minor Spirit Thirst"},
"3250": {"key": 152, "change": 0.03, "bonus": 0.03, "name": "Major Spirit Thirst"},
"4670": {"key": 152, "change": 0.05, "bonus": 0.05, "name": "Epic Spirit Thirst"},
"6098": {"key": 152, "change": 0.07, "bonus": 0.07, "name": "Legendary Spirit Thirst"},
"3735": {"key": 152, "change": 0.15, "bonus": 0, "name": "Prodigal Spirit Drinker"},
"1592": {"key": 167772172, "change": 0.15, "bonus": 0, "name": "Heart Seeker VI"},
"2106": {"key": 167772172, "change": 0.17, "bonus": 0, "name": "Elysa's Sight"},
"4405": {"key": 167772172, "change": 0.20, "bonus": 0, "name": "Incantation of Heart Seeker"},
"2603": {"key": 167772172, "change": 0.03, "bonus": 0.03, "name": "Minor Heart Thirst"},
"2591": {"key": 167772172, "change": 0.05, "bonus": 0.05, "name": "Major Heart Thirst"},
"4666": {"key": 167772172, "change": 0.07, "bonus": 0.07, "name": "Epic Heart Thirst"},
"6094": {"key": 167772172, "change": 0.09, "bonus": 0.09, "name": "Legendary Heart Thirst"},
"1605": {"key": 29, "change": 0.15, "bonus": 0, "name": "Defender VI"},
"2101": {"key": 29, "change": 0.17, "bonus": 0, "name": "Cragstone's Will"},
"4400": {"key": 29, "change": 0.20, "bonus": 0, "name": "Incantation of Defender"},
"2600": {"key": 29, "change": 0.03, "bonus": 0.03, "name": "Minor Defender"},
"3985": {"key": 29, "change": 0.04, "bonus": 0.04, "name": "Mukkir Sense"},
"2588": {"key": 29, "change": 0.05, "bonus": 0.05, "name": "Major Defender"},
"4663": {"key": 29, "change": 0.07, "bonus": 0.07, "name": "Epic Defender"},
"6091": {"key": 29, "change": 0.09, "bonus": 0.09, "name": "Legendary Defender"},
"3699": {"key": 29, "change": 0.25, "bonus": 0, "name": "Prodigal Defender"},
"1480": {"key": 144, "change": 1.60, "bonus": 0, "name": "Hermetic Link VI"},
"2117": {"key": 144, "change": 1.70, "bonus": 0, "name": "Mystic's Blessing"},
"4418": {"key": 144, "change": 1.80, "bonus": 0, "name": "Incantation of Hermetic Link"},
"3201": {"key": 144, "change": 1.05, "bonus": 1.05, "name": "Feeble Hermetic Link"},
"3199": {"key": 144, "change": 1.10, "bonus": 1.10, "name": "Minor Hermetic Link"},
"3202": {"key": 144, "change": 1.15, "bonus": 1.15, "name": "Moderate Hermetic Link"},
"3200": {"key": 144, "change": 1.20, "bonus": 1.20, "name": "Major Hermetic Link"},
"6086": {"key": 144, "change": 1.25, "bonus": 1.25, "name": "Epic Hermetic Link"},
"6087": {"key": 144, "change": 1.30, "bonus": 1.30, "name": "Legendary Hermetic Link"}
}
}

2883
main.py

File diff suppressed because it is too large Load diff

View file

@ -1,116 +0,0 @@
# Nginx site config for overlord.snakedesert.se
#
# Lives on the host (not in the Docker stack) at:
# /etc/nginx/sites-enabled/overlord
#
# This file is the source-of-truth copy committed to git. To deploy a change:
# 1. Edit this file in the repo
# 2. SSH to the host
# 3. sudo cp /home/erik/MosswartOverlord/nginx/overlord.conf /etc/nginx/sites-enabled/overlord
# 4. sudo nginx -t && sudo nginx -s reload
#
# Critical settings:
# - proxy_read_timeout / proxy_send_timeout 1d on /websocket/ and /
# WebSockets are long-lived; nginx's default 60s timeout drops idle clients.
# Removing these timeouts caused all plugin connections to drop every
# ~60s when no data flowed from backend to client (April 2026 incident).
# - Bearer token in /grafana/ proxy_set_header is a Grafana service account
# token used for anonymous panel embeds. Rotate when credentials leak.
server {
listen 443 ssl;
server_name overlord.snakedesert.se;
# Security hardening
server_tokens off;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
# SSL certificates
ssl_certificate /etc/letsencrypt/live/overlord.snakedesert.se/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/overlord.snakedesert.se/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Plugin WebSocket ingest — `/ws/position` upstream
location /websocket/ {
proxy_pass http://tracker/ws/position;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Plugin-Secret $http_x_plugin_secret;
proxy_cache_bypass $http_upgrade;
# Long-lived WebSocket: don't time out the proxy
proxy_read_timeout 1d;
proxy_send_timeout 1d;
}
# Overlord Agent — host-side service running OUTSIDE the Docker stack
# because it shells out to `claude` which depends on host-side
# ~/.claude credentials. Long timeout because agent calls can spin
# while Claude Code chains tool invocations.
location /api/agent/ {
proxy_pass http://127.0.0.1:8767/agent/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass_request_headers on;
# Heavy tool calls (cross-char search, suitbuilder) can take a while;
# the python wrapper caps each turn at 240s, so 300s gives some
# headroom for the round trip.
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# API endpoints (live, trails, history, stats) — short-lived HTTP
location /api/ {
proxy_pass http://tracker/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
}
# Frontend UI and browser WebSocket (`/ws/live` upstream)
location / {
proxy_pass http://tracker/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
# Long-lived browser WebSocket (/ws/live): don't time out
proxy_read_timeout 1d;
proxy_send_timeout 1d;
}
# Grafana Dashboard UI (served under /grafana)
location /grafana/ {
proxy_pass http://grafana;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "Bearer glsa_AcDTcN5CUX9h5Bi2ipmVAs6g1FRTSIWk_8b81cf99";
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

View file

@ -1,268 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dereth Tracker - Admin</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
background: #0a0a0a;
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
color: #d4c9a8;
padding: 40px;
}
.admin-container {
max-width: 600px;
margin: 0 auto;
background: linear-gradient(180deg, #1a1610 0%, #0e0c08 100%);
border: 2px solid #8a7a44;
border-radius: 6px;
padding: 24px;
box-shadow: 0 8px 32px rgba(0,0,0,0.8);
}
h1 {
color: #d4af37;
font-size: 1.3rem;
margin-bottom: 20px;
text-align: center;
letter-spacing: 1px;
}
.back-link {
display: inline-block;
margin-bottom: 16px;
color: #8a7a44;
text-decoration: none;
font-size: 0.8rem;
}
.back-link:hover { color: #d4af37; }
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th, td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid #2a2418;
font-size: 0.8rem;
}
th {
color: #a09070;
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 1px;
}
td { color: #d4c9a8; }
.badge-admin {
display: inline-block;
padding: 1px 6px;
background: #d4af37;
color: #1a1610;
border-radius: 3px;
font-size: 0.65rem;
font-weight: bold;
}
.badge-user {
display: inline-block;
padding: 1px 6px;
background: #3a3a3a;
color: #aaa;
border-radius: 3px;
font-size: 0.65rem;
}
.btn {
padding: 3px 8px;
font-size: 0.7rem;
font-family: inherit;
cursor: pointer;
border-radius: 3px;
border: 1px solid;
background: transparent;
margin-right: 4px;
}
.btn-danger { color: #c44; border-color: #c44; }
.btn-danger:hover { background: #c44; color: #fff; }
.btn-warn { color: #ca4; border-color: #ca4; }
.btn-warn:hover { background: #ca4; color: #fff; }
.add-form {
padding: 16px;
background: #151210;
border: 1px solid #3a2818;
border-radius: 4px;
}
.add-form h3 {
color: #a09070;
font-size: 0.85rem;
margin-bottom: 12px;
}
.form-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: center;
}
.form-row input {
flex: 1;
padding: 6px 10px;
font-size: 0.85rem;
font-family: inherit;
background: #0e0c08;
color: #d4c9a8;
border: 1px solid #5a4a24;
border-radius: 3px;
outline: none;
}
.form-row input:focus { border-color: #d4af37; }
.form-row label {
font-size: 0.75rem;
color: #a09070;
white-space: nowrap;
}
.form-row input[type="checkbox"] {
flex: none;
width: 16px;
height: 16px;
}
.btn-add {
padding: 6px 16px;
font-size: 0.85rem;
font-family: inherit;
font-weight: bold;
color: #1a1610;
background: linear-gradient(180deg, #d4af37 0%, #a08520 100%);
border: 1px solid #8a7a44;
border-radius: 3px;
cursor: pointer;
}
.btn-add:hover { background: linear-gradient(180deg, #e0c050 0%, #b89a30 100%); }
.msg {
margin-top: 8px;
font-size: 0.75rem;
padding: 6px;
border-radius: 3px;
display: none;
}
.msg-error { color: #ff6b6b; background: rgba(255,50,50,0.08); border: 1px solid rgba(255,50,50,0.2); }
.msg-ok { color: #4a4; background: rgba(74,170,74,0.08); border: 1px solid rgba(74,170,74,0.2); }
</style>
</head>
<body>
<div class="admin-container">
<a href="/" class="back-link">&larr; Back to Tracker</a>
<h1>User Management</h1>
<table>
<thead><tr><th>Username</th><th>Role</th><th>Created</th><th>Actions</th></tr></thead>
<tbody id="userTableBody"><tr><td colspan="4">Loading...</td></tr></tbody>
</table>
<div class="add-form">
<h3>Add New User</h3>
<div class="form-row">
<input type="text" id="newUsername" placeholder="Username">
<input type="password" id="newPassword" placeholder="Password">
<label><input type="checkbox" id="newIsAdmin"> Admin</label>
<button class="btn-add" onclick="addUser()">Add</button>
</div>
<div class="msg msg-error" id="addError"></div>
<div class="msg msg-ok" id="addOk"></div>
</div>
</div>
<script>
async function loadUsers() {
const tbody = document.getElementById('userTableBody');
try {
const resp = await fetch('/api-admin/users');
if (!resp.ok) throw new Error('Failed to load');
const data = await resp.json();
tbody.innerHTML = '';
data.users.forEach(u => {
const date = u.created_at ? new Date(u.created_at).toLocaleDateString('sv-SE') : '';
const role = u.is_admin
? '<span class="badge-admin">ADMIN</span>'
: '<span class="badge-user">USER</span>';
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${esc(u.username)}</td>
<td>${role}</td>
<td>${date}</td>
<td>
<button class="btn btn-warn" onclick="resetPw(${u.id}, '${esc(u.username)}')">Reset PW</button>
<button class="btn btn-danger" onclick="delUser(${u.id}, '${esc(u.username)}')">Delete</button>
</td>
`;
tbody.appendChild(tr);
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="4" style="color:#c44">Failed to load users</td></tr>';
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
async function addUser() {
const errDiv = document.getElementById('addError');
const okDiv = document.getElementById('addOk');
errDiv.style.display = 'none';
okDiv.style.display = 'none';
const username = document.getElementById('newUsername').value.trim();
const password = document.getElementById('newPassword').value;
const is_admin = document.getElementById('newIsAdmin').checked;
if (!username || !password) { showMsg(errDiv, 'Username and password required'); return; }
try {
const resp = await fetch('/api-admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, is_admin }),
});
if (!resp.ok) {
const d = await resp.json();
showMsg(errDiv, d.detail || 'Failed');
return;
}
document.getElementById('newUsername').value = '';
document.getElementById('newPassword').value = '';
document.getElementById('newIsAdmin').checked = false;
showMsg(okDiv, `User "${username}" created`);
loadUsers();
} catch (e) { showMsg(errDiv, 'Connection error'); }
}
async function delUser(id, name) {
if (!confirm(`Delete user "${name}"?`)) return;
try {
const resp = await fetch(`/api-admin/users/${id}`, { method: 'DELETE' });
if (!resp.ok) { const d = await resp.json(); alert(d.detail || 'Failed'); return; }
loadUsers();
} catch (e) { alert('Connection error'); }
}
async function resetPw(id, name) {
const pw = prompt(`New password for "${name}":`);
if (!pw) return;
try {
const resp = await fetch(`/api-admin/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
});
if (!resp.ok) { const d = await resp.json(); alert(d.detail || 'Failed'); return; }
alert('Password updated');
} catch (e) { alert('Connection error'); }
}
function showMsg(el, text) {
el.textContent = text;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 4000);
}
loadUsers();
</script>
</body>
</html>

View file

@ -1 +0,0 @@
import{e as I,r as t,l as _,f as L,g as N,h as W,j as e,D as F}from"./index-C-CrqDmq.js";import"./react-yfL0ty4i.js";function R(c){try{return new Date(c).toISOString().slice(0,10)}catch{return c}}const q=({id:c,zIndex:x})=>{const{user:j}=I(),[g,k]=t.useState([]),[w,p]=t.useState(!0),[b,n]=t.useState(null),[l,C]=t.useState(""),[i,f]=t.useState(""),[h,y]=t.useState(!1),[d,S]=t.useState(!1),[A,u]=t.useState(null),[o,m]=t.useState(""),r=t.useCallback(async()=>{p(!0),n(null);try{const s=await _();k(s.users??[])}catch(s){n(String(s))}finally{p(!1)}},[]);t.useEffect(()=>{r()},[r]);const U=t.useCallback(async s=>{if(s.preventDefault(),!l.trim()||i.length<4){n("Username required and password must be at least 4 chars");return}S(!0),n(null);try{await L(l.trim(),i,h),C(""),f(""),y(!1),await r()}catch(a){n(String(a))}finally{S(!1)}},[l,i,h,r]),v=t.useCallback(async s=>{n(null);try{await N(s.id,{is_admin:!s.is_admin}),await r()}catch(a){n(String(a))}},[r]),D=t.useCallback(async s=>{if(o.length<4){n("Password must be at least 4 characters");return}n(null);try{await N(s,{password:o}),u(null),m("")}catch(a){n(String(a))}},[o]),P=t.useCallback(async s=>{if(confirm(`Delete user "${s.username}"? This cannot be undone.`)){n(null);try{await W(s.id),await r()}catch(a){n(String(a))}}},[r]);return e.jsx(F,{id:c,title:"🛡️ Admin · Users",zIndex:x,width:620,height:540,children:e.jsxs("div",{className:"ml-admin",children:[b&&e.jsx("div",{className:"ml-admin-error",children:b}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsx("h3",{children:"Add user"}),e.jsxs("form",{onSubmit:U,className:"ml-admin-create",children:[e.jsx("input",{type:"text",placeholder:"Username",value:l,onChange:s=>C(s.target.value),disabled:d,autoComplete:"off"}),e.jsx("input",{type:"password",placeholder:"Password (min 4)",value:i,onChange:s=>f(s.target.value),disabled:d,autoComplete:"new-password"}),e.jsxs("label",{children:[e.jsx("input",{type:"checkbox",checked:h,onChange:s=>y(s.target.checked),disabled:d}),"admin"]}),e.jsx("button",{type:"submit",disabled:d||!l.trim()||i.length<4,children:d?"Adding…":"Add"})]})]}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsxs("h3",{children:["Users ",w&&e.jsx("span",{className:"ml-admin-muted",children:"(loading…)"})]}),e.jsxs("table",{className:"ml-admin-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"ID"}),e.jsx("th",{children:"Username"}),e.jsx("th",{children:"Admin"}),e.jsx("th",{children:"Created"}),e.jsx("th",{children:"Actions"})]})}),e.jsxs("tbody",{children:[g.map(s=>{const a=j!=null&&j.username.toLowerCase()===s.username.toLowerCase();return e.jsxs("tr",{children:[e.jsx("td",{children:s.id}),e.jsxs("td",{children:[s.username,a&&e.jsx("span",{className:"ml-admin-muted",children:" (you)"})]}),e.jsx("td",{children:e.jsx("button",{className:"ml-admin-toggle",onClick:()=>v(s),title:"Click to toggle admin",children:s.is_admin?"✓":""})}),e.jsx("td",{children:R(s.created_at)}),e.jsx("td",{children:A===s.id?e.jsxs("span",{className:"ml-admin-pw-edit",children:[e.jsx("input",{type:"text",placeholder:"New password",value:o,onChange:E=>m(E.target.value),autoFocus:!0}),e.jsx("button",{onClick:()=>D(s.id),children:"Save"}),e.jsx("button",{onClick:()=>{u(null),m("")},children:"Cancel"})]}):e.jsxs(e.Fragment,{children:[e.jsx("button",{onClick:()=>{u(s.id),m("")},children:"Reset PW"}),!a&&e.jsx("button",{className:"ml-admin-danger",onClick:()=>P(s),children:"Delete"})]})})]},s.id)}),g.length===0&&!w&&e.jsx("tr",{children:e.jsx("td",{colSpan:5,className:"ml-admin-muted",children:"No users."})})]})]})]})]})})};export{q as AdminUsersWindow};

View file

@ -1 +0,0 @@
import{r as n,b as w,c as k,d as E,j as t,D as I}from"./index-C-CrqDmq.js";import"./react-yfL0ty4i.js";const h="overlord_agent_session_id";function v(){if(typeof crypto<"u"&&typeof crypto.randomUUID=="function")return crypto.randomUUID();const s=l=>Math.floor(Math.random()*l);return`${s(4294967296).toString(16).padStart(8,"0")}-${s(65536).toString(16).padStart(4,"0")}-4${s(4096).toString(16).padStart(3,"0")}-${(8+s(4)).toString(16)}${s(4096).toString(16).padStart(3,"0")}-${s(281474976710656).toString(16).padStart(12,"0")}`}function $(){try{const l=localStorage.getItem(h);if(l)return l}catch{}const s=v();try{localStorage.setItem(h,s)}catch{}return s}const _=({id:s,zIndex:l})=>{const[o,j]=n.useState(()=>$()),[d,i]=n.useState([]),[g,m]=n.useState(""),[r,f]=n.useState(!1),[p,x]=n.useState(!0),S=n.useRef(null);n.useEffect(()=>{let e=!1;return x(!0),w(o).then(a=>{if(e)return;const c=(a.messages??[]).map(y=>({role:y.role,text:y.text}));i(c)}).catch(()=>{e||i([])}).finally(()=>{e||x(!1)}),()=>{e=!0}},[o]),n.useEffect(()=>{const e=S.current;e&&(e.scrollTop=e.scrollHeight)},[d.length,r]);const u=n.useCallback(async()=>{const e=g.trim();if(!(!e||r)){m(""),i(a=>[...a,{role:"user",text:e}]),f(!0);try{const a=await k(e,o);i(c=>[...c,{role:a.is_error?"error":"assistant",text:a.result||"(no response)"}])}catch(a){i(c=>[...c,{role:"error",text:`Request failed: ${String(a)}`}])}finally{f(!1)}}},[g,r,o]),N=n.useCallback(async()=>{if(r)return;let e="";try{e=(await E()).session_id}catch{e=v()}try{localStorage.setItem(h,e)}catch{}j(e),i([]),m("")},[r]),b=n.useCallback(e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),u())},[u]);return t.jsx(I,{id:s,title:"🤖 Overlord Assistant",zIndex:l,width:520,height:620,children:t.jsxs("div",{className:"ml-agent",children:[t.jsxs("div",{className:"ml-agent-toolbar",children:[t.jsx("button",{className:"ml-agent-btn",onClick:N,disabled:r,children:"+ New Chat"}),t.jsxs("span",{className:"ml-agent-session",title:o,children:[o.slice(0,8),"…"]})]}),t.jsxs("div",{className:"ml-agent-messages",ref:S,children:[p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Loading conversation…"}),!p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Ask anything about the live game state — players, kills, inventory, suitbuilder, recent rares, etc."}),d.map((e,a)=>t.jsxs("div",{className:`ml-agent-msg ml-agent-${e.role}`,children:[t.jsx("div",{className:"ml-agent-role",children:e.role==="user"?"You":e.role==="assistant"?"Overlord":"Error"}),t.jsx("div",{className:"ml-agent-text",children:e.text})]},a)),r&&t.jsxs("div",{className:"ml-agent-msg ml-agent-assistant",children:[t.jsx("div",{className:"ml-agent-role",children:"Overlord"}),t.jsx("div",{className:"ml-agent-text ml-agent-thinking",children:"Thinking…"})]})]}),t.jsxs("form",{className:"ml-agent-form",onSubmit:e=>{e.preventDefault(),u()},children:[t.jsx("textarea",{className:"ml-agent-input",value:g,onChange:e=>m(e.target.value),onKeyDown:b,placeholder:r?"Waiting for response…":"Type a message — Enter to send, Shift+Enter for newline",disabled:r,rows:2}),t.jsx("button",{type:"submit",className:"ml-agent-send",disabled:r||!g.trim(),children:"Send"})]})]})})};export{_ as AgentWindow};

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
import{u as c,j as r,D as d}from"./index-C-CrqDmq.js";import"./react-yfL0ty4i.js";const p=({id:n,zIndex:i,characters:a})=>{const{openWindow:s}=c(),e=Array.from(a.keys()).sort();return r.jsx(d,{id:n,title:"Combat Stats — Select Character",zIndex:i,width:300,height:400,children:r.jsx("div",{style:{flex:1,overflowY:"auto",padding:6},children:e.length===0?r.jsx("div",{style:{padding:12,color:"#666",textAlign:"center",fontSize:"0.8rem"},children:"No characters online"}):e.map(o=>r.jsx("div",{style:{padding:"5px 8px",cursor:"pointer",borderBottom:"1px solid #222",color:"#ccc",fontSize:"0.82rem"},onMouseEnter:t=>t.currentTarget.style.background="#2a2a2a",onMouseLeave:t=>t.currentTarget.style.background="",onClick:()=>s(`combat-${o}`,`Combat: ${o}`,o),children:o},o))})})};export{p as CombatPickerWindow};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
import{r as c,j as t,D as u,a as f}from"./index-C-CrqDmq.js";import"./react-yfL0ty4i.js";const j=({id:p,zIndex:x})=>{const[s,h]=c.useState(null);c.useEffect(()=>{const e=async()=>{try{h(await f("/quest-status"))}catch{}};e();const o=setInterval(e,3e4);return()=>clearInterval(o)},[]);const i=s?Object.keys(s.quest_data).sort():[],l=new Set;if(s)for(const e of Object.values(s.quest_data))for(const o of Object.keys(e))l.add(o);const n=Array.from(l).sort();return t.jsx(u,{id:p,title:"Quest Status",zIndex:x,width:780,height:500,children:t.jsx("div",{style:{flex:1,overflow:"auto",fontSize:"0.72rem"},children:s?i.length===0?t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"No quest data available"}):t.jsxs("table",{style:{width:"100%",borderCollapse:"collapse"},children:[t.jsx("thead",{children:t.jsxs("tr",{style:{position:"sticky",top:0,background:"#1a1a1a",zIndex:1},children:[t.jsx("th",{style:{textAlign:"left",padding:"4px 8px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.65rem",fontWeight:600,minWidth:140},children:"Character"}),n.map(e=>t.jsx("th",{style:{textAlign:"center",padding:"4px 6px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.6rem",fontWeight:600,maxWidth:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},title:e,children:e.replace(" Timer","").replace(" Pickup","")},e))]})}),t.jsx("tbody",{children:i.map(e=>{const o=s.quest_data[e]||{};return t.jsxs("tr",{style:{borderBottom:"1px solid #222"},children:[t.jsx("td",{style:{padding:"3px 8px",color:"#ccc",fontWeight:500,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis",maxWidth:160},children:e}),n.map(d=>{const r=o[d],a=r==="READY";return t.jsx("td",{style:{textAlign:"center",padding:"3px 6px",color:a?"#4c4":r?"#ca0":"#333",fontWeight:a?600:400,fontSize:a?"0.7rem":"0.68rem"},children:r||"—"},d)})]},e)})})]}):t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"Loading quest data..."})})})};export{j as QuestStatusWindow};

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
import{r as o,j as t,D as d}from"./index-C-CrqDmq.js";import"./react-yfL0ty4i.js";const c=[{title:"Kills per Hour",id:1},{title:"Memory (MB)",id:2},{title:"CPU (%)",id:3},{title:"Mem Handles",id:4}],m=[{label:"1H",value:"now-1h"},{label:"6H",value:"now-6h"},{label:"24H",value:"now-24h"},{label:"7D",value:"now-7d"}],v=({id:s,charName:a,zIndex:i})=>{const[l,r]=o.useState("now-24h"),n=e=>`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${e}&var-character=${encodeURIComponent(a)}&from=${l}&to=now&theme=light`;return t.jsxs(d,{id:s,title:`Stats: ${a}`,zIndex:i,width:750,height:480,children:[t.jsx("div",{className:"ml-stats-controls",children:m.map(e=>t.jsx("button",{className:`ml-stats-range-btn ${l===e.value?"active":""}`,onClick:()=>r(e.value),children:e.label},e.value))}),t.jsx("div",{className:"ml-stats-grid",children:c.map(e=>t.jsx("div",{className:"ml-stats-panel",children:t.jsx("iframe",{src:n(e.id),width:"100%",height:"100%",frameBorder:"0",title:e.title})},e.id))})]})};export{v as StatsWindow};

View file

@ -1 +0,0 @@
import{r as n,j as t,D as x,a as m}from"./index-C-CrqDmq.js";import"./react-yfL0ty4i.js";const v=({id:o,zIndex:c})=>{const[a,d]=n.useState([]);n.useEffect(()=>{const e=async()=>{try{const s=await m("/vital-sharing/peers");d(s.peers??[])}catch{}};e();const l=setInterval(e,5e3);return()=>clearInterval(l)},[]);const h=(e,l)=>l>0?Math.min(100,e/l*100):0;return t.jsx(x,{id:o,title:"Vital Sharing Network",zIndex:c,width:520,height:450,children:t.jsx("div",{style:{flex:1,overflowY:"auto",padding:6,fontSize:"0.75rem"},children:a.length===0?t.jsx("div",{style:{padding:16,color:"#666",textAlign:"center"},children:"No vital-sharing peers connected"}):a.map(e=>{var l,s,r;return t.jsxs("div",{style:{padding:"6px 8px",marginBottom:4,background:"#1f1f1f",borderRadius:3,border:"1px solid #333"},children:[t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:3},children:[t.jsx("span",{style:{color:e.plugin_connected?"#4c4":"#a33",fontSize:"0.8rem"},children:"●"}),t.jsx("strong",{style:{flex:1},children:e.character_name}),e.subscribed&&t.jsx("span",{style:{color:"#6bf",fontSize:"0.65rem"},children:"[subscribed]"})]}),t.jsxs("div",{style:{color:"#666",fontSize:"0.68rem",marginBottom:3},children:["tags: ",((l=e.tags)==null?void 0:l.join(", "))||"none"]}),e.vitals&&e.vitals.max_health>0&&t.jsx("div",{style:{display:"flex",flexDirection:"column",gap:2},children:[{label:"HP",cur:e.vitals.current_health,max:e.vitals.max_health,bg:"#330000",fill:"#c44"},{label:"STA",cur:e.vitals.current_stamina,max:e.vitals.max_stamina,bg:"#331a00",fill:"#ca0"},{label:"MANA",cur:e.vitals.current_mana,max:e.vitals.max_mana,bg:"#001433",fill:"#48f"}].map(i=>t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:4},children:[t.jsx("span",{style:{width:32,color:"#888",fontSize:"0.65rem"},children:i.label}),t.jsx("div",{style:{flex:1,height:6,background:i.bg,borderRadius:3,overflow:"hidden"},children:t.jsx("div",{style:{width:`${h(i.cur,i.max)}%`,height:"100%",background:i.fill,borderRadius:3}})}),t.jsxs("span",{style:{width:60,textAlign:"right",fontSize:"0.65rem",color:"#888"},children:[i.cur,"/",i.max]})]},i.label))}),e.position&&t.jsxs("div",{style:{color:"#555",fontSize:"0.65rem",marginTop:2},children:[(s=e.position.ns)==null?void 0:s.toFixed(1),"N, ",(r=e.position.ew)==null?void 0:r.toFixed(1),"E"]})]},e.character_name)})})})};export{v as VitalSharingWindow};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,161 +0,0 @@
<!--
Dereth Tracker Single-Page Application
Displays live player locations, trails, and statistics on a map.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dereth Tracker</title>
<!-- Link to main stylesheet -->
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Version display -->
<div id="versionDisplay" style="position:fixed;top:4px;left:4px;z-index:9999;color:#fff;font-size:0.65rem;font-family:monospace;opacity:0.7;"></div>
<!-- Sidebar for active players list and filters -->
<aside id="sidebar">
<h2 id="activePlayersHeader">Active Mosswart Enjoyers</h2>
<!-- Server Status -->
<div id="serverStatus" class="server-status-container">
<h3>Coldeve Server Status</h3>
<div class="status-indicator">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">Checking...</span>
</div>
<div class="status-details">
<div>Players: <span id="playerCount">-</span></div>
<div>Latency: <span id="latencyMs">-</span> ms</div>
<div>Uptime: <span id="uptime">-</span></div>
<div>Last Restart: <span id="lastRestart">-</span></div>
</div>
</div>
<!-- Total rares counter -->
<div id="totalRaresCounter" class="total-rares-counter">
🔥 Total Rares: <span id="totalRaresCount">Loading...</span>
</div>
<!-- Server KPH counter -->
<div id="serverKphCounter" class="server-kph-counter">
⚡ Server KPH: <span id="serverKphCount">Loading...</span>
</div>
<!-- Total kills counter -->
<div id="totalKillsCounter" class="total-kills-counter">
⚔️ Total Kills: <span id="totalKillsCount">Loading...</span>
</div>
<!-- Heat map toggle -->
<div class="heatmap-toggle">
<label>
<input type="checkbox" id="heatmapToggle">
🔥 Show Spawn Heat Map
</label>
</div>
<!-- Portal toggle -->
<div class="portal-toggle">
<label>
<input type="checkbox" id="portalToggle">
🌀 Show Portals
</label>
</div>
<!-- Inventory search link -->
<div class="inventory-search-link">
<a href="#" id="inventorySearchBtn" onclick="openInventorySearch()">
📦 Inventory Search
</a>
</div>
<!-- Suitbuilder link -->
<div class="suitbuilder-link">
<a href="#" id="suitbuilderBtn" onclick="openSuitbuilder()">
🛡️ Suitbuilder
</a>
</div>
<!-- Player Debug link -->
<div class="debug-link">
<a href="#" id="playerDebugBtn" onclick="openPlayerDebug()">
🔍 Player Debug
</a>
</div>
<!-- Quest Status link -->
<div class="quest-status-link">
<a href="#" id="questStatusBtn" onclick="openQuestStatus()">
⏰ Quest Status
</a>
</div>
<!-- Player Dashboard link -->
<div class="player-dashboard-link">
<a href="#" id="playerDashboardBtn" onclick="openPlayerDashboard()">
👥 Player Dashboard
</a>
</div>
<!-- Issues Board link -->
<div class="quest-status-link">
<a href="#" id="issuesBoardBtn" onclick="showIssuesWindow()">
📋 Issues Board
</a>
</div>
<!-- Vital Sharing Network link -->
<div class="quest-status-link">
<a href="#" id="vitalSharingBtn" onclick="showVitalSharingWindow()">
🤝 Vital Sharing
</a>
</div>
<!-- Combat Stats link -->
<div class="quest-status-link">
<a href="#" id="combatStatsBtn" onclick="showCombatStatsWindow()">
Combat Stats
</a>
</div>
<!-- Container for sort and filter controls -->
<div id="sortButtons" class="sort-buttons"></div>
<!-- Text input to filter active players by name -->
<input type="text" id="playerFilter" class="player-filter" placeholder="Filter players..." />
<ul id="playerList"></ul>
<!-- User info section (populated by script.js after /me fetch) -->
<div id="userInfo" class="user-info" style="display:none;">
<span id="currentUsername" class="user-info-name"></span>
<a href="#" id="adminLink" class="user-info-admin" style="display:none;" onclick="window.open('/admin/users','_blank')">Admin</a>
<a href="/logout" class="user-info-logout">Logout</a>
</div>
</aside>
<!-- Epic rare notifications container -->
<div id="rareNotifications" class="rare-notifications"></div>
<!-- Fireworks container -->
<div id="fireworksContainer" class="fireworks-container"></div>
<!-- Main map container showing terrain and player data -->
<div id="mapContainer">
<div id="mapGroup">
<img id="map" src="dereth.png" alt="Dereth map">
<canvas id="heatmapCanvas"></canvas>
<svg id="trails"></svg>
<div id="dots"></div>
<div id="portals"></div>
</div>
<div id="tooltip" class="tooltip"></div>
<div id="coordinates" class="coordinates"></div>
</div>
<!-- Main JavaScript file for WebSocket communication and UI logic -->
<script src="script.js" defer></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -1,739 +0,0 @@
/*
* style-ac.css - Asheron's Call themed styles for Dereth Tracker
*
* Recreates the classic AC UI with stone textures, beveled edges,
* golden accents, and medieval fantasy aesthetics.
*/
/* CSS Custom Properties for AC theme colors and sizing */
:root {
--sidebar-width: 340px;
/* AC Color Palette */
--ac-black: #0a0a0a;
--ac-dark-stone: #1a1a1a;
--ac-medium-stone: #2a2a2a;
--ac-light-stone: #3a3a3a;
--ac-border-dark: #000;
--ac-border-light: #4a4a4a;
--ac-gold: #d4af37;
--ac-gold-bright: #ffd700;
--ac-gold-dark: #b8941f;
--ac-green: #00ff00;
--ac-cyan: #00ffff;
--ac-text: #e0e0e0;
--ac-text-dim: #a0a0a0;
/* Backgrounds */
--bg-main: var(--ac-black);
--bg-side: var(--ac-dark-stone);
--card: var(--ac-medium-stone);
--card-hov: var(--ac-light-stone);
--text: var(--ac-text);
--accent: var(--ac-gold);
}
/* Placeholder text in chat input */
.chat-input::placeholder {
color: var(--ac-text-dim);
opacity: 0.7;
}
html {
margin: 0;
height: 100%;
width: 100%;
}
body {
margin: 0;
height: 100%;
display: flex;
overflow: hidden;
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
background: var(--bg-main);
color: var(--text);
background-image:
repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
rgba(255, 255, 255, 0.01) 10px,
rgba(255, 255, 255, 0.01) 20px
);
}
/* AC-style stone textured panels with beveled edges */
.ac-panel {
background: linear-gradient(135deg, var(--ac-medium-stone) 0%, var(--ac-dark-stone) 100%);
border: 2px solid var(--ac-border-dark);
box-shadow:
inset 2px 2px 3px rgba(255, 255, 255, 0.1),
inset -2px -2px 3px rgba(0, 0, 0, 0.5),
0 2px 5px rgba(0, 0, 0, 0.8);
border-radius: 0;
}
/* Sort buttons - AC style */
.sort-buttons {
display: flex;
gap: 3px;
margin: 12px 16px 8px;
padding: 8px;
background: var(--ac-dark-stone);
border: 1px solid var(--ac-border-dark);
box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.5);
}
.sort-buttons .btn {
flex: 1;
padding: 5px 8px;
background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-medium-stone) 100%);
color: var(--ac-text-dim);
border: 1px solid var(--ac-border-dark);
border-radius: 2px;
text-align: center;
cursor: pointer;
user-select: none;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.15s;
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.1);
}
.sort-buttons .btn:hover {
background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-light-stone) 100%);
color: var(--ac-gold-bright);
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.2),
0 0 5px rgba(212, 175, 55, 0.3);
}
.sort-buttons .btn:active {
box-shadow:
inset 2px 2px 3px rgba(0, 0, 0, 0.7),
inset -1px -1px 2px rgba(255, 255, 255, 0.1);
}
.sort-buttons .btn.active {
background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%);
color: var(--ac-black);
border-color: var(--ac-gold-dark);
font-weight: 700;
position: relative;
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.3),
0 0 10px rgba(212, 175, 55, 0.5);
}
.sort-buttons .btn.active:hover {
background: linear-gradient(180deg, var(--ac-gold-bright) 0%, var(--ac-gold) 100%);
color: var(--ac-black);
}
/* Sort direction indicators */
.sort-buttons .btn.active::after {
content: '';
position: absolute;
top: 3px;
right: 3px;
width: 0;
height: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
}
/* Most sorts are descending (down arrow) */
.sort-buttons .btn.active::after {
border-top: 4px solid var(--ac-black);
}
/* Name and KPR are ascending (up arrow) */
.sort-buttons .btn.active[data-value="name"]::after,
.sort-buttons .btn.active[data-value="kpr"]::after {
border-top: none;
border-bottom: 4px solid var(--ac-black);
}
/* Sidebar - AC stone panel style */
#sidebar {
width: var(--sidebar-width);
scrollbar-width: thin;
scrollbar-color: var(--ac-gold-dark) var(--ac-dark-stone);
background: linear-gradient(180deg, var(--ac-dark-stone) 0%, var(--ac-black) 100%);
border-right: 3px solid var(--ac-border-dark);
box-shadow:
inset -2px 0 5px rgba(0, 0, 0, 0.5),
2px 0 5px rgba(0, 0, 0, 0.8);
box-sizing: border-box;
padding: 18px 16px;
overflow-y: auto;
}
#sidebar h2 {
margin: 8px 0 12px;
font-size: 1.25rem;
color: var(--ac-gold);
text-shadow:
2px 2px 3px rgba(0, 0, 0, 0.8),
0 0 10px rgba(212, 175, 55, 0.3);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
}
#playerList {
list-style: none;
margin: 0;
padding: 0;
}
/* Filter input - AC style */
.player-filter {
width: 100%;
padding: 6px 10px;
margin-bottom: 12px;
background: var(--ac-dark-stone);
color: var(--ac-gold);
border: 2px solid var(--ac-border-dark);
border-radius: 2px;
font-size: 0.9rem;
box-sizing: border-box;
box-shadow:
inset 2px 2px 3px rgba(0, 0, 0, 0.5),
inset -1px -1px 2px rgba(255, 255, 255, 0.05);
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
}
.player-filter:focus {
outline: none;
border-color: var(--ac-gold-dark);
box-shadow:
inset 2px 2px 3px rgba(0, 0, 0, 0.5),
0 0 5px rgba(212, 175, 55, 0.5);
}
/* Map container */
#mapContainer {
flex: 1;
position: relative;
overflow: hidden;
background: var(--bg-main);
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.8);
}
/* Player list items - AC stone panels */
#playerList li {
display: grid;
grid-template-columns: 1fr auto auto auto auto auto;
grid-template-rows: auto auto auto auto;
grid-template-areas:
"name name name name name name"
"kills totalkills kph kph kph kph"
"rares kpr meta meta meta meta"
"onlinetime deaths tapers tapers tapers tapers";
gap: 4px 8px;
margin: 6px 0;
padding: 10px 12px;
background: linear-gradient(135deg, var(--ac-medium-stone) 0%, var(--ac-dark-stone) 100%);
border: 2px solid var(--ac-border-dark);
border-left: 4px solid var(--ac-gold-dark);
transition: all 0.2s;
box-shadow:
1px 1px 3px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.05);
}
/* Grid assignments */
.player-name {
grid-area: name;
font-weight: 700;
color: var(--ac-gold);
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
.coordinates-inline {
font-size: 0.75rem;
color: var(--ac-text-dim);
font-weight: 400;
margin-left: 8px;
}
.stat.kills { grid-area: kills; }
.stat.total-kills { grid-area: totalkills; }
.stat.kph { grid-area: kph; }
.stat.rares { grid-area: rares; }
.stat.kpr { grid-area: kpr; }
.stat.meta { grid-area: meta; }
.stat.onlinetime { grid-area: onlinetime; }
.stat.deaths { grid-area: deaths; }
.stat.tapers { grid-area: tapers; }
/* Stat pills - AC style */
#playerList li .stat {
background: linear-gradient(180deg, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.5) 100%);
padding: 4px 8px;
border-radius: 2px;
display: inline-block;
font-size: 0.75rem;
white-space: nowrap;
color: var(--ac-text);
border: 1px solid rgba(0, 0, 0, 0.5);
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.3),
inset 1px 1px 1px rgba(255, 255, 255, 0.05);
}
/* Icons & suffixes */
.stat.kills::before { content: "⚔️ "; }
.stat.total-kills::before { content: "🏆 "; }
.stat.kph::after { content: " KPH"; font-size:0.7em; color: var(--ac-text-dim); }
.stat.rares::before { content: "💎 "; }
.stat.rares::after { content: " Rares"; font-size:0.7em; color: var(--ac-text-dim); }
.stat.kpr::before { content: "📊 "; }
.stat.kpr::after { content: " KPR"; font-size:0.7em; color: var(--ac-text-dim); }
/* Metastate pills */
#playerList li .stat.meta {
background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%);
color: var(--ac-black);
border-color: var(--ac-gold-dark);
}
#playerList li .stat.meta.green {
background: linear-gradient(180deg, #4ade80 0%, #22c55e 100%);
color: var(--ac-black);
border-color: #16a34a;
}
#playerList li .stat.meta.red {
background: linear-gradient(180deg, #f87171 0%, #ef4444 100%);
color: #fff;
border-color: #dc2626;
}
/* Chat/Stats/Inventory buttons - AC style */
.chat-btn, .stats-btn, .inventory-btn {
margin-top: 4px;
margin-right: 4px;
padding: 3px 8px;
background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%);
color: var(--ac-black);
border: 1px solid var(--ac-gold-dark);
border-radius: 2px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 1px rgba(255, 255, 255, 0.3);
transition: all 0.15s;
}
.chat-btn:hover, .stats-btn:hover, .inventory-btn:hover {
background: linear-gradient(180deg, var(--ac-gold-bright) 0%, var(--ac-gold) 100%);
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 1px rgba(255, 255, 255, 0.4),
0 0 5px rgba(212, 175, 55, 0.5);
}
/* Windows - AC stone panel style */
.chat-window, .stats-window, .inventory-window {
position: absolute;
top: 10px;
left: calc(var(--sidebar-width) + 10px);
width: 760px;
height: 300px;
background: linear-gradient(135deg, var(--ac-medium-stone) 0%, var(--ac-dark-stone) 100%);
border: 3px solid var(--ac-border-dark);
display: flex;
flex-direction: column;
z-index: 10000;
box-shadow:
0 5px 20px rgba(0, 0, 0, 0.8),
inset 2px 2px 3px rgba(255, 255, 255, 0.05);
border-radius: 2px;
}
.window-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-medium-stone) 100%);
border-bottom: 2px solid var(--ac-border-dark);
color: var(--ac-gold);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.5);
}
.chat-close-btn {
background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%);
color: #fff;
border: 1px solid #991b1b;
border-radius: 2px;
padding: 2px 8px;
font-size: 1rem;
cursor: pointer;
font-weight: 700;
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 1px rgba(255, 255, 255, 0.3);
}
.chat-close-btn:hover {
background: linear-gradient(180deg, #f87171 0%, #ef4444 100%);
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 1px rgba(255, 255, 255, 0.4),
0 0 5px rgba(239, 68, 68, 0.5);
}
.chat-messages {
flex: 1;
padding: 10px 15px;
overflow-y: auto;
background: rgba(0, 0, 0, 0.3);
color: var(--ac-green);
font-family: "Courier New", Courier, monospace;
font-size: 0.9rem;
line-height: 1.4;
box-shadow: inset 2px 2px 5px rgba(0, 0, 0, 0.5);
}
.chat-messages::-webkit-scrollbar {
width: 10px;
}
.chat-messages::-webkit-scrollbar-track {
background: var(--ac-dark-stone);
box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.5);
}
.chat-messages::-webkit-scrollbar-thumb {
background: var(--ac-gold-dark);
border-radius: 2px;
box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
}
.chat-form {
display: flex;
padding: 10px 15px;
background: var(--ac-dark-stone);
border-top: 2px solid var(--ac-border-dark);
box-shadow: 0 -2px 3px rgba(0, 0, 0, 0.5);
}
.chat-input {
flex: 1;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.5);
color: var(--ac-green);
border: 2px solid var(--ac-border-dark);
border-radius: 2px;
font-family: "Courier New", Courier, monospace;
font-size: 0.9rem;
box-shadow: inset 2px 2px 3px rgba(0, 0, 0, 0.5);
}
.chat-input:focus {
outline: none;
border-color: var(--ac-gold-dark);
box-shadow:
inset 2px 2px 3px rgba(0, 0, 0, 0.5),
0 0 5px rgba(212, 175, 55, 0.3);
}
/* Map elements */
#mapGroup {
transform-origin: top left;
position: relative;
}
#map {
display: block;
user-select: none;
-webkit-user-drag: none;
filter: brightness(0.9) contrast(1.1);
}
.dot {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: auto;
cursor: pointer;
z-index: 10;
box-shadow:
0 0 5px rgba(0, 0, 0, 0.8),
0 0 10px currentColor;
border: 1px solid rgba(0, 0, 0, 0.5);
}
.dot.highlight {
animation: pulse 2s infinite;
z-index: 20;
}
@keyframes pulse {
0% { box-shadow: 0 0 5px rgba(0, 0, 0, 0.8), 0 0 10px currentColor; }
50% { box-shadow: 0 0 10px rgba(0, 0, 0, 0.8), 0 0 20px currentColor, 0 0 30px currentColor; }
100% { box-shadow: 0 0 5px rgba(0, 0, 0, 0.8), 0 0 10px currentColor; }
}
/* Tooltip - AC style */
.tooltip {
position: absolute;
display: none;
background: linear-gradient(135deg, rgba(26, 26, 26, 0.95) 0%, rgba(10, 10, 10, 0.95) 100%);
color: var(--ac-gold);
padding: 6px 10px;
border: 2px solid var(--ac-gold-dark);
border-radius: 2px;
font-size: 0.8rem;
pointer-events: none;
white-space: nowrap;
z-index: 1000;
box-shadow:
0 2px 10px rgba(0, 0, 0, 0.8),
inset 1px 1px 2px rgba(255, 255, 255, 0.1);
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
/* Coordinate display - AC style */
.coordinates {
position: absolute;
display: none;
background: linear-gradient(135deg, rgba(0, 50, 100, 0.95) 0%, rgba(0, 30, 60, 0.95) 100%);
color: var(--ac-cyan);
padding: 4px 8px;
border: 2px solid rgba(0, 100, 150, 0.8);
border-radius: 2px;
font-size: 0.75rem;
font-family: "Courier New", Courier, monospace;
font-weight: 700;
pointer-events: none;
white-space: nowrap;
z-index: 999;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.8),
inset 1px 1px 2px rgba(255, 255, 255, 0.1);
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
/* Hover states */
#playerList li:hover {
background: linear-gradient(135deg, var(--ac-light-stone) 0%, var(--ac-medium-stone) 100%);
border-left-color: var(--ac-gold-bright);
box-shadow:
2px 2px 5px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.1),
0 0 10px rgba(212, 175, 55, 0.2);
}
#playerList li.selected {
background: linear-gradient(135deg, var(--ac-gold-dark) 0%, var(--ac-medium-stone) 100%);
border-left-color: var(--ac-gold-bright);
box-shadow:
2px 2px 5px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.2),
0 0 15px rgba(212, 175, 55, 0.3);
}
/* Trail paths */
#trails {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
opacity: 0.7;
}
.trail-path {
stroke-width: 2;
stroke-opacity: 0.8;
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.8));
}
/* Stats window specific */
.stats-window {
height: auto;
}
.stats-window .chat-messages {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: auto;
gap: 10px;
padding: 10px;
overflow: visible;
background: var(--ac-dark-stone);
color: var(--ac-text);
}
.stats-window iframe {
width: 350px;
height: 200px;
border: 2px solid var(--ac-border-dark);
box-shadow:
inset 2px 2px 3px rgba(0, 0, 0, 0.5),
1px 1px 2px rgba(0, 0, 0, 0.3);
background: var(--ac-black);
}
/* Stats time controls - AC style */
.stats-controls {
display: flex;
gap: 8px;
padding: 10px 15px;
background: var(--ac-medium-stone);
border-bottom: 2px solid var(--ac-border-dark);
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.5);
}
.time-range-btn {
padding: 6px 12px;
background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-medium-stone) 100%);
color: var(--ac-text-dim);
border: 1px solid var(--ac-border-dark);
border-radius: 2px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.1);
}
.time-range-btn:hover {
background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-light-stone) 100%);
color: var(--ac-gold);
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.2),
0 0 5px rgba(212, 175, 55, 0.2);
}
.time-range-btn.active {
background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%);
color: var(--ac-black);
border-color: var(--ac-gold-dark);
font-weight: 700;
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.3),
0 0 10px rgba(212, 175, 55, 0.4);
}
/* Inventory window */
.inventory-content {
flex: 1;
padding: 15px;
background: var(--ac-dark-stone);
color: var(--ac-text);
overflow-y: auto;
box-shadow: inset 2px 2px 5px rgba(0, 0, 0, 0.5);
}
.inventory-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 1.1rem;
color: var(--ac-text-dim);
font-style: italic;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
.stat.onlinetime::before { content: "🕑 "; }
.stat.deaths::before { content: "💀 "; }
.stat.tapers::before {
content: "";
display: inline-block;
width: 16px;
height: 16px;
background-image: url('prismatic-taper-icon.png');
background-size: contain;
background-repeat: no-repeat;
margin-right: 4px;
vertical-align: text-bottom;
}
/* Disable text selection during drag */
.noselect {
user-select: none !important;
}
/* Custom scrollbar for sidebar */
#sidebar::-webkit-scrollbar {
width: 12px;
}
#sidebar::-webkit-scrollbar-track {
background: var(--ac-dark-stone);
box-shadow: inset 2px 2px 3px rgba(0, 0, 0, 0.5);
}
#sidebar::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%);
border-radius: 2px;
border: 1px solid var(--ac-gold-dark);
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 1px rgba(255, 255, 255, 0.3);
}
#sidebar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, var(--ac-gold-bright) 0%, var(--ac-gold) 100%);
}
/* Map container special effects */
#mapContainer.dragging {
cursor: move;
}
/* Additional hover effects */
.player-item {
position: relative;
overflow: hidden;
}
.player-item::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent 0%, rgba(212, 175, 55, 0.2) 50%, transparent 100%);
transition: left 0.5s;
}
.player-item:hover::before {
left: 100%;
}

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

File diff suppressed because one or more lines are too long

View file

@ -1,18 +1,130 @@
<!--
Dereth Tracker Single-Page Application
Displays live player locations, trails, and statistics on a map.
-->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Dereth Tracker</title>
<title>Mosswart Overlord v2</title> <!-- Link to main stylesheet -->
<link rel="icon" type="image/png" href="/icons/7735.png" /> <link rel="stylesheet" href="style.css">
<link rel="preload" as="image" href="/dereth.png" /> </head>
<link rel="preload" as="image" href="/icons/0600127E.png" /> <body>
<link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" />
<script type="module" crossorigin src="/assets/index-C-CrqDmq.js"></script> <!-- Sidebar for active players list and filters -->
<link rel="modulepreload" crossorigin href="/assets/react-yfL0ty4i.js"> <aside id="sidebar">
<link rel="stylesheet" crossorigin href="/assets/index-C28HcMMD.css"> <h2 id="activePlayersHeader">Active Mosswart Enjoyers</h2>
</head>
<body> <!-- Server Status -->
<div id="root"></div> <div id="serverStatus" class="server-status-container">
</body> <h3>Coldeve Server Status</h3>
<div class="status-indicator">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">Checking...</span>
</div>
<div class="status-details">
<div>Players: <span id="playerCount">-</span></div>
<div>Latency: <span id="latencyMs">-</span> ms</div>
<div>Uptime: <span id="uptime">-</span></div>
<div>Last Restart: <span id="lastRestart">-</span></div>
</div>
</div>
<!-- Total rares counter -->
<div id="totalRaresCounter" class="total-rares-counter">
🔥 Total Rares: <span id="totalRaresCount">Loading...</span>
</div>
<!-- Server KPH counter -->
<div id="serverKphCounter" class="server-kph-counter">
⚡ Server KPH: <span id="serverKphCount">Loading...</span>
</div>
<!-- Total kills counter -->
<div id="totalKillsCounter" class="total-kills-counter">
⚔️ Total Kills: <span id="totalKillsCount">Loading...</span>
</div>
<!-- Heat map toggle -->
<div class="heatmap-toggle">
<label>
<input type="checkbox" id="heatmapToggle">
🔥 Show Spawn Heat Map
</label>
</div>
<!-- Portal toggle -->
<div class="portal-toggle">
<label>
<input type="checkbox" id="portalToggle">
🌀 Show Portals
</label>
</div>
<!-- Inventory search link -->
<div class="inventory-search-link">
<a href="#" id="inventorySearchBtn" onclick="openInventorySearch()">
📦 Inventory Search
</a>
</div>
<!-- Suitbuilder link -->
<div class="suitbuilder-link">
<a href="#" id="suitbuilderBtn" onclick="openSuitbuilder()">
🛡️ Suitbuilder
</a>
</div>
<!-- Player Debug link -->
<div class="debug-link">
<a href="#" id="playerDebugBtn" onclick="openPlayerDebug()">
🔍 Player Debug
</a>
</div>
<!-- Quest Status link -->
<div class="quest-status-link">
<a href="#" id="questStatusBtn" onclick="openQuestStatus()">
⏰ Quest Status
</a>
</div>
<!-- Player Dashboard link -->
<div class="player-dashboard-link">
<a href="#" id="playerDashboardBtn" onclick="openPlayerDashboard()">
👥 Player Dashboard
</a>
</div>
<!-- Container for sort and filter controls -->
<div id="sortButtons" class="sort-buttons"></div>
<!-- Text input to filter active players by name -->
<input type="text" id="playerFilter" class="player-filter" placeholder="Filter players..." />
<ul id="playerList"></ul>
</aside>
<!-- Epic rare notifications container -->
<div id="rareNotifications" class="rare-notifications"></div>
<!-- Fireworks container -->
<div id="fireworksContainer" class="fireworks-container"></div>
<!-- Main map container showing terrain and player data -->
<div id="mapContainer">
<div id="mapGroup">
<img id="map" src="dereth.png" alt="Dereth map">
<canvas id="heatmapCanvas"></canvas>
<svg id="trails"></svg>
<div id="dots"></div>
<div id="portals"></div>
</div>
<div id="tooltip" class="tooltip"></div>
<div id="coordinates" class="coordinates"></div>
</div>
<!-- Main JavaScript file for WebSocket communication and UI logic -->
<script src="script.js" defer></script>
</body>
</html> </html>

Some files were not shown because too many files have changed in this diff Show more