Compare commits

..

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

203 changed files with 5764 additions and 40585 deletions

16
.gitignore vendored
View file

@ -1,18 +1,2 @@
.venv
__pycache__
static/v2/
frontend/node_modules/
# Secrets — the server-side env files hold SHARED_SECRET, SECRET_KEY, DB
# passwords, and the Discord token. This repo is PUBLIC — never commit them.
# .env.example stays tracked as the template.
.env
.env.bak-*
# 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/
.superpowers/

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`
- Trails endpoint: `GET /trails`
- Plugin WS endpoint: `/ws/position` (authenticated via X-Plugin-Secret)
- Browser WS endpoint: `/ws/live` (session-cookie authenticated; internal Docker-network clients trusted by IP)
- 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.

256
CLAUDE.md
View file

@ -1,186 +1,140 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Cross-repo workflows (plugin coupling, deploy commands, nginx) live in the workspace-level `../CLAUDE.md` — read that too for any deploy or protocol change.
## Project Overview
Dereth Tracker is a real-time telemetry platform for Asheron's Call world tracking. **The production backend is Go** (`go-services/`): a tracker service (`tracker-go/`) ingests player data from the MosswartMassacre DECAL plugin over `/ws/position`, serves the React dashboard + login/admin + the read API, and writes TimescaleDB; an inventory service (`inventory-go/`) handles item search, the suitbuilder solver, and inventory ingestion. Plus Grafana, a (Python) Discord rare bot, and a host-side Claude-powered assistant.
Dereth Tracker is a real-time telemetry service for game world tracking. It's a FastAPI-based WebSocket and HTTP API service that ingests player position/stats data via plugins and provides live map visualization through a web interface.
The original Python/FastAPI implementation (`main.py` ~4200 lines, `inventory-service/`) is preserved on the **`python-legacy`** branch; the Go services were validated byte-identical against it in a parallel "strangler-fig" run, then production was cut over. ⚠ **The behavioral contracts below (WS, auth, DB, routes, suitbuilder) describe what Go honors. Where they cite `main.py` / `inventory-service/`, that's the legacy source that defined the contract — the live implementation is the corresponding Go handler.**
## Key Components
## Components
### Main Service (main.py)
- WebSocket endpoint `/ws/position` receives telemetry and inventory events
- Routes inventory events to inventory service via HTTP
- Handles real-time player tracking and map updates
| Component | Where | Runs as |
|---|---|---|
| **Tracker** (ingest + website + read API + WS) | `go-services/tracker-go/` | Docker `dereth-tracker-go`, 127.0.0.1:8770 |
| **Inventory** (search + suitbuilder + ingestion) | `go-services/inventory-go/` | Docker `inventory-go`, 127.0.0.1:8772 |
| Telemetry DB (TimescaleDB) | schema in `tracker-go/schema.go` (replica of legacy `db_async.py`) | Docker `dereth-db`, port 5432 |
| Inventory DB | schema in `inventory-go/schema.go` | Docker `inventory-db`, 5433 |
| React frontend | `frontend/` → built into `static/` | served by `tracker-go` (static file server, SPA fallback) |
| Classic v1 / legacy pages | `static/classic/`, `static/*.html` | served by `tracker-go` |
| Grafana | compose service `dereth-grafana` | 127.0.0.1:3000, anonymous Viewer auth, proxied at `/grafana/` |
| Discord rare bot | `discord-rare-monitor/` (Python) | Docker, reads the Go `/ws/live` |
| Overlord Agent (assistant) | `agent/` | **host-side systemd service** `overlord-agent`, 127.0.0.1:8767 |
### Inventory Service (inventory-service/main.py)
- Separate FastAPI service for inventory management
- Processes inventory JSON into normalized PostgreSQL tables
- Provides search API with advanced filtering and sorting
- Uses comprehensive enum database for translating game IDs to readable names
### Go services — build, deploy, gotchas
### Database Architecture
- **Telemetry DB**: TimescaleDB for time-series player tracking data
- **Inventory DB**: PostgreSQL with normalized schema for equipment data
- `items`: Core item properties
- `item_combat_stats`: Armor level, damage bonuses
- `item_enhancements`: Material, item sets, tinkering
- `item_spells`: Spell names and categories
- `item_raw_data`: Original JSON for complex queries
- **Build on the server, no host Go needed** (multi-stage distroless images). Go 1.25, `pgx/v5`, `coder/websocket`, `bwmarrin/discordgo`, `x/crypto/bcrypt`. Sync + build + recreate:
```bash
tar czf - go-services | ssh erik@overlord.snakedesert.se "tar xzf - -C /home/erik/MosswartOverlord/"
ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \
export BUILD_VERSION="$(date -u +%Y.%-m.%-d.%H%M)-$(git rev-parse --short HEAD)" && \
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml build dereth-tracker-go inventory-go && \
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml -f go-services/docker-compose.cutover.yml \
up -d --no-deps dereth-tracker-go inventory-go'
```
- **`docker-compose.cutover.yml`** is what makes the Go services production: `READ_ONLY=false` (write the prod DBs), `SKIP_SCHEMA_INIT=true` (trust the existing schema, run NO DDL), `SHARED_SECRET`/`DISCORD_ACLOG_WEBHOOK` for the tracker, and the Discord bot repointed at `ws://dereth-tracker-go:8770/ws/live`. Drop it to revert to read-only parallel mode.
- **Rollback** = `docker compose ... up -d` WITHOUT the cutover override (Go → read-only) + start the Python `dereth-tracker`/`inventory-service` + revert the nginx `http://tracker_go/` lines to `http://tracker/`.
- ⚠ **Plugin sends some numeric fields as STRINGS** (`kills_per_hour`, `deaths`, `total_deaths`, `prismatic_taper_count`). Go coerces via `coerceNum` (`tracker-go/reads.go`) — pydantic did this implicitly; a plain number cast would write null/0.
- ⚠ **Telemetry must be broadcast TYPELESS** to `/ws/live` (`stripType` in `tracker-go/ingest.go`). The browser ignores typeless messages and uses the 5 s `/live` poll for player data; broadcasting telemetry WITH a type makes the UI overwrite the /live-derived counters and flap them 0↔value.
- ⚠ `inventory-go` `slot_names=Trinket` must exclude `%bracelet%` or bracelets duplicate the Wrist buckets in the suitbuilder.
## Memories and Known Bugs
## WebSocket endpoints
* Fixed: Material names now properly display (e.g., "Gold Celdon Girth" instead of "Unknown_Material_Gold Celdon Girth")
* Fixed: Slot column shows "-" instead of "Unknown" for items without slot data
* Fixed: All 208 items in Larsson's inventory now process successfully (was 186 with 22 SQL type errors)
* Added: Type column in inventory search using object_classes enum for accurate item type classification
* Note: ItemType data is inconsistent in JSON - using ObjectClass as primary source for Type column
- `/ws/position` — plugin ingest (telemetry, inventory, portal, rare, combat, share_*, …). Authenticated by `X-Plugin-Secret` header against the `SHARED_SECRET` env var; fails closed (refuses all plugins) when unset or left at the old placeholder. Constant-time compare.
- `/ws/live` — browser clients: session-cookie authenticated. Accepts `subscribe`, `request_dungeon_map`, and `{player_name, command}` envelopes forwarded to the matching plugin socket.
- Internal-trust rule (AuthMiddleware + `/ws/live`): a request is "internal" only when its source IP is private/loopback AND it has **no `X-Forwarded-For` header**. nginx sets XFF on every proxied request, so internet traffic can never qualify; host-side callers (overlord-agent → 127.0.0.1:8765) and compose-network services (discord bot) do. INVARIANT: every nginx location that proxies to the tracker MUST set `X-Forwarded-For` (documented in nginx/overlord.conf) — forgetting it would silently bypass session auth.
## Recent Fixes (September 2025)
## Auth & users
### Portal Coordinate Rounding Fix ✅ RESOLVED
* **Problem**: Portal insertion failed with duplicate key errors due to coordinate rounding mismatch
* **Root Cause**: Code used 2 decimal places (`ROUND(ns::numeric, 2)`) but database constraint used 1 decimal place
* **Solution**: Changed all portal coordinate checks to use 1 decimal place to match DB constraint
* **Result**: 98% reduction in duplicate key errors (from 600+/min to ~11/min)
* **Location**: `main.py` lines ~1989, 1996, 2025, 2047
- Session cookies signed with `SECRET_KEY` (itsdangerous, 30-day expiry); login at `/login`, user CRUD at `/api-admin/users` (admin-only), `/me` returns the current user.
- Users live in the `users` table (bcrypt). `seed_users()` seeds initial accounts only when the table is empty.
- The agent service (`agent/auth.py`) verifies the same cookie with the same `SECRET_KEY` — keep them identical.
### Character Display Issues ✅ RESOLVED
* **Problem**: Some characters (e.g., "Crazed n Dazed") not appearing in frontend
* **Root Cause**: Database connection pool exhaustion from portal error spam
* **Solution**: Fixed portal errors to reduce database load
* **Result**: Characters now display correctly after portal fix
## Database
### Docker Container Deployment
* **Issue**: Code changes require container rebuild with `--no-cache` flag
* **Command**: `docker compose build --no-cache dereth-tracker`
* **Reason**: Docker layer caching can prevent updated source code from being copied
- **Two separate Postgres databases**: telemetry (`dereth` on TimescaleDB, container `dereth-db`) and inventory (`inventory_db` on plain postgres:14, container `inventory-db`).
- **Schema source of truth is code, not migrations**: `db_async.py` table metadata + `metadata.create_all()` + ad-hoc `IF NOT EXISTS` DDL in `init_db_async()`. Alembic is configured but `alembic/versions/` is empty — `create_all()` never ALTERs existing tables, so **adding a column to db_async.py requires a manual `ALTER TABLE` on the live DB**.
- Hypertables: `telemetry_events` (retention via `DB_RETENTION_DAYS`, default 7 days in code) and `spawn_events` (7 days). Both confirmed hypertables on the live DB with active retention jobs.
- ⚠ Known divergence: live `portals` unique index uses `ROUND(...,1)` (matches the `ON CONFLICT` in main.py), but `db_async.py` creates `ROUND(...,2)` on fresh databases — a fresh install breaks portal upserts until aligned.
- Connection pool: `min_size=5, max_size=100, command_timeout=120` (`db_async.py:21`). Postgres `max_connections` is the default 100, shared with Grafana and the agent's read-only role — don't widen the pool further.
- Persisted event types: telemetry, spawn, rare, portal, character_stats, combat_stats. Everything else (vitals, quest, cantrips, nearby_objects, dungeon_map, share_*) is memory-only.
- Read-only agent role `overlord_agent_ro` is provisioned manually via `agent/sql/0001_overlord_agent_ro.sql` (SELECT-only).
- Backups: nightly cron on the host runs `scripts/backup-databases.sh` (pg_dump both DBs to `/home/erik/backups/postgres/`, 7-day retention; telemetry/spawn hypertable data deliberately excluded). Restore procedure: `docs/backups.md` — TimescaleDB needs `timescaledb_pre_restore()/post_restore()`.
- `db.py` is a dead legacy SQLite layer — nothing imports it. All persistence goes through `db_async.py`.
## Current Known Issues
## Route conventions
### Minor Portal Race Conditions
* **Status**: ~11 duplicate key errors per minute (down from 600+)
* **Cause**: Multiple players discovering same portal simultaneously
* **Impact**: Minimal - errors are caught and handled gracefully
* **Handling**: Try/catch in code logs as debug messages and updates portal timestamp
* **Potential Fix**: PostgreSQL ON CONFLICT DO UPDATE (upsert pattern) would eliminate completely
- nginx strips `/api/` before proxying, so backend routes must NOT start with `/api/`.
- Routes that need to bypass the strip are hyphen-named on purpose: `/api-version`, `/api-admin/...` (they fall through nginx's `location /`).
- The static SPA is mounted last (`app.mount('/', NoCacheStaticFiles(...), html=True)`), so unmatched paths serve `static/`.
- `/inv/*` is a catch-all HTTP proxy to the inventory service; `/api/agent/*` is proxied by nginx (not the tracker) to the host-side agent.
### Database Initialization Warnings
* **TimescaleDB Hypertable**: `telemetry_events` fails to become hypertable due to primary key constraint
* **Impact**: None - table works as regular PostgreSQL table
* **Warning**: "cannot create a unique index without the column 'timestamp'"
## Frontend
### Connection Pool Under Load
* **Issue**: Database queries can timeout when connection pool is exhausted
* **Symptom**: Characters may not appear during high error load
* **Mitigation**: Portal error fix significantly reduced this issue
- Source: `frontend/` (React 19 + Vite + TypeScript). Built output goes to `static/_build/`, then `deploy-frontend.sh` copies it into `static/`**running `bash deploy-frontend.sh` alone is the complete build+deploy flow** (it runs `npm run build` itself).
- Local dev: `cd frontend && npm run dev` (port 5173, `/api` proxied to localhost:8765).
- The React app's WebSocket URL is `/api/ws/live` (goes through nginx `location /api/`); the classic frontend uses `/ws/live` (through `location /`).
- Window components are routed by id prefix in `WindowRenderer.tsx`: `{prefix}-{charName}` (chat|stats|char|inv|radar|combat|combatpicker|issues|vitalsharing|queststatus|playerdash|agent|adminusers).
- `?view=dashboard` renders the fullscreen Player Dashboard (own tab, own WS connection per tab — by design).
- Map positions update from the 5 s `/live` HTTP poll; backend telemetry broadcasts have no `type` field so the WS telemetry branch in the frontend is inert.
## Equipment Suit Builder
## Suitbuilder
### Status: PRODUCTION READY
Production equipment-optimization engine, ported to Go in `inventory-go/suit_*.go` (constraint-satisfaction DFS: multi-character search, armor set constraints, cantrip overlap, SSE streaming) — validated byte-identical against the legacy `inventory-service/suitbuilder.py`. Live endpoint: `POST /suitbuilder/search` (the tracker proxies `/inv/suitbuilder/search`); the `/optimize/*` solver in the legacy `inventory-service/main.py` was a near-duplicate and is NOT the live path. UI at `/suitbuilder.html`. Known limitations: no slot-aware spell filtering, equal spell weighting.
Real-time equipment optimization engine for building optimal character loadouts by searching across multiple characters' inventories (mules). Uses Mag-SuitBuilder constraint satisfaction algorithms.
## Deploying
**Core Features:**
- Multi-character inventory search across 100+ characters, 25,000+ items
- Armor set constraints (primary 5-piece + secondary 4-piece set support)
- Cantrip/ward spell optimization with bitmap-based overlap detection
- Crit damage rating optimization
- Locked slots with set/spell preservation across searches
- Real-time SSE streaming with progressive phase updates
- Suit summary with copy-to-clipboard functionality
- Stable deterministic sorting for reproducible results
- **Go backend changes** → see "Go services — build, deploy, gotchas" above (sync `go-services/`, build, recreate with the cutover override). `BUILD_VERSION` (CalVer `YYYY.M.D.HHMM-gitshorthash`) shows in the frontend sidebar.
- **Frontend**`bash deploy-frontend.sh` (complete build+copy into `static/`); the tracker serves `static/` from a bind mount, no restart needed.
- **Overlord Agent** → unchanged (host-side Python systemd): `git pull && sudo systemctl restart overlord-agent`.
- `README.md` has the full build/run reference. The legacy Python deploy lives on the `python-legacy` branch.
**Access:** `/suitbuilder.html`
## Operational notes
**Architecture Details:** See `docs/plans/2026-02-09-suitbuilder-architecture.md`
- Discord: rare bot posts rares + relays allegiance chat; **death/idle alerts come from the backend** via `DISCORD_ACLOG_WEBHOOK` (`_idle_detection_loop` in main.py).
- Issues board persists to a flat file `static/openissues.json` (web-served, bind-mounted).
- Server status (Coldeve) is polled via UDP every 30 s; TreeStats player count every 5 min.
- Debugging: `docker logs mosswartoverlord-dereth-tracker-1`, `docker logs dereth-db`. Read-only psql: `docker exec dereth-db psql -U postgres -d dereth`.
- This repo is **public** on git.snakedesert.se — never commit secrets (a Grafana token was leaked & removed June 2026; nginx `/grafana/` works via anonymous Viewer auth, no token needed). Grafana's container state DB is ephemeral (no volume) — don't create service accounts expecting them to persist.
### Known Limitations
- Slot-aware spell filtering not yet implemented (e.g., underclothes have limited spell pools but system treats all slots equally)
- All spells weighted equally (no priority/importance weighting yet)
- See architecture doc for future enhancement roadmap
---
## Technical Notes for Development
## Overlord Assistant Mode
### Database Performance
- Connection pool: 5-20 connections (configured in `db_async.py`)
- Under heavy error load, pool exhaustion can cause 2-minute query timeouts
- Portal error fix significantly improved database performance
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.
### Docker Development Workflow
1. **Code Changes**: Edit source files locally
2. **Rebuild**: `docker compose build --no-cache dereth-tracker` (required for code changes)
3. **Deploy**: `docker compose up -d dereth-tracker`
4. **Debug**: `docker logs mosswartoverlord-dereth-tracker-1` and `docker logs dereth-db`
### Non-negotiable scope rules — read carefully
### Frontend Architecture
- **Main Map**: `static/index.html` - Real-time player tracking
- **Inventory Search**: `static/inventory.html` - Advanced item filtering
- **Suitbuilder**: `static/suitbuilder.html` - Equipment optimization interface
- **All static files**: Served directly by FastAPI StaticFiles
You are a **read-only game-state question-answering service**. Nothing more.
### DOM Optimization Status ✅ COMPLETE (September 2025)
* **Achievement**: 100% DOM element reuse with zero element creation after initial render
* **Performance**: ~5ms render time for 69 players, eliminated 4,140+ elements/minute creation
* **Implementation**: Element pooling system with player name mapping for O(1) lookup
* **Monitoring**: Color-coded console output (✨ green = optimized, ⚡ yellow = partial, 🔥 red = poor)
* **Status**: Production ready - achieving perfect element reuse consistently
- ✅ 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.
**Current Render Stats**:
- ✅ This render: 0 dots created, 69 reused | 0 list items created, 69 reused
- ✅ Lifetime: 69 dots created, 800+ reused | 69 list items created, 800+ reused
**You have MCP tools** (from `.mcp.json`) for live game data. **Always use them** instead of guessing or apologising for not having data:
**Remaining TODO**:
- ❌ Fix CSS Grid layout for player sidebar (deferred per user request)
- ❌ Extend optimization to trails and portal rendering
- ❌ Add memory usage tracking
- `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.
### WebSocket Endpoints
- `/ws/position`: Plugin telemetry, inventory, portal, rare events (authenticated)
- `/ws/live`: Browser client commands and live updates (unauthenticated)

View file

@ -16,9 +16,7 @@ RUN python -m pip install --upgrade pip && \
sqlalchemy \
alembic \
psycopg2-binary \
httpx \
bcrypt \
itsdangerous
httpx
## Copy application source code and migration scripts into container
COPY static/ /app/static/
@ -31,19 +29,14 @@ COPY Dockerfile /Dockerfile
## Expose the application port to host
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
## NOTE: no SHARED_SECRET default here on purpose — main.py fails closed
## (refuses plugin connections) unless a real value arrives via compose/.env.
ENV DATABASE_URL=postgresql://postgres:password@db:5432/dereth \
DB_MAX_SIZE_MB=2048 \
DB_RETENTION_DAYS=7 \
DB_MAX_SQL_LENGTH=1000000000 \
DB_MAX_SQL_VARIABLES=32766 \
DB_WAL_AUTOCHECKPOINT_PAGES=1000
DB_WAL_AUTOCHECKPOINT_PAGES=1000 \
SHARED_SECRET=your_shared_secret
## 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"]

483
README.md
View file

@ -1,155 +1,412 @@
# Mosswart Overlord (Dereth Tracker)
# Dereth Tracker
Real-time telemetry, inventory, and analytics platform for Asheron's Call —
driven by a firehose of WebSocket events from the companion
[MosswartMassacre](https://github.com/SawatoMosswartsEnjoyersClub/MosswartMassacre)
DECAL plugin running on 60+ characters.
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.
**The production backend is written in Go** (`go-services/`). It replaced the
original Python/FastAPI implementation via a strangler-fig migration: the Go
services ran in parallel against live traffic until every endpoint was proven
byte-identical, then production was cut over. The Python implementation is
preserved on the `python-legacy` branch.
## Table of Contents
- [Overview](#overview)
- [Features](#features)
- [Requirements](#requirements)
- [Installation](#installation)
- [Configuration](#configuration)
- [Usage](#usage)
- [API Reference](#api-reference)
- [Frontend](#frontend)
- [Database Schema](#database-schema)
- [Contributing](#contributing)
---
## Overview
## Architecture
This project provides:
- A FastAPI backend with endpoints for receiving and querying telemetry data.
- PostgreSQL/TimescaleDB-based storage for time-series telemetry and per-character stats.
- A live, interactive map using static HTML, CSS, and JavaScript.
- 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 plugin ──wss──> nginx ──> Go tracker (tracker-go) ──> dereth (TimescaleDB)
(60+ game clients) │ │
│ ├──HTTP──> Go inventory (inventory-go) ──> inventory_db
Browsers ──https──────────────────> nginx │
│ └──/ws/live──> Discord rare bot (relays rares + chat)
└──> Grafana (/grafana/) death/idle alerts → Discord webhook
```
## Features
| Component | Path | Runs as | Notes |
|---|---|---|---|
| **Tracker** (ingest + website + read API + WS) | `go-services/tracker-go/` | Docker `dereth-tracker-go`, 127.0.0.1:8770 | serves the React frontend, login/admin, the plugin `/ws/position`, browser `/ws/live`, and the full read API; writes the `dereth` DB |
| **Inventory** (search + suitbuilder + ingestion) | `go-services/inventory-go/` | Docker `inventory-go`, 127.0.0.1:8772 | normalized item search, the suitbuilder solver (SSE), inventory ingestion; writes `inventory_db` |
| Telemetry DB | TimescaleDB | Docker `dereth-db`, 5432 | hypertables `telemetry_events`, `spawn_events` |
| Inventory DB | postgres:14 | Docker `inventory-db`, 5433 | 7-table normalized item schema |
| React frontend | `frontend/``static/` | served by `tracker-go` | unchanged by the migration — same paths, same API |
| Classic v1 / legacy pages | `static/classic/`, `static/*.html` | served by `tracker-go` | `/classic`, `/suitbuilder.html`; inventory search is now the React page at `/?view=inventory` |
| Grafana | compose `dereth-grafana` | 127.0.0.1:3000 | anonymous Viewer auth, proxied at `/grafana/` |
| Discord rare bot | `discord-rare-monitor/` (Python) | Docker, reads Go `/ws/live` | posts rares + relays allegiance chat |
| Overlord Agent (assistant) | `agent/` | host-side systemd `overlord-agent`, 127.0.0.1:8767 | shells out to `claude -p`; outside Docker by design |
- **WebSocket /ws/position**: Stream telemetry snapshots and inventory updates (protected by a shared secret).
- **GET /live**: Fetch active players seen in the last 30 seconds.
- **GET /history**: Retrieve historical telemetry data with optional time filtering.
- **GET /debug**: Health check endpoint.
- **Live Map**: Interactive map interface with panning, zooming, and sorting.
- **Inventory Management**:
- Real-time inventory updates via WebSocket on character login/logout
- Advanced search across all character inventories
- Filter by character, equipment type, material, stats, and more
- Sort by any column with live results
- Track item properties including spells, armor level, damage ratings
- **Suitbuilder**:
- Equipment optimization across multiple character inventories
- Constraint-based search for optimal armor combinations
- Support for primary and secondary armor sets
- Real-time streaming results during long-running searches
- **Portal Tracking**:
- Automatic discovery and tracking of in-game portals
- 1-hour retention for discovered portals
- Coordinate-based uniqueness (rounded to 0.1 precision)
- Real-time portal updates on the map interface
- **Discord Rare Monitor Bot**: Monitors rare discoveries and posts filtered notifications to Discord channels
- **Sample Data Generator**: `generate_data.py` sends telemetry snapshots over WebSocket for testing.
**Stack:** Go 1.25 (stdlib `net/http` with 1.22 method+path routing, `pgx/v5`,
`coder/websocket`, `bwmarrin/discordgo`, `golang.org/x/crypto/bcrypt`), distroless
multi-stage images. React 19 + Vite + TypeScript. PostgreSQL/TimescaleDB. nginx
reverse proxy (host-side). Unlike the old single-worker Python service, the Go
tracker uses `GOMAXPROCS` = all available cores, so traffic bursts parallelize
instead of bottlenecking on one core.
## Requirements
---
- Python 3.9 or newer (only if running without Docker)
- pip (only if running without Docker)
- Docker & Docker Compose (recommended)
## Build & run
Python packages (if using local virtualenv):
Everything builds and runs in Docker — **no host Go toolchain needed** (the
multi-stage images compile from source). The production stack is the base compose
(databases, Grafana, Discord bot) plus two override files for the Go services and
the cutover wiring.
- fastapi
- uvicorn
- pydantic
- databases
- asyncpg
- sqlalchemy
- websockets # required for sample data generator
## Installation
1. Clone the repository:
```bash
git clone https://github.com/yourusername/dereth-tracker.git
cd dereth-tracker
```
2. Create and activate a virtual environment:
```bash
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
```
3. Install dependencies:
```bash
pip install fastapi uvicorn pydantic websockets
```
## Configuration
- 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`.
## Usage
### Using Docker (Recommended)
1. Build and start all services:
```bash
docker compose up -d
```
2. Rebuild container after code changes:
```bash
docker compose build --no-cache dereth-tracker
docker compose up -d dereth-tracker
```
3. View logs:
```bash
docker logs mosswartoverlord-dereth-tracker-1
docker logs dereth-db
```
### Without Docker
Start the server using Uvicorn:
```bash
# --- build the Go service images ---
export BUILD_VERSION="$(date -u +%Y.%-m.%-d.%H%M)-$(git rev-parse --short HEAD)"
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml \
build dereth-tracker-go inventory-go
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
# Grafana Dashboard UI
```nginx
location /grafana/ {
# Optional: require basic auth on the Grafana UI
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
# --- production: Go services in write mode, serving the site + ingest ---
docker compose -f docker-compose.yml \
-f go-services/docker-compose.go.yml \
-f go-services/docker-compose.cutover.yml \
up -d --no-deps dereth-tracker-go inventory-go
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>
```
- `docker-compose.go.yml` defines the Go services (plus the isolated shadow DBs used during the parallel run).
- `docker-compose.cutover.yml` flips the Go services to **write mode** against the production DBs (`READ_ONLY=false`, `SKIP_SCHEMA_INIT=true` so they run no DDL and trust the existing schema) and points the Discord bot at the Go `/ws/live`. Drop this file to return the Go services to read-only parallel mode.
- `BUILD_VERSION` is shown in the frontend sidebar (CalVer: `YYYY.M.D.HHMM-gitshorthash`).
- Required env (server `.env`, **never committed**): `SHARED_SECRET`, `SECRET_KEY`, `POSTGRES_PASSWORD`, `INVENTORY_DB_PASSWORD`, `DISCORD_ACLOG_WEBHOOK`, `DISCORD_RARE_BOT_TOKEN`, the Discord channel IDs, and Grafana admin. See `.env.example`.
After connecting, send JSON messages matching the `TelemetrySnapshot` schema. For example:
### Frontend (unchanged by the migration)
```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"
}
```
The React app and the legacy static pages call the same absolute paths
(`/api/...`, `/inv/...`, `/live`, …) — the Go tracker answers them, so the
frontend ships as-is.
### Chat messages
You can also send chat envelopes over the same WebSocket to display messages in the browser. Fields:
- `type`: must be "chat"
- `character_name`: target player name
- `text`: message content
- `color` (optional): CSS color string (e.g. "#ff8800"); if sent as an integer (0xRRGGBB), it will be converted to hex.
```bash
cd frontend && npm run dev # local dev, port 5173, /api → :8770
bash deploy-frontend.sh # complete build + copy into static/ (runs npm run build itself)
Example chat payload:
```json
{
"type": "chat",
"character_name": "MyCharacter",
"text": "Hello world!",
"color": "#88f"
}
```
The tracker serves `static/` directly (bind-mounted), so static/JS/CSS changes
need no restart. ⚠️ `npm run build` writes to `static/_build/`; only
`deploy-frontend.sh` copies it into the served `static/`.
## Event Payload Formats
### nginx
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:
- **Telemetry events** (`type`: "telemetry")
- **Spawn events** (`type`: "spawn")
- **Chat events** (`type`: "chat")
- **Rare events** (`type`: "rare")
- **Inventory events** (`type`: "inventory")
The live config is host-side at `/etc/nginx/sites-enabled/overlord` (source copy
in `nginx/overlord.conf`); the `tracker_go` upstream is in
`/etc/nginx/conf.d/tracker_go.conf` (`server 127.0.0.1:8770;`). Production routes
`/`, `/api/`, `/websocket/` to the Go tracker. Every location that proxies to the
tracker **must** set `X-Forwarded-For` — it drives the internal-trust auth rule.
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.
### Overlord Agent
Each entry shows all required and optional fields, their types, and example values.
Unchanged by the migration — it's a host-side Python systemd service. Code change:
`git pull && sudo systemctl restart overlord-agent`. Its env lives separately at
`/etc/overlord/agent.env`. See `agent/` and `CLAUDE.md`.
### GET /live
Returns active players seen within the last 30 seconds:
---
```json
{
"players": [ { ... } ]
}
```
## WebSocket contract
### GET /history
Retrieve historical snapshots with optional `from` and `to` ISO8601 timestamps:
- **`/ws/position`** — plugin → backend. Telemetry, vitals, inventory, portal, rare, combat, quest, chat, share_*, … Authenticated by the `X-Plugin-Secret` header against `SHARED_SECRET` (constant-time; fails closed when unset). The tracker forwards inventory to `inventory-go`, accumulates kill/combat stats, and re-broadcasts to browsers.
- **`/ws/live`** — browser ↔ backend. Session-cookie (or internal-trust) authenticated. Accepts `subscribe`, `request_dungeon_map`, and `{player_name, command}` envelopes routed to the matching plugin socket. **Telemetry is broadcast typeless** so the browser ignores it and takes player data from the 5 s `/live` poll (matching the original design — broadcasting it typed flaps the per-player counters).
- **Internal-trust rule:** a request skips cookie auth only when its source is private/loopback **and** carries no `X-Forwarded-For`. nginx sets XFF on all internet traffic, so only host-side / compose-network callers qualify.
```
GET /history?from=2025-04-22T12:00:00Z&to=2025-04-22T13:00:00Z
```
### Payload note
Response:
Payloads are snake_case JSON; keep field names and shapes stable across plugin +
backend. The plugin sends several numeric telemetry fields as **strings**
(`kills_per_hour`, `deaths`, `total_deaths`, `prismatic_taper_count`) — the backend
coerces them (`coerceNum` in `tracker-go/reads.go`).
```json
{
"data": [ { ... } ]
}
```
## Auth & users
## Frontend
Session cookies are signed with `SECRET_KEY` via an itsdangerous-compatible
`URLSafeTimedSerializer` (HMAC-SHA1, 30-day expiry) — cookies interoperate with
the legacy Python service. Login at `/login` (bcrypt against the `users` table),
admin user CRUD at `/api-admin/users`, current user at `/me`.
- **Live Map**: `static/index.html` Real-time player positions on a map.
- **Inventory Search**: `static/inventory.html` Search and browse character inventories with advanced filtering.
## Databases
## Database Schema
Two separate Postgres databases, both schema-from-code:
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.
- **`dereth`** (TimescaleDB, `dereth-db`): hypertables `telemetry_events` + `spawn_events`, plus `char_stats`, `combat_stats(_sessions)`, `rare_*`, `portals`, `character_stats`, `users`. Persisted event types: telemetry, spawn, rare, portal, character_stats, combat_stats. Everything else (vitals, quest, cantrips, nearby_objects, dungeon_map, share_*) is memory-only.
- **`inventory_db`** (postgres:14, `inventory-db`): 7 normalized tables (`items` + combat/requirements/enhancements/ratings/spells/raw_data).
### Telemetry Database Tables:
In cutover mode the Go services reuse these production databases directly; the
shadow DBs in `docker-compose.go.yml` exist only for isolated parallel-run
validation. **Backups:** `pg_dump -Fc` of both DBs; TimescaleDB restore needs
`timescaledb_pre_restore()` / `post_restore()` around `pg_restore`.
- **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)
## Route conventions
- **char_stats**:
- `character_name` (text, PK)
- `total_kills` (integer)
- nginx strips `/api/` before proxying, so backend routes do **not** start with `/api/`.
- Hyphenated routes (`/api-version`, `/api-admin/...`) deliberately bypass the strip (they fall through nginx's `location /`).
- The static SPA is the catch-all (`GET /`), registered after the API routes, with `index.html` fallback for client-side routing.
- `/inv/*` reverse-proxies to the inventory service; `/api/agent/*` is proxied by nginx (not the tracker) to the host-side agent.
- **rare_stats**:
- `character_name` (text, PK)
- `total_rares` (integer)
## Operational notes
- **rare_stats_sessions**:
- `character_name`, `session_id` (composite PK)
- `session_rares` (integer)
- Discord: the rare bot posts rares + relays allegiance chat; **death/idle alerts come from the tracker itself** via `DISCORD_ACLOG_WEBHOOK`.
- Issue board persists to the flat file `static/openissues.json` (web-served, mounted read-write).
- Logs: `docker logs dereth-tracker-go`, `docker logs inventory-go`. Read-only psql: `docker exec dereth-db psql -U postgres -d dereth`, `docker exec inventory-db psql -U inventory_user -d inventory_db`.
- **This repo is PUBLIC** on git.snakedesert.se — never commit secrets. `.env` is gitignored; `.env.example` is the template.
- **spawn_events**:
- `id` (PK, serial)
- `character_name` (text)
- `mob` (text)
- `timestamp` (timestamptz)
- `ew`, `ns`, `z` (float)
## Branches
- **rare_events**:
- `id` (PK, serial)
- `character_name` (text)
- `name` (text)
- `timestamp` (timestamptz)
- `ew`, `ns`, `z` (float)
- **`master`** — the Go production backend (this).
- **`python-legacy`** — the original Python/FastAPI implementation, preserved for reference and rollback.
- **portals**:
- `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)`
See [`CLAUDE.md`](CLAUDE.md) for contributor/agent guidance and deeper internals.
### Inventory Database Tables:
- **items**:
- `id` (PK, serial)
- `character_name` (text, indexed)
- `item_id` (bigint)
- `name` (text)
- `object_class` (integer)
- `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
Contributions are welcome! Feel free to open issues or submit pull requests.
## 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,58 +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 — and fail closed like it does: starting with a known
# default key would let anyone forge a valid session cookie.
SECRET_KEY = os.getenv("SECRET_KEY", "")
if not SECRET_KEY or SECRET_KEY == "change-me-in-production-please":
raise RuntimeError(
"SECRET_KEY env var must be set (shared with dereth-tracker; see "
"/etc/overlord/agent.env) — refusing to start with a forgeable "
"session-signing key"
)
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,115 +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). REQUIRED (no leading '-'):
# a missing secrets file must abort startup, not fail open — auth.py also
# refuses to start without SECRET_KEY.
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
initialization function to set up TimescaleDB hypertable.
"""
import os
import sqlalchemy
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 Index, BigInteger, JSON, Boolean, UniqueConstraint
from sqlalchemy.sql import func
import bcrypt as _bcrypt
# Environment: Postgres/TimescaleDB connection URL
DATABASE_URL = os.getenv(
"DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth"
)
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth")
# Async database client with explicit connection pool configuration and query timeout
database = Database(DATABASE_URL, min_size=5, max_size=100, command_timeout=120)
# Metadata for SQLAlchemy Core
@ -49,17 +45,12 @@ telemetry_events = Table(
Column("cpu_pct", Float, nullable=True),
Column("mem_handles", Integer, nullable=True),
Column("latency_ms", Float, nullable=True),
# Server-side receive time. The `timestamp` column above is the CLIENT's
# self-reported wall clock and drifts up to ~90s across machines, so the
# "online" window must use this server-stamped value instead (see /live
# cache query). Nullable so pre-migration rows fall back to `timestamp`.
Column("received_at", DateTime(timezone=True), nullable=True),
)
# Composite index to accelerate Grafana queries filtering by character_name then ordering by timestamp
Index(
"ix_telemetry_events_char_ts",
'ix_telemetry_events_char_ts',
telemetry_events.c.character_name,
telemetry_events.c.timestamp,
telemetry_events.c.timestamp
)
# Table for persistent total kills per character
@ -88,26 +79,6 @@ rare_stats_sessions = Table(
Column("session_id", String, primary_key=True),
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
spawn_events = Table(
# Records individual mob spawn occurrences for heatmap and analysis
@ -170,8 +141,20 @@ portals = Table(
Column("discovered_by", String, nullable=False),
)
# Server health monitoring: only current state is kept.
# Historical health checks were removed — nothing read from them.
# Server health monitoring tables
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(
# Current server status and uptime tracking
"server_status",
@ -186,13 +169,18 @@ server_status = Table(
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",
metadata,
Column("character_name", String, primary_key=True, nullable=False),
Column(
"timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()
),
Column("timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("level", Integer, nullable=True),
Column("total_xp", BigInteger, nullable=True),
Column("unassigned_xp", BigInteger, nullable=True),
@ -202,20 +190,6 @@ character_stats = Table(
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():
"""Initialize PostgreSQL/TimescaleDB schema and hypertable.
@ -238,12 +212,10 @@ async def init_db_async():
print(f"Warning: failed to create extension timescaledb: {e}")
# Convert to hypertable, migrating existing data and skipping default index creation
try:
conn.execute(
text(
"SELECT create_hypertable('telemetry_events', 'timestamp', "
"if_not_exists => true, migrate_data => true, create_default_indexes => false)"
)
)
conn.execute(text(
"SELECT create_hypertable('telemetry_events', 'timestamp', "
"if_not_exists => true, migrate_data => true, create_default_indexes => false)"
))
except Exception as e:
print(f"Warning: failed to create hypertable telemetry_events: {e}")
except Exception as e:
@ -251,94 +223,44 @@ async def init_db_async():
# Ensure composite index exists for efficient time-series queries by character
try:
with engine.connect() as conn:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_telemetry_events_char_ts "
"ON telemetry_events (character_name, timestamp)"
)
)
conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_telemetry_events_char_ts "
"ON telemetry_events (character_name, timestamp)"
))
except Exception as e:
print(
f"Warning: failed to create composite index ix_telemetry_events_char_ts: {e}"
)
# Add the server-receive-time column to existing deployments (idempotent).
# Used as the clock-skew-proof basis for the "online" window in /live.
try:
with engine.connect() as conn:
conn.execute(
text(
"ALTER TABLE telemetry_events "
"ADD COLUMN IF NOT EXISTS received_at TIMESTAMPTZ"
)
)
except Exception as e:
print(f"Warning: failed to add telemetry_events.received_at column: {e}")
print(f"Warning: failed to create composite index ix_telemetry_events_char_ts: {e}")
# Add retention and compression policies on the hypertable
try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
# Retain only recent data (default 7 days or override via DB_RETENTION_DAYS)
days = int(os.getenv("DB_RETENTION_DAYS", "7"))
conn.execute(
text(
f"SELECT add_retention_policy('telemetry_events', INTERVAL '{days} days')"
)
)
days = int(os.getenv('DB_RETENTION_DAYS', '7'))
conn.execute(text(
f"SELECT add_retention_policy('telemetry_events', INTERVAL '{days} days')"
))
# Compress chunks older than 1 day
conn.execute(
text(
"SELECT add_compression_policy('telemetry_events', INTERVAL '1 day')"
)
)
conn.execute(text(
"SELECT add_compression_policy('telemetry_events', INTERVAL '1 day')"
))
except Exception as 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
try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
# Drop old portal_discoveries table if it exists
conn.execute(text("DROP TABLE IF EXISTS portal_discoveries CASCADE"))
# Create unique constraint on rounded coordinates for the new portals table
conn.execute(
text(
"""CREATE UNIQUE INDEX IF NOT EXISTS unique_portal_coords
conn.execute(text(
"""CREATE UNIQUE INDEX IF NOT EXISTS unique_portal_coords
ON portals (ROUND(ns::numeric, 2), ROUND(ew::numeric, 2))"""
)
)
))
# Create index on coordinates for efficient lookups
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_portals_coords ON portals (ns, ew)"
)
)
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_portals_coords ON portals (ns, ew)"
))
print("Portal table indexes and constraints created successfully")
except Exception as e:
print(f"Warning: failed to create portal table constraints: {e}")
@ -346,8 +268,7 @@ async def init_db_async():
# Ensure character_stats table exists with JSONB column type
try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
conn.execute(
text("""
conn.execute(text("""
CREATE TABLE IF NOT EXISTS character_stats (
character_name VARCHAR(255) PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@ -359,60 +280,25 @@ async def init_db_async():
deaths INTEGER,
stats_data JSONB NOT NULL
)
""")
)
"""))
print("character_stats table created/verified successfully")
except Exception as e:
print(f"Warning: failed to create character_stats table: {e}")
async def cleanup_old_portals():
"""Clean up portals older than 1 hour."""
try:
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=1)
# Delete old portals
result = await database.execute(
"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")
return result
except Exception as e:
print(f"Warning: failed to cleanup old portals: {e}")
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}")
return 0

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

@ -34,6 +34,7 @@ logger = logging.getLogger(__name__)
# Configuration from environment variables
DISCORD_TOKEN = os.getenv('DISCORD_RARE_BOT_TOKEN')
WEBSOCKET_URL = os.getenv('DERETH_TRACKER_WS_URL', 'ws://dereth-tracker:8765/ws/live')
SHARED_SECRET = 'your_shared_secret'
ACLOG_CHANNEL_ID = int(os.getenv('ACLOG_CHANNEL_ID', '1349649482786275328'))
COMMON_RARE_CHANNEL_ID = int(os.getenv('COMMON_RARE_CHANNEL_ID', '1355328792184226014'))
GREAT_RARE_CHANNEL_ID = int(os.getenv('GREAT_RARE_CHANNEL_ID', '1353676584334131211'))
@ -292,15 +293,7 @@ class DiscordRareMonitor:
# Send connection established message
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
try:
message_count = 0

View file

@ -26,13 +26,8 @@ services:
DB_MAX_SQL_VARIABLES: "${DB_MAX_SQL_VARIABLES}"
DB_WAL_AUTOCHECKPOINT_PAGES: "${DB_WAL_AUTOCHECKPOINT_PAGES}"
SHARED_SECRET: "${SHARED_SECRET}"
# Optional second secret accepted during plugin migration — remove
# from .env after rollout (see main.py SHARED_SECRET_LEGACY).
SHARED_SECRET_LEGACY: "${SHARED_SECRET_LEGACY:-}"
SECRET_KEY: "${SECRET_KEY}"
LOG_LEVEL: "DEBUG"
INVENTORY_SERVICE_URL: "http://inventory-service:8000"
DISCORD_ACLOG_WEBHOOK: "${DISCORD_ACLOG_WEBHOOK:-}"
LOG_LEVEL: "INFO"
restart: unless-stopped
logging:
driver: "json-file"
@ -44,19 +39,6 @@ services:
db:
image: timescale/timescaledb:2.19.3-pg14
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:
POSTGRES_DB: dereth
POSTGRES_USER: postgres
@ -65,11 +47,7 @@ services:
volumes:
- timescale-data:/var/lib/postgresql/data
ports:
# Loopback only — Docker-published ports bypass ufw, and this host is
# internet-facing (active brute-force on the open port observed June
# 2026). In-stack consumers use the compose network; host-side tools
# (psql, overlord-agent) use 127.0.0.1.
- "127.0.0.1:5432:5432"
- "5432:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
@ -92,7 +70,7 @@ services:
- "./inventory-service:/app"
environment:
DATABASE_URL: "postgresql://inventory_user:${INVENTORY_DB_PASSWORD}@inventory-db:5432/inventory_db"
LOG_LEVEL: "INFO"
LOG_LEVEL: "DEBUG"
restart: unless-stopped
logging:
driver: "json-file"
@ -111,8 +89,7 @@ services:
volumes:
- inventory-data:/var/lib/postgresql/data
ports:
# Loopback only — see db service note.
- "127.0.0.1:5433:5432"
- "5433:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U inventory_user"]

View file

@ -1,102 +0,0 @@
# Database backups
Nightly logical backups of both databases, taken by
[`scripts/backup-databases.sh`](../scripts/backup-databases.sh) via a cron
job on the live host (user `erik`, who is in the `docker` group — no sudo
needed). Install with:
```
mkdir -p /home/erik/backups # MUST exist before the first run —
# cron opens the log redirect before
# the script's own mkdir executes
crontab -e # add the line below
15 3 * * * bash /home/erik/MosswartOverlord/scripts/backup-databases.sh >> /home/erik/backups/backup.log 2>&1
```
Dumps land in `/home/erik/backups/postgres/` as `dereth-YYYYMMDD-HHMM.dump`
and `inventory-YYYYMMDD-HHMM.dump` (pg_dump custom format, compressed,
mode 0600). Retention: ~8 days of dailies (`-mtime +7`), pruned by the
script itself only after a successful run. The nightly `backup.log` will
contain pg_dump circular-FK warnings about hypertable chunks — those are
normal; the canary to watch is the printed dump sizes (a healthy dereth
dump is ~50 MB, and the script aborts if it drops below 10 MB).
## What is and isn't included
- **dereth** (TimescaleDB): everything EXCEPT the row data of the
`telemetry_events` and `spawn_events` hypertables (their chunk data in
`_timescaledb_internal._hyper_*` is excluded). That data is ~12 GB and
expires through retention policies within 730 days anyway. The
irreplaceable tables — `users`, `char_stats`, `rare_stats`,
`rare_stats_sessions`, `rare_events`, `combat_stats`,
`combat_stats_sessions`, `portals`, `character_stats`, `server_status`
are fully included. Table *schemas* for the excluded hypertables are
still dumped, so a restore recreates them empty.
- **inventory_db**: full dump (items, combat stats, enhancements, spells,
requirements, ratings, raw JSON).
⚠ The `_timescaledb_internal._hyper_*` exclusion drops the chunk data of
**every** hypertable, present and future. If an irreplaceable table is ever
converted to a hypertable (or a continuous aggregate is added), revisit the
exclusion list — otherwise its data silently disappears from backups.
## Off-host copies (recommended, not yet automated)
The dumps live on the same disk as the databases. Sync them off-host
periodically, e.g. from another machine:
```
rsync -av erik@overlord.snakedesert.se:backups/postgres/ ./overlord-backups/
```
## Restore
### inventory_db (plain Postgres)
```bash
docker exec -i inventory-db pg_restore -U inventory_user -d inventory_db --clean --if-exists < inventory-<stamp>.dump
```
### dereth (TimescaleDB — needs pre/post restore calls)
TimescaleDB requires putting the extension into restore mode around the
`pg_restore`, otherwise catalog rows fail:
```bash
# 1. Create a fresh DB (or use --clean against the existing one)
docker exec dereth-db psql -U postgres -c "CREATE DATABASE dereth_restore;"
docker exec dereth-db psql -U postgres -d dereth_restore -c "CREATE EXTENSION IF NOT EXISTS timescaledb;"
# 2. Pre-restore mode
docker exec dereth-db psql -U postgres -d dereth_restore -c "SELECT timescaledb_pre_restore();"
# 3. Restore the dump
docker exec -i dereth-db pg_restore -U postgres -d dereth_restore --no-owner < dereth-<stamp>.dump
# 4. Post-restore mode (re-enables background workers, validates catalog)
docker exec dereth-db psql -U postgres -d dereth_restore -c "SELECT timescaledb_post_restore();"
```
Notes:
- Step 3 reports one ignorable error — the dump's `CREATE EXTENSION
timescaledb` collides with the extension pre-created in step 1
("already exists", `errors ignored on restore: 1`). That is expected,
not a failed restore.
- The TimescaleDB **version** at restore time must be the **same** as at
dump time (restore first, then `ALTER EXTENSION timescaledb UPDATE` if
upgrading). Same-container restores with the image pinned in
docker-compose.yml (`timescale/timescaledb:2.19.3-pg14`) are fine.
Then either point `DATABASE_URL` at the restored DB or rename databases.
The `telemetry_events`/`spawn_events` hypertables come back empty (by
design); retention/compression policies are part of the dump and reattach.
## Verifying a backup
```bash
pg_restore --list dereth-<stamp>.dump | head # table of contents
pg_restore --list dereth-<stamp>.dump | grep -c 'TABLE DATA'
```
A dump that suddenly shrinks dramatically (check `backup.log` sizes) is the
canary for silent failure.

View file

@ -1,73 +0,0 @@
# Fresh-session prompt: rewrite the Overlord backend in Go (parallel run)
Paste everything below the line into a new Claude Code session started in
`C:\Users\erikn\source\repos\dereth-workspace`.
---
You are starting a side project: **rewrite the MosswartOverlord backend (currently Python/FastAPI) in Go**, and **deploy it in parallel with the live Python service** so we can compare them on identical real traffic before cutting over. This is a strangler-fig migration, not a big-bang rewrite — the live Python service must keep running untouched the entire time.
## Read these first (do not skip)
- `C:\Users\erikn\source\repos\dereth-workspace\CLAUDE.md` — cross-repo overview, WebSocket event families, deploy, nginx, SSH.
- `MosswartOverlord\CLAUDE.md` — backend specifics: components, WS endpoints + auth, DB, route conventions, deploy.
- `MosswartOverlord\README.md` — HTTP API reference and architecture.
- `MosswartOverlord\main.py` (~4200 lines) — the de-facto spec. The Pydantic models in it ARE the WebSocket payload schema. `db_async.py` is the DB schema (there are no alembic migrations; schema lives in code + idempotent DDL in `init_db_async`).
- `MosswartOverlord\nginx\overlord.conf` — reverse-proxy layout.
## What the system is (one paragraph)
"Dereth Tracker" ingests real-time telemetry from ~70 Asheron's Call game clients (a C# DECAL plugin, `MosswartMassacre`) over a WebSocket, persists to PostgreSQL/TimescaleDB, and serves a React dashboard (live map, player sidebar, stats, inventory search). A separate `inventory-service` (FastAPI + its own Postgres) handles item data. There's also a Discord rare bot and a host-side `overlord-agent` (shells out to `claude` — leave that alone).
## Why Go (the actual motivation — don't lose sight of it)
The Python service runs a **single uvicorn worker / single asyncio event loop**, so it's capped at one CPU core and can't use the host's other cores (in-memory state — plugin connections, live snapshots — prevents multi-worker). Under load it saturated that core (telemetry processing lagged, the dashboard flickered). Go's value here is **true multicore concurrency** (goroutines + shared state via `sync`/channels) plus ~1050× cheaper per-message work. The win is the concurrency model, not raw speed — this is an I/O-bound service, so design for correctness and parallelism, not micro-optimization.
## Scope
**In scope — rewrite in Go. Three separate services, each independently deployable and parallel-testable:**
1. **discord-rare-monitor** (`discord-rare-monitor/`) — **do this FIRST as the Go warm-up; it's the smallest and most isolated.** A Discord bot that connects to the tracker's `/ws/live` (subscribes to `rare`/`chat`), classifies rares (the ~71-name common-rares list → common vs great channel), posts embeds to Discord, and relays allegiance chat. In Go: a `coder/websocket` client + `bwmarrin/discordgo`. Parallel test: run the Go copy against the same `/ws/live` but pointed at a **TEST Discord channel** (so it doesn't double-post to the real ones), and compare its output to the Python bot's.
2. **inventory-service** (`inventory-service/`) — a separate FastAPI app with its **own Postgres** (`inventory_db`, container `inventory-db`, port 5433). Receives inventory payloads over HTTP from the tracker (`POST /inventory/{char}/item`, `/process-inventory`), does item **enum translation** (`comprehensive_enum_database_v2.json`) + DB writes, and serves item search + the **suitbuilder constraint solver** (`suitbuilder.py` — the heaviest piece; port carefully and validate against the Python solver's results). In Go: `net/http` + `pgx`. Parallel test: Go copy on a separate port with its own DB (or read-only against the same one); have the tracker tee inventory forwards to it; diff outputs.
3. **Main tracker** (`main.py`) — the big one, do last: WS ingest `/ws/position`, browser WS `/ws/live`, the HTTP read API (`/live`, `/trails`, `/stats/*`, `/total-rares`, `/total-kills`, `/character-stats/*`, `/quest-status`, …), the 5s `/live` cache loop, persistence to TimescaleDB, and serving the React `static/` bundle. Follow the phased parallel-run plan below.
**Suggested order:** (1) discord bot → (2) tracker read-side (Phase 1 below) → (3) inventory-service → (4) tracker ingest + cutover. The three services can also progress somewhat independently.
**Out of scope (keep as-is):**
- The **React frontend** (`frontend/`) — it stays; the Go tracker serves the same built `static/` bundle and implements the same API/WS contract. No frontend changes should be needed if the contract matches.
- The **overlord-agent** (host-side, shells to `claude`) — leave in Python.
- The **DECAL plugin** — do NOT change it. Go must speak the existing wire protocol.
- The **databases themselves** — Go reuses the same PostgreSQL/TimescaleDB and inventory Postgres.
## The parallel-run plan (this is the core of the project)
Run Go as a **new container in the same docker-compose stack**, on a new loopback port (e.g. `127.0.0.1:8770`), reachable via a **separate nginx path** (e.g. `https://overlord.snakedesert.se/go/`) so it's testable side-by-side with the live Python app. Phases:
**Phase 0 — scaffold.** New Go module in a new directory (suggest `MosswartOverlord/go-tracker/` or a sibling repo `MosswartOverlord-go/`). Dockerfile, compose service `dereth-tracker-go` (loopback-bound), nginx `location /go/`. Health endpoint. Deploy it doing nothing useful yet, confirm the plumbing.
**Phase 1 — read-side parity (zero risk, do this first).** Go connects **read-only** to the existing `dereth` TimescaleDB and reimplements the HTTP read API + serves the React bundle. Then **compare Go vs Python on identical data**: hit `https://.../live` (Python) and `https://.../go/live` (Go) and diff the JSON. They should match (semantically). This validates the read/serve half — which is most of the user-facing behavior — without touching ingest. Build a small comparison script and iterate until they match.
**Phase 2 — ingest in shadow.** Implement the plugin WebSocket ingest (`/ws/position`) and browser WS (`/ws/live`) in Go. To test ingest in parallel **without stealing plugin connections or double-writing the live tables**: have the Python tracker **tee a copy** of every received plugin message to the Go service (a small, low-risk addition to `main.py` — forward each raw message to Go over an internal channel/HTTP/WS), and have **Go write to its own separate schema or database** (e.g. a `dereth_go` DB) so you can compare ingest results against Python's without conflicts. Compare row counts, latencies, and `/live` outputs.
**Phase 3 — the rest.** Commands (browser→plugin envelopes), inventory forwarding to inventory-service, share_*, dungeon_map, combat_stats accumulation, Discord death/idle webhook, etc.
**Phase 4 — cutover.** Once Go matches Python on real traffic for long enough, flip nginx to route the real paths to Go, point the plugin endpoint at Go, retire the Python container. Keep Python deployable for rollback.
## Contract & correctness facts you MUST preserve (learned the hard way)
- **Wire format:** snake_case JSON, exact field names, events routed by a `type` field, ISO8601 UTC timestamps. The Pydantic models in `main.py` are the schema. Match them exactly or the plugin/frontend break.
- **`/live` "online" window MUST use the SERVER receive-time, not the client timestamp.** Game machines' clocks drift up to ~90s apart; telemetry carries the client's `DateTime.UtcNow`. Python recently added a `telemetry_events.received_at` (server-stamped) column and windows "online" on `COALESCE(received_at, timestamp) > now()-30s`. Go must stamp its own server receive-time and window on that, or the player count flaps. (See the June 2026 fix; `ACTIVE_WINDOW` = 30s.)
- **Inventory deltas are a firehose** — the plugin debounces "update" events to a 25 min randomized flush, but adds/removes are immediate, and forwards still arrive bursty. Python caps concurrent forwards to inventory-service with a semaphore(8) + a bounded httpx client. Go must similarly bound concurrency so an ingest burst can't starve telemetry.
- **Auth:** browser endpoints use a session cookie signed with `itsdangerous` `URLSafeTimedSerializer(SECRET_KEY)` (HMAC, 30-day expiry). If Go reuses the same `SECRET_KEY` and replicates the format, the same login works on both during the parallel run — do that. Plugin `/ws/position` auth is an `X-Plugin-Secret` header vs `SHARED_SECRET` (env). ⚠ Currently the live deploy runs with `SHARED_SECRET_LEGACY=your_shared_secret` accepted (a migration escape hatch) — don't be surprised by the placeholder; read `MosswartOverlord/CLAUDE.md` "Integration contract".
- **Internal-trust rule:** Python treats a request as internal (skips cookie auth) only if it comes from a private source IP **and has no `X-Forwarded-For`** (nginx adds XFF to all proxied traffic). Preserve this semantics; never trust the raw 172.x range.
- **DB:** `telemetry_events` and `spawn_events` are TimescaleDB hypertables (partitioned on `timestamp`) with retention policies. There are NO migrations — schema is created in `db_async.init_db_async`. Read it for the exact tables/columns/indexes. Don't break the hypertable partition key (`timestamp`) — keep writing `timestamp` (client) for partitioning AND `received_at` (server) for the window.
- **Deploy reality:** `main.py`/`db_async.py`/`static/` are bind-mounted into the Python container (restart applies changes). The full-rebuild flow bakes a `BUILD_VERSION` for the UI version stamp. Postgres ports are bound to loopback; DB ports are NOT public. SSH: `erik@overlord.snakedesert.se` (key-based). Read-only DB: `docker exec dereth-db psql -U postgres -d dereth`.
## Suggested Go stack (decide for yourself, but these fit)
- HTTP: stdlib `net/http` + `go-chi/chi` router. WebSocket: `coder/websocket` (formerly nhooyr) or `gorilla/websocket`.
- Postgres/TimescaleDB: `jackc/pgx` v5 + `pgxpool`. JSON: stdlib `encoding/json` (fine) or `goccy/go-json` if profiling says so. Logging: stdlib `log/slog`. Config: env vars matching the Python service.
- Concurrency: shared in-memory state (live snapshots, plugin connections) behind `sync.RWMutex` or sharded maps; per-connection goroutines; bounded worker pools (`golang.org/x/sync/semaphore` or buffered channels) for inventory forwarding.
## How to work
- **Evidence-driven and parallel-safe.** Never disrupt the live Python service. Before claiming parity, *diff the actual outputs* against Python on real data and show the comparison.
- Commit frequently. Keep the Go service in its own directory/repo. Don't touch `main.py` except the tiny Phase-2 tee (and even that, behind a flag).
- Start by reading the docs above and `main.py`, then deliver **Phase 0 + Phase 1** (a Go service deployed at `/go/` that serves a `/go/live` matching Python's `/live`). Report the comparison.
- Ask the user before: changing the live Python service, repointing the plugin endpoint, or any cutover step.
First, read the listed files and `main.py`, then propose your Phase 0/1 plan (Go module layout, the compose + nginx additions, and how you'll compare `/go/live` to `/live`) before writing code.

View file

@ -1,85 +0,0 @@
# Suitbuilder CD-tier filter — design
**Date:** 2026-06-25
**Status:** Approved (pending spec review)
**Scope:** Live Go suitbuilder only (`go-services/inventory-go/`) + the static suitbuilder page (`static/suitbuilder.{html,js}`). **No changes** to the frozen `inventory-service/suitbuilder.py` (legacy rollback reference).
## Goal
Let the user restrict which **crit-damage tiers** (CD0 / CD1 / CD2) are allowed on **armor** pieces in a suit search, so they can build, e.g., all-CD1 suits or CD1/CD0-only suits. Among whatever tiers are allowed, the solver still prefers the highest (existing behavior) — so this is fundamentally a **filter**, not a scoring change.
## Background — current state
- The live suitbuilder is the Go solver (`suit_solver.go` / `suit_model.go` / `suit_http.go`), reached via browser → tracker `/inv/suitbuilder/search` → inventory-go `/suitbuilder/search`. Python is frozen on `python-legacy`.
- There is **no crit-damage filtering today.** CD0/CD1/CD2 armor all flows into the search. The only thing distinguishing tiers is scoring (`CritDamage1: +10`, `CritDamage2: +20`) and the CD-descending armor sort — which is why CD2 always wins.
- The UI already shows **Crit Damage min/max** number inputs (`suitbuilder.html:54-57`), and the JS already sends `min_crit_damage`/`max_crit_damage` (`suitbuilder.js:310-311, 386-387`). The Go solver receives them into `SearchConstraints.MinCritDamage`/`MaxCritDamage` but **never references them** — dead, half-wired scaffold. This feature replaces that dead control.
## Behavior contract
- A new per-search filter selects which CD tiers are **allowed on armor**: independent CD0 / CD1 / CD2 toggles.
- **A checked tier = "allowed."** "Prefer higher, fall back lower" happens automatically among the allowed tiers via the existing scoring/sort — no scoring change.
- **Default = all three allowed.** Because the solver prefers the highest allowed tier, the default naturally leads with CD2 — i.e. identical to today's behavior. This is the "default CD2" state.
- **Empty / none-selected = treated as the default** (all allowed). A search can never be forced into an armorless state by this control.
- **Jewelry and clothing are never filtered by CD** — they are categorized separately in `loadItems` and the filter only touches armor.
- **Tier mapping** (handles rare high-crit gear): `CD0 = rating ≤ 0`, `CD1 = rating == 1`, **`CD2 = rating ≥ 2`**. A CD3+ gear piece counts as CD2 and is not silently dropped.
### Worked examples
| Allowed set | Result |
|---|---|
| `{0,1,2}` (default / empty) | Unchanged from today — prefer CD2, fall back CD1, CD0 |
| `{0,1}` | No CD2 armor; prefer CD1, fall back CD0 |
| `{1}` | All-CD1 suits; a slot with no CD1 piece is left empty |
| `{1,2}` | No CD0 armor; prefer CD2, fall back CD1 |
## Backend design — `go-services/inventory-go`
### 1. Constraint field (`suit_model.go`)
- Add `AllowedCritDamage []int \`json:"allowed_crit_damage"\`` to `SearchConstraints`.
- **Remove** the dead `MinCritDamage *int` / `MaxCritDamage *int` fields (never wired; their UI is being replaced). Leave the other unrelated dead fields (`MinArmor`/`MaxArmor`/`MinDamageRating`/`MaxDamageRating`) untouched — out of scope.
### 2. Precompute the allowed set (`newSolver`, `suit_solver.go`)
- Build `allowedCD map[int]bool` by normalizing each value in `AllowedCritDamage` to a tier in `{0,1,2}` (clamp ≥2 to 2, ≤0 to 0).
- **Filter inactive** (no-op) when the resulting set is empty **or** already contains all of `{0,1,2}`. This makes "all checked", "none checked", and "field absent" all mean *no filter* — and guarantees the default path is byte-identical to current output.
### 3. Apply the filter in `loadItems` (`suit_solver.go`)
- **Location & ordering are load-bearing:** filter armor items **after** the raw `items` slice is built (~line 254) and **before `removeSurpassedItems`** (line 262). If the CD filter ran after domination, a CD2 piece could dominate and remove an allowed CD1 piece, which we'd then exclude — leaving the slot needlessly empty. Filtering first keeps domination confined to allowed items.
- An item is "armor" iff its slot matches `armorSlotSet` (including comma-joined multi-coverage slots like `"Chest, Abdomen"`). Factor a small package-level helper `isArmorSlot(slot string) bool` (mirrors the existing `matches(it.Slot, armorSlotSet, nil)` logic) so it can be used both here and in the existing categorization pass. Non-armor items (jewelry/clothing/unknown) are never dropped by this filter.
- When the filter is active, drop armor items whose normalized tier ∉ `allowedCD`.
- Tailored/reduced armor inherits its CD from the origin piece (already filtered upstream), so reductions of excluded pieces never appear — no extra handling needed.
### Regression safety
- The default (no `allowed_crit_damage`, or all three) path must produce **identical** output to the current solver. The no-op guard in step 2 ensures this.
## Frontend design — `static/suitbuilder.{html,js}`
(Vanilla static page served from the bind-mounted `static/` — no build step, no container restart.)
### 1. `suitbuilder.html` (~lines 53-58)
- Replace the `Crit Damage [Min]-[Max]` number inputs (`#minCritDmg`, `#maxCritDmg`) with three checkboxes inside the existing `filter-group`: `#allowCD0`, `#allowCD1`, `#allowCD2`, labelled CD0 / CD1 / CD2, **all `checked` by default.** Keep the surrounding `filter-row`/`filter-group`/`constraint-section` layout.
### 2. `suitbuilder.js`
- **`gatherConstraints()` (lines 310-311):** remove the `min_crit_damage`/`max_crit_damage` reads; add `allowed_crit_damage`, an array of the checked tiers, e.g. `[0,1,2]`.
- **`validateConstraints()` (line 360):** remove the now-deleted `!constraints.min_crit_damage` term from the "at least one constraint" check. (A CD restriction is not a valid *standalone* search — armor is only loaded for the chosen primary/secondary set, so a set/cantrip/ward/rating-min is still required. The CD filter is a refinement on top.)
- **`streamOptimalSuits()` (lines 386-387):** remove `min_crit_damage`/`max_crit_damage` from `requestBody`; add `allowed_crit_damage: constraints.allowed_crit_damage`.
## Testing
- **Regression (Go):** a default search (no `allowed_crit_damage`) yields output identical to baseline — assert the no-op path. Where existing suitbuilder validation/golden harnesses exist (`compare/`), the default case must stay byte-identical; filtered cases are intentionally Python-divergent and are validated by the new tests below, not against Python.
- **New unit test (Go):**
- `allowed=[1]` ⇒ every armor piece in every returned suit has tier CD1; jewelry/clothing still present.
- `allowed=[0,1]` ⇒ no CD2 armor appears in any suit.
- `allowed=[1,2]` ⇒ no CD0 armor appears.
- `allowed=[]` / `[0,1,2]` ⇒ identical to baseline.
- **Manual:** on the server, run a real CD1-only search and confirm all-CD1 armor and sane fallback/empty-slot behavior.
## Deploy
- **Backend:** rebuild `inventory-go` on the server (sync `go-services/`, build, recreate with the cutover override) — see MosswartOverlord CLAUDE.md "Go services — build, deploy, gotchas".
- **Frontend:** edit `static/suitbuilder.{html,js}`; a normal `git pull` on the host picks them up via the bind mount — no build, no restart.
## Out of scope
- `inventory-service/suitbuilder.py` (frozen/legacy) — intentionally left to diverge.
- The other dead constraint fields (`min/max_armor`, `min/max_damage_rating`) — separate follow-up if wanted.
- No scoring-weight changes; no new scoring knobs.

View file

@ -1,522 +0,0 @@
# Suitbuilder CD-tier filter — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let a suitbuilder search restrict which crit-damage tiers (CD0/CD1/CD2) are allowed on armor pieces, so the user can build e.g. all-CD1 suits — while the default (all allowed) stays byte-identical to today.
**Architecture:** Add an `allowed_crit_damage` constraint. In the live Go solver (`inventory-go`), drop armor items whose CD tier isn't allowed during item loading, before the domination pre-filter. "Prefer highest allowed tier" needs no new code — it falls out of the existing scoring and CD-descending armor sort. Frontend swaps the dead Crit-Damage min/max inputs for three CD checkboxes.
**Tech Stack:** Go 1.25 (`go-services/inventory-go`), vanilla JS/HTML (`static/suitbuilder.*`), Docker on the server (no local Go toolchain).
**Spec:** `docs/plans/2026-06-25-suitbuilder-cd-tier-filter-design.md`
---
## Conventions for this plan
- **Source-of-truth edits** happen in the local repo at `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`, on branch `suitbuilder-cd-tier-filter`. Commit there.
- **No local Go toolchain.** Build & test run on the server (`overlord.snakedesert.se`) inside Docker.
- **Fast unit-test loop** (run from the local MosswartOverlord dir after copying changed files to the host — see Task 6 for the copy command):
```bash
ssh erik@overlord.snakedesert.se "docker run --rm \
-v /home/erik/MosswartOverlord/go-services/inventory-go:/src -w /src \
golang:1.25-bookworm sh -c 'go mod tidy >/dev/null 2>&1 && go test ./... -v'"
```
(Mounts the host's inventory-go source into a throwaway golang container. `go mod tidy` writes go.sum into that untracked dir — harmless.)
- The live container is `inventory-go` (image `inventory-go:local`, `127.0.0.1:8772`).
---
## File structure
- `go-services/inventory-go/suit_model.go`**modify**: constraint field.
- `go-services/inventory-go/suit_cd.go`**create**: pure CD-tier helpers (one responsibility, DB-free, unit-testable).
- `go-services/inventory-go/suit_cd_test.go`**create**: unit tests for the helpers.
- `go-services/inventory-go/suit_solver.go`**modify**: solver field + wire filter into `loadItems`.
- `go-services/inventory-go/Dockerfile`**modify**: add a `go test` build gate (mirrors tracker-go).
- `static/suitbuilder.html`**modify**: CD checkboxes replace min/max inputs.
- `static/suitbuilder.js`**modify**: gather/validate/send `allowed_crit_damage`.
- `static/suitbuilder.css`**modify**: minor styling for the toggles.
---
## Task 1: Add the `allowed_crit_damage` constraint field
**Files:** Modify `go-services/inventory-go/suit_model.go`
- [ ] **Step 1: Replace the dead crit min/max fields**
In `SearchConstraints`, replace these two lines:
```go
MinCritDamage *int `json:"min_crit_damage"`
MaxCritDamage *int `json:"max_crit_damage"`
```
with:
```go
AllowedCritDamage []int `json:"allowed_crit_damage"`
```
(The `Min/MaxCritDamage` fields were never referenced by the solver — confirmed by grep. The other `Min/Max*` fields stay untouched.)
- [ ] **Step 2: Commit**
```bash
cd /c/Users/erikn/source/repos/dereth-workspace/MosswartOverlord
git add go-services/inventory-go/suit_model.go
git commit -m "feat(suitbuilder): add allowed_crit_damage constraint field"
```
---
## Task 2: CD-tier helpers + unit tests (TDD)
**Files:**
- Create: `go-services/inventory-go/suit_cd.go`
- Create: `go-services/inventory-go/suit_cd_test.go`
- [ ] **Step 1: Write the failing tests**
Create `go-services/inventory-go/suit_cd_test.go`:
```go
package main
import "testing"
func TestCritTier(t *testing.T) {
cases := []struct {
rating, want int
}{{-1, 0}, {0, 0}, {1, 1}, {2, 2}, {3, 2}, {5, 2}}
for _, c := range cases {
if got := critTier(c.rating); got != c.want {
t.Errorf("critTier(%d) = %d, want %d", c.rating, got, c.want)
}
}
}
func TestAllowedCritSet(t *testing.T) {
for _, vals := range [][]int{nil, {}, {0, 1, 2}, {0, 1, 3}} {
if allowedCritSet(vals) != nil {
t.Errorf("allowedCritSet(%v) should be nil (inactive)", vals)
}
}
if s := allowedCritSet([]int{1}); s == nil || !s[1] || s[0] || s[2] {
t.Errorf("allowedCritSet({1}) = %v, want only tier 1", s)
}
if s := allowedCritSet([]int{0, 1}); s == nil || !s[0] || !s[1] || s[2] {
t.Errorf("allowedCritSet({0,1}) = %v, want tiers 0,1", s)
}
if s := allowedCritSet([]int{3}); s == nil || !s[2] || s[0] || s[1] {
t.Errorf("allowedCritSet({3}) = %v, want only tier 2 (normalized)", s)
}
}
func TestIsArmorSlot(t *testing.T) {
for _, s := range []string{"Chest", "Head", "Feet", "Chest, Abdomen", "Upper Legs, Lower Legs"} {
if !isArmorSlot(s) {
t.Errorf("isArmorSlot(%q) = false, want true", s)
}
}
for _, s := range []string{"Neck", "Left Ring", "Left Wrist", "Trinket", "Shirt", "Pants", "Unknown", ""} {
if isArmorSlot(s) {
t.Errorf("isArmorSlot(%q) = true, want false", s)
}
}
}
func cdItem(slot string, cd int) *SuitItem {
return &SuitItem{Slot: slot, Ratings: map[string]int{"crit_damage_rating": cd}}
}
func TestFilterArmorByCD(t *testing.T) {
items := []*SuitItem{
cdItem("Chest", 0), cdItem("Head", 1), cdItem("Feet", 2),
cdItem("Chest, Abdomen", 2), // multi-coverage armor, CD2
cdItem("Neck", 0), // jewelry — never filtered
cdItem("Shirt", 0), // clothing — never filtered
}
if got := filterArmorByCD(items, nil); len(got) != len(items) {
t.Errorf("nil filter dropped items: got %d, want %d", len(got), len(items))
}
got := filterArmorByCD(items, map[int]bool{1: true})
keep := map[string]bool{"Head": true, "Neck": true, "Shirt": true}
if len(got) != 3 {
t.Fatalf("allowed{1}: got %d items, want 3", len(got))
}
for _, it := range got {
if !keep[it.Slot] {
t.Errorf("allowed{1}: unexpected slot %q survived", it.Slot)
}
}
got = filterArmorByCD(items, map[int]bool{0: true, 1: true})
if len(got) != 4 { // Chest(0), Head(1), Neck, Shirt
t.Errorf("allowed{0,1}: got %d items, want 4", len(got))
}
for _, it := range got {
if isArmorSlot(it.Slot) && it.Ratings["crit_damage_rating"] >= 2 {
t.Errorf("allowed{0,1}: CD2 armor %q should have been dropped", it.Slot)
}
}
}
```
- [ ] **Step 2: Run the tests to confirm they fail to build**
Copy only the test file to the host (the implementation doesn't exist yet):
```bash
cd /c/Users/erikn/source/repos/dereth-workspace/MosswartOverlord
scp go-services/inventory-go/suit_cd_test.go \
erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/go-services/inventory-go/
```
Then run the fast test loop (see Conventions).
Expected: FAIL — `undefined: critTier`, `allowedCritSet`, `isArmorSlot`, `filterArmorByCD`.
- [ ] **Step 3: Write the implementation**
Create `go-services/inventory-go/suit_cd.go`:
```go
package main
import "strings"
// CD-tier filtering for the suitbuilder. The allowed_crit_damage constraint
// restricts which crit-damage tiers are permitted on ARMOR pieces; jewelry and
// clothing are never affected. "Prefer the highest allowed tier" is NOT done
// here — it falls out of the existing scoring (CritDamage2 > CritDamage1) and
// the CD-descending armor sort once disallowed tiers are removed.
// critTier normalizes a raw crit_damage_rating into a tier in {0,1,2}. Rare
// high-crit gear (rating >= 2, including 3+) collapses to tier 2 so it counts
// as "CD2" rather than being silently excluded.
func critTier(rating int) int {
switch {
case rating <= 0:
return 0
case rating == 1:
return 1
default:
return 2
}
}
// isArmorSlot reports whether a slot name denotes an armor coverage slot,
// including comma-joined multi-coverage slots like "Chest, Abdomen".
func isArmorSlot(slot string) bool {
if armorSlotSet[slot] {
return true
}
if strings.Contains(slot, ", ") {
for _, p := range strings.Split(slot, ", ") {
if armorSlotSet[strings.TrimSpace(p)] {
return true
}
}
}
return false
}
// allowedCritSet normalizes the constraint's allowed crit-damage tiers into a
// set, or returns nil when the filter is INACTIVE: no values, or all three
// tiers {0,1,2} present (== default). A nil result means "no filter" and keeps
// the default search path byte-identical to the unfiltered solver.
func allowedCritSet(vals []int) map[int]bool {
if len(vals) == 0 {
return nil
}
set := map[int]bool{}
for _, v := range vals {
set[critTier(v)] = true
}
if set[0] && set[1] && set[2] {
return nil
}
return set
}
// filterArmorByCD drops armor items whose crit-damage tier is not in allowed.
// Non-armor items (jewelry, clothing, unknown) always pass through. When
// allowed is nil the input is returned unchanged.
func filterArmorByCD(items []*SuitItem, allowed map[int]bool) []*SuitItem {
if allowed == nil {
return items
}
out := make([]*SuitItem, 0, len(items))
for _, it := range items {
if isArmorSlot(it.Slot) && !allowed[critTier(it.Ratings["crit_damage_rating"])] {
continue
}
out = append(out, it)
}
return out
}
```
- [ ] **Step 4: Run the tests to confirm they pass**
```bash
scp go-services/inventory-go/suit_cd.go \
erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/go-services/inventory-go/
```
Run the fast test loop. Expected: PASS (`ok` — 4 tests).
- [ ] **Step 5: Add the `go test` build gate to the Dockerfile**
In `go-services/inventory-go/Dockerfile`, after `RUN go mod tidy` add:
```dockerfile
RUN go test ./...
```
(Mirrors `tracker-go/Dockerfile`; from now on every image build runs the tests.)
- [ ] **Step 6: Commit**
```bash
git add go-services/inventory-go/suit_cd.go go-services/inventory-go/suit_cd_test.go go-services/inventory-go/Dockerfile
git commit -m "feat(suitbuilder): CD-tier filter helpers + tests; gate inventory-go build on go test"
```
---
## Task 3: Wire the filter into the solver
**Files:** Modify `go-services/inventory-go/suit_solver.go`
- [ ] **Step 1: Add the precomputed set to the Solver struct**
In the `Solver` struct, after `armorBucketsItems int`, add:
```go
allowedCD map[int]bool // nil == no CD filter (default / all tiers)
```
- [ ] **Step 2: Populate it in `newSolver`**
In `newSolver`, after the line `sv.neededSpellBitmap = sv.spellIndex.getBitmap(c.RequiredSpells)`, add:
```go
sv.allowedCD = allowedCritSet(c.AllowedCritDamage)
```
- [ ] **Step 3: Apply the filter in `loadItems` before domination**
In `loadItems`, find:
```go
filtered := removeSurpassedItems(items)
```
and immediately ABOVE it insert:
```go
// Drop armor whose CD tier is disallowed BEFORE domination, so a CD2 piece
// can't surpass-and-remove an allowed CD1 piece we'd then exclude.
items = filterArmorByCD(items, sv.allowedCD)
```
- [ ] **Step 4: Verify it still builds and all tests pass**
Copy the changed solver file and run the test loop:
```bash
scp go-services/inventory-go/suit_solver.go \
erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/go-services/inventory-go/
```
Run the fast test loop. Expected: PASS, and the package compiles (the wiring type-checks; `go test` builds the whole `main` package).
- [ ] **Step 5: Commit**
```bash
git add go-services/inventory-go/suit_solver.go
git commit -m "feat(suitbuilder): apply CD-tier filter in loadItems (before domination)"
```
---
## Task 4: Frontend — CD checkboxes
**Files:** Modify `static/suitbuilder.html`, `static/suitbuilder.js`, `static/suitbuilder.css`
- [ ] **Step 1: Replace the Crit Damage inputs with checkboxes**
In `static/suitbuilder.html`, replace this block:
```html
<div class="filter-group">
<label>Crit Damage:</label>
<input type="number" id="minCritDmg" placeholder="Min" min="0" max="999">
<span>-</span>
<input type="number" id="maxCritDmg" placeholder="Max" min="0" max="999">
</div>
```
with:
```html
<div class="filter-group">
<label>Allowed Crit Damage:</label>
<label class="cd-toggle"><input type="checkbox" id="allowCD0" checked> CD0</label>
<label class="cd-toggle"><input type="checkbox" id="allowCD1" checked> CD1</label>
<label class="cd-toggle"><input type="checkbox" id="allowCD2" checked> CD2</label>
</div>
```
- [ ] **Step 2: Build `allowed_crit_damage` in `gatherConstraints()`**
In `static/suitbuilder.js`, replace these two lines:
```js
min_crit_damage: document.getElementById('minCritDmg').value || null,
max_crit_damage: document.getElementById('maxCritDmg').value || null,
```
with:
```js
allowed_crit_damage: [
document.getElementById('allowCD0').checked ? 0 : null,
document.getElementById('allowCD1').checked ? 1 : null,
document.getElementById('allowCD2').checked ? 2 : null,
].filter(v => v !== null),
```
- [ ] **Step 3: Drop the deleted field from validation**
In `validateConstraints()`, change:
```js
!constraints.min_armor && !constraints.min_crit_damage && !constraints.min_damage_rating) {
```
to:
```js
!constraints.min_armor && !constraints.min_damage_rating) {
```
- [ ] **Step 4: Send `allowed_crit_damage` in the request body**
In `streamOptimalSuits()`, replace these two lines:
```js
min_crit_damage: constraints.min_crit_damage ? parseInt(constraints.min_crit_damage) : null,
max_crit_damage: constraints.max_crit_damage ? parseInt(constraints.max_crit_damage) : null,
```
with:
```js
allowed_crit_damage: constraints.allowed_crit_damage,
```
- [ ] **Step 5: Style the toggles**
Append to `static/suitbuilder.css`:
```css
.cd-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-right: 10px;
font-weight: normal;
cursor: pointer;
}
.cd-toggle input { margin: 0; }
```
- [ ] **Step 6: Commit**
```bash
git add static/suitbuilder.html static/suitbuilder.js static/suitbuilder.css
git commit -m "feat(suitbuilder): CD0/CD1/CD2 allowed-tier checkboxes (replace dead crit min/max)"
```
---
## Task 5: Deploy to the server & verify end-to-end
- [ ] **Step 1: Copy changed backend files to the host build context**
```bash
cd /c/Users/erikn/source/repos/dereth-workspace/MosswartOverlord
scp go-services/inventory-go/suit_model.go go-services/inventory-go/suit_cd.go \
go-services/inventory-go/suit_cd_test.go go-services/inventory-go/suit_solver.go \
go-services/inventory-go/Dockerfile \
erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/go-services/inventory-go/
```
- [ ] **Step 2: Build the image (runs `go test` as part of the build)**
```bash
ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml \
build inventory-go'
```
Expected: build succeeds; the `RUN go test ./...` layer passes.
- [ ] **Step 3: Recreate the container with the cutover override**
```bash
ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml \
-f go-services/docker-compose.cutover.yml up -d --no-deps inventory-go'
```
Expected: `inventory-go` recreated; `docker ps` shows it healthy on :8772.
- [ ] **Step 4: Copy the changed static files (bind-mounted; live immediately)**
```bash
scp static/suitbuilder.html static/suitbuilder.js static/suitbuilder.css \
erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/static/
```
- [ ] **Step 5: Verify default search is unchanged + CD1-only works**
Manual, in the browser at the suitbuilder page (hard-refresh to bust cache):
- With **all three CD boxes checked**, run a search (a primary set + a character with armor). Confirm results look like before.
- Check **only CD1**, run the same search. Confirm in the Network tab the request body has `"allowed_crit_damage":[1]`, and every armor piece in the returned suits shows **CD1** (jewelry/clothing unaffected; slots with no CD1 piece may be empty).
- Check **CD1 + CD0**, confirm no CD2 armor appears and CD1 is preferred where available.
---
## Task 6: Finalize the local feature commit
- [ ] **Step 1: Confirm the branch state**
```bash
cd /c/Users/erikn/source/repos/dereth-workspace/MosswartOverlord
git log --oneline -6
git status
```
Expected: clean tree; the spec + plan + Tasks 1-4 feature commits on `suitbuilder-cd-tier-filter`.
---
## Phase 2: Reconcile host git + push to Gitea (separate, after the feature is verified live)
> ⚠ Pushing to the **public** Gitea is outward-facing and partly irreversible. Investigate state and decide a strategy BEFORE any push; surface the chosen strategy to the user first. Never `git add` the host's `.env` (secrets).
- [ ] **Step 1: Establish the true state of all three gits**
- Local `MosswartOverlord` HEAD (`9911edbf`, has go-services committed).
- Host `/home/erik/MosswartOverlord` HEAD (`6a0bb9fe`, go-services untracked, has server-only commits like rickroll/midsummer).
- Gitea `origin/master` — fetch and inspect; determine whether local's go-services history and/or the host's server-only commits are already on the remote.
- [ ] **Step 2: Decide a reconciliation strategy** (depends on Step 1 findings):
- Get the host's server-only commits into the canonical local history (cherry-pick or merge), and get the local go-services history onto the host — so a single `master` contains both, with this feature on top.
- Plan must avoid clobbering the host's untracked `.env`/backups and avoid a destructive force-push unless explicitly chosen.
- [ ] **Step 3: Execute the chosen reconciliation, then `git pull` on the host** so the host runs tracked code, and push the unified `master` to Gitea. Confirm `docker compose build` still uses the now-tracked go-services.
(Phase 2 steps are deliberately high-level — the exact git commands depend on Step 1's findings and a strategy choice. Do not pre-bake destructive commands.)

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

View file

@ -1,895 +0,0 @@
# Midsummer "Små grodorna" Theme Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A full-takeover Swedish-midsummer frog/maypole theme for the Overlord React dashboard, toggled per browser (default on), with a dancing maypole, frog + flower-crown player dots, a Glad midsommar banner + confetti, a frog-hop easter egg replacing the rickroll, and a WebAudio-synthesized *Små grodorna* jingle.
**Architecture:** A `data-midsummer` attribute on `<html>` gates a scoped CSS overlay (`midsummer.css`, all rules under `:root[data-midsummer]`) layered over the untouched base `map-layout.css`. A `MidsummerProvider`/`useMidsummer` context holds the on/off + sound state in `localStorage`. Dynamic pieces (maypole, banner, confetti, toggle, jingle) are small React components/hooks gated by the flag; palette, crowns and the hop are pure CSS.
**Tech Stack:** React 19 + Vite + TypeScript, plain CSS, WebAudio API. No new dependencies, no audio asset.
**Testing note:** This repo has **no automated frontend test runner** (verification is build + manual browser checks, per the repo's own docs). Each task therefore verifies via `npm run build` and a dev-server browser check rather than a unit-test runner. Run the dev server once up front: `cd frontend && npm run dev` (Vite on :5173, `/api` proxied to :8765) and keep it open across tasks.
---
## File structure
New files:
- `frontend/src/hooks/useMidsummer.tsx` — context + provider + `useMidsummer()` hook (state, persistence, attribute).
- `frontend/src/hooks/useMidsummerSound.ts` — WebAudio jingle synth + first-gesture hook.
- `frontend/src/styles/midsummer.css` — entire scoped theme overlay (palette, maypole, dots, banner, confetti, hop).
- `frontend/src/components/midsummer/Maypole.tsx` — the dancing maypole (mounted in the map group).
- `frontend/src/components/midsummer/MidsummerBanner.tsx` — banner + first-load confetti.
- `frontend/src/components/midsummer/FrogToggle.tsx` — 🐸 theme + 🔊 sound toggle links.
- `frontend/src/components/midsummer/confetti.ts` — DOM confetti burst helper.
Modified files:
- `frontend/src/App.tsx` — wrap in `MidsummerProvider`, import `midsummer.css`.
- `frontend/src/components/map/MapView.tsx` — mount `<Maypole>` inside `.ml-map-group`.
- `frontend/src/components/map/MapLayout.tsx` — mount `<MidsummerBanner>`, call `useMidsummerSound()`.
- `frontend/src/components/PlayerDashboardFullPage.tsx` — same banner + sound for the new-tab dashboard.
- `frontend/src/components/sidebar/SidebarWindowButtons.tsx` — add `<FrogToggle/>`.
- `frontend/src/components/map/Sidebar.tsx` — replace rickroll easter egg with frog-hop.
---
## Task 1: Theme state — provider, hook, attribute
**Files:**
- Create: `frontend/src/hooks/useMidsummer.tsx`
- Create (empty): `frontend/src/styles/midsummer.css`
- Modify: `frontend/src/App.tsx`
- [ ] **Step 1: Create the context/provider/hook**
Create `frontend/src/hooks/useMidsummer.tsx`:
```tsx
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
const KEY = 'mo-midsummer';
const SOUND_KEY = 'mo-midsummer-sound';
interface MidsummerCtx {
enabled: boolean;
toggle: () => void;
soundOn: boolean;
toggleSound: () => void;
}
const Ctx = createContext<MidsummerCtx | null>(null);
export const MidsummerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// Default ON: only the literal "off" disables it.
const [enabled, setEnabled] = useState<boolean>(() => localStorage.getItem(KEY) !== 'off');
const [soundOn, setSoundOn] = useState<boolean>(() => localStorage.getItem(SOUND_KEY) !== 'off');
useEffect(() => {
const el = document.documentElement;
if (enabled) el.setAttribute('data-midsummer', '');
else el.removeAttribute('data-midsummer');
localStorage.setItem(KEY, enabled ? 'on' : 'off');
}, [enabled]);
useEffect(() => {
localStorage.setItem(SOUND_KEY, soundOn ? 'on' : 'off');
}, [soundOn]);
const toggle = useCallback(() => setEnabled(e => !e), []);
const toggleSound = useCallback(() => setSoundOn(s => !s), []);
return (
<Ctx.Provider value={{ enabled, toggle, soundOn, toggleSound }}>
{children}
</Ctx.Provider>
);
};
export function useMidsummer(): MidsummerCtx {
const c = useContext(Ctx);
if (!c) throw new Error('useMidsummer must be used within MidsummerProvider');
return c;
}
```
- [ ] **Step 2: Create the (empty) overlay stylesheet**
Create `frontend/src/styles/midsummer.css` with a single header comment so the import resolves:
```css
/* Midsummer "Små grodorna" theme overlay. All rules scoped under
:root[data-midsummer] so they only apply when the theme is on. */
```
- [ ] **Step 3: Wrap the app in the provider and import the stylesheet**
Replace the entire contents of `frontend/src/App.tsx` with:
```tsx
import { MapLayout } from './components/map/MapLayout';
import { PlayerDashboardFullPage } from './components/PlayerDashboardFullPage';
import { MidsummerProvider } from './hooks/useMidsummer';
import { useLiveData } from './hooks/useLiveData';
import './styles/map-layout.css';
import './styles/midsummer.css';
/**
* Single SPA entry. Branches on `?view=` query param:
* /?view=dashboard → fullscreen PlayerDashboardFullPage (new-tab target)
* / → default map + sidebar layout
*/
export default function App() {
const view = new URLSearchParams(window.location.search).get('view');
return (
<MidsummerProvider>
{view === 'dashboard' ? <PlayerDashboardFullPage /> : <DefaultApp />}
</MidsummerProvider>
);
}
/** 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} />;
}
```
- [ ] **Step 4: Verify build + attribute toggling**
Run: `cd frontend && npm run build`
Expected: build succeeds, no TS errors.
In the dev server browser console, run `document.documentElement.hasAttribute('data-midsummer')` → expect `true` (default on). Run `localStorage.setItem('mo-midsummer','off')` then reload → expect `false`. Set back to `'on'`.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/hooks/useMidsummer.tsx frontend/src/styles/midsummer.css frontend/src/App.tsx
git commit -m "feat(midsummer): theme state provider + data-midsummer attribute"
```
---
## Task 2: Base palette overlay (pond-green takeover)
**Files:**
- Modify: `frontend/src/styles/midsummer.css`
- [ ] **Step 1: Append the palette overlay**
Append to `frontend/src/styles/midsummer.css`:
```css
:root[data-midsummer] .ml-sidebar {
background: #0a1f16;
border-right: 2px solid #1c5a2c;
}
:root[data-midsummer] .ml-map-container {
background: #0e2a1e;
}
:root[data-midsummer] .ml-sidebar-title {
color: #7ed957;
text-shadow: 0 0 6px rgba(126, 217, 87, 0.35);
}
:root[data-midsummer] .ml-tool-link {
color: #bfe9a8;
}
:root[data-midsummer] .ml-tool-link:hover {
color: #eafbe0;
}
:root[data-midsummer] .ml-server-status,
:root[data-midsummer] .ml-counters,
:root[data-midsummer] .ml-player-row {
border-color: #1c5a2c;
}
:root[data-midsummer] .ml-player-row.ml-player-selected {
background: rgba(126, 217, 87, 0.14);
outline: 1px solid rgba(126, 217, 87, 0.5);
}
:root[data-midsummer] .ml-sort-btn,
:root[data-midsummer] .ml-btn {
border-color: #2c6e36;
}
```
- [ ] **Step 2: Verify in browser**
Reload the dev server with the theme on. Expect: sidebar turns deep pond-green, title turns lime with a glow, map background darkens to forest green, tool links go pale green. Toggle `data-midsummer` off in console → expect the original dark theme returns exactly.
Run: `cd frontend && npm run build` → expect success.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/styles/midsummer.css
git commit -m "feat(midsummer): pond-green palette overlay for sidebar and map"
```
---
## Task 3: 🐸 toggle (+ 🔊 sound) in the sidebar
**Files:**
- Create: `frontend/src/components/midsummer/FrogToggle.tsx`
- Modify: `frontend/src/components/sidebar/SidebarWindowButtons.tsx`
> Note: `FrogToggle` imports `playSmaGrodorna` from `useMidsummerSound`, which is created in Task 8. To keep tasks independently buildable, this task includes a minimal stub of that module; Task 8 replaces the stub with the full synth. If executing in order, create the stub now.
- [ ] **Step 1: Create the jingle module stub (replaced fully in Task 8)**
Create `frontend/src/hooks/useMidsummerSound.ts`:
```ts
// Stub — replaced with the full WebAudio synth in Task 8.
export function playSmaGrodorna(): void {}
```
- [ ] **Step 2: Create the toggle component**
Create `frontend/src/components/midsummer/FrogToggle.tsx`:
```tsx
import React from 'react';
import { useMidsummer } from '../../hooks/useMidsummer';
import { playSmaGrodorna } from '../../hooks/useMidsummerSound';
/** 🐸 theme toggle + 🔊 jingle toggle, rendered among the sidebar tool links. */
export const FrogToggle: React.FC = () => {
const { enabled, toggle, soundOn, toggleSound } = useMidsummer();
return (
<>
<span
className="ml-tool-link"
style={{ cursor: 'pointer' }}
title={enabled ? 'Turn off the midsummer theme' : 'Turn on the midsummer theme'}
onClick={toggle}
>
🐸 Midsommar {enabled ? 'on' : 'off'}
</span>
{enabled && (
<span
className="ml-tool-link"
style={{ cursor: 'pointer' }}
title={soundOn ? 'Mute the Små grodorna jingle' : 'Unmute the Små grodorna jingle'}
onClick={() => {
const turningOn = !soundOn;
toggleSound();
if (turningOn) playSmaGrodorna(); // this click is a user gesture
}}
>
{soundOn ? '🔊' : '🔇'} Jingle
</span>
)}
</>
);
};
```
- [ ] **Step 3: Mount it in the sidebar tool links**
In `frontend/src/components/sidebar/SidebarWindowButtons.tsx`, add the import at the top:
```tsx
import { FrogToggle } from '../midsummer/FrogToggle';
```
Then add `<FrogToggle />` as the first child inside the `<div className="ml-tool-links">` (immediately before the `🤖 Assistant` span):
```tsx
<div className="ml-tool-links">
<FrogToggle />
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('agent', 'Overlord Assistant')}>🤖 Assistant</span>
```
- [ ] **Step 4: Verify**
Run: `cd frontend && npm run build` → expect success.
In the browser: the sidebar shows `🐸 Midsommar on` and `🔊 Jingle`. Click `🐸` → theme turns off, label becomes `Midsommar off`, the `🔊 Jingle` link disappears, and the page reverts to the base dark theme. Click again → back on, preference survives reload.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/components/midsummer/FrogToggle.tsx frontend/src/components/sidebar/SidebarWindowButtons.tsx frontend/src/hooks/useMidsummerSound.ts
git commit -m "feat(midsummer): sidebar frog toggle + jingle toggle (sound stubbed)"
```
---
## Task 4: Dancing maypole on the map
**Files:**
- Create: `frontend/src/components/midsummer/Maypole.tsx`
- Modify: `frontend/src/styles/midsummer.css`
- Modify: `frontend/src/components/map/MapView.tsx`
- [ ] **Step 1: Create the Maypole component**
Create `frontend/src/components/midsummer/Maypole.tsx`:
```tsx
import React from 'react';
import { useMidsummer } from '../../hooks/useMidsummer';
interface Props {
imgW: number;
imgH: number;
}
// Kept small for perf — these orbit the pole via one CSS animation.
const FROG_COUNT = 6;
// Default: dead centre of the Dereth map image. To plant at a landmark,
// import { worldToPx } from '../../utils/coordinates' and compute from
// world coords instead.
const center = (imgW: number, imgH: number) => ({ x: imgW / 2, y: imgH / 2 });
/**
* Midsommarstång planted inside the map's pan/zoom group, so it scales and
* pans with the world automatically. Carries its own ring of dancing frogs
* (one CSS rotation) so the spectacle is independent of who is online.
*/
export const Maypole: React.FC<Props> = ({ imgW, imgH }) => {
const { enabled } = useMidsummer();
if (!enabled || imgW === 0) return null;
const { x, y } = center(imgW, imgH);
return (
<div className="ms-maypole" style={{ left: x, top: y }} aria-hidden="true">
<div className="ms-maypole-pole" />
<div className="ms-maypole-ring">
{Array.from({ length: FROG_COUNT }).map((_, i) => (
<span
key={i}
className="ms-frog"
style={{ transform: `rotate(${(360 / FROG_COUNT) * i}deg) translateY(-40px)` }}
>
🐸
</span>
))}
</div>
</div>
);
};
```
- [ ] **Step 2: Append maypole styles**
Append to `frontend/src/styles/midsummer.css`:
```css
.ms-maypole {
position: absolute;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 6;
}
.ms-maypole-pole {
position: absolute;
left: -2px;
top: -64px;
width: 4px;
height: 70px;
background: #6b4f2a;
border-radius: 2px;
}
.ms-maypole-pole::before {
content: '';
position: absolute;
top: 4px;
left: -16px;
width: 36px;
height: 4px;
background: #3b6d11;
border-radius: 2px;
}
.ms-maypole-pole::after {
content: '🌼';
position: absolute;
top: -16px;
left: -8px;
font-size: 16px;
line-height: 1;
}
.ms-maypole-ring {
position: absolute;
left: 0;
top: -30px;
width: 0;
height: 0;
animation: ms-spin 12s linear infinite;
}
.ms-frog {
position: absolute;
font-size: 13px;
line-height: 1;
}
@keyframes ms-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.ms-maypole-ring { animation: none; }
}
```
- [ ] **Step 3: Mount the maypole inside the map group**
In `frontend/src/components/map/MapView.tsx`, add the import near the other layer imports:
```tsx
import { Maypole } from '../midsummer/Maypole';
```
Then, inside the `{imgSize.w > 0 && ( … )}` block, add `<Maypole>` as the last layer after `<PortalMarkers … />`:
```tsx
<PortalMarkers imgW={imgSize.w} imgH={imgSize.h} enabled={showPortals} />
<Maypole imgW={imgSize.w} imgH={imgSize.h} />
```
- [ ] **Step 4: Verify**
Run: `cd frontend && npm run build` → expect success.
In the browser with the theme on: a maypole with a flower on top and 6 frogs orbiting it sits at the centre of the Dereth map. Pan and zoom the map → the maypole stays pinned to the same map location and scales with the world. Toggle theme off → maypole disappears. In DevTools, emulate `prefers-reduced-motion: reduce` → frogs stop orbiting (pole still shown).
- [ ] **Step 5: Commit**
```bash
git add frontend/src/components/midsummer/Maypole.tsx frontend/src/styles/midsummer.css frontend/src/components/map/MapView.tsx
git commit -m "feat(midsummer): dancing maypole pinned to map centre"
```
---
## Task 5: Frog + flower-crown player dots
**Files:**
- Modify: `frontend/src/styles/midsummer.css`
- [ ] **Step 1: Append dot decoration styles**
Append to `frontend/src/styles/midsummer.css`:
```css
:root[data-midsummer] .ml-dot {
overflow: visible;
}
:root[data-midsummer] .ml-dot::before {
content: '🌸';
position: absolute;
left: 50%;
top: -9px;
transform: translateX(-50%);
font-size: 9px;
line-height: 1;
pointer-events: none;
}
:root[data-midsummer] .ml-dot.ml-dot-selected::after {
content: '🐸';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 15px;
line-height: 1;
pointer-events: none;
}
```
- [ ] **Step 2: Verify**
Run: `cd frontend && npm run build` → expect success.
In the browser with players online: every dot wears a small flower crown above it. Click a dot to select it → a frog appears on it (the base blink animation still runs). Toggle theme off → dots return to plain markers.
(If no players are online in the dev environment, point the dev server's `/api` proxy at the live backend or verify against production after deploy — the CSS is data-independent.)
- [ ] **Step 3: Commit**
```bash
git add frontend/src/styles/midsummer.css
git commit -m "feat(midsummer): flower-crown dots, frog on selected"
```
---
## Task 6: Glad midsummer banner + confetti
**Files:**
- Create: `frontend/src/components/midsummer/confetti.ts`
- Create: `frontend/src/components/midsummer/MidsummerBanner.tsx`
- Modify: `frontend/src/styles/midsummer.css`
- Modify: `frontend/src/components/map/MapLayout.tsx`
- Modify: `frontend/src/components/PlayerDashboardFullPage.tsx`
- [ ] **Step 1: Create the confetti helper**
Create `frontend/src/components/midsummer/confetti.ts`:
```ts
const EMOJIS = ['🐸', '🌼', '🌸', '🥂', '🌿'];
/** One-shot falling-emoji burst. Self-removes after the animation. */
export function burstConfetti(count = 28): void {
const layer = document.createElement('div');
layer.className = 'ms-confetti';
for (let i = 0; i < count; i++) {
const p = document.createElement('span');
p.textContent = EMOJIS[i % EMOJIS.length];
p.style.left = Math.floor(Math.random() * 100) + 'vw';
p.style.animationDelay = (Math.random() * 0.6).toFixed(2) + 's';
p.style.fontSize = 12 + Math.floor(Math.random() * 14) + 'px';
layer.appendChild(p);
}
document.body.appendChild(layer);
window.setTimeout(() => layer.remove(), 4200);
}
```
- [ ] **Step 2: Create the banner component**
Create `frontend/src/components/midsummer/MidsummerBanner.tsx`:
```tsx
import React, { useEffect } from 'react';
import { useMidsummer } from '../../hooks/useMidsummer';
import { burstConfetti } from './confetti';
const CONFETTI_FLAG = 'mo-midsummer-confetti';
/** Festive top strip + a one-shot confetti burst on the first load of a
* session while the theme is on. */
export const MidsummerBanner: React.FC = () => {
const { enabled } = useMidsummer();
useEffect(() => {
if (!enabled) return;
if (sessionStorage.getItem(CONFETTI_FLAG)) return;
sessionStorage.setItem(CONFETTI_FLAG, '1');
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
burstConfetti();
}
}, [enabled]);
if (!enabled) return null;
return (
<div className="ms-banner" role="status">
🐸 Glad midsommar! 🌼 Små grodorna, små grodorna… 🥂
</div>
);
};
```
- [ ] **Step 3: Append banner + confetti styles**
Append to `frontend/src/styles/midsummer.css`:
```css
.ms-banner {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
z-index: 50;
margin-top: 6px;
padding: 4px 16px;
border-radius: 14px;
background: rgba(20, 64, 31, 0.92);
border: 1px solid #7ed957;
color: #eafbe0;
font-size: 0.8rem;
white-space: nowrap;
pointer-events: none;
}
.ms-confetti {
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 999998;
}
.ms-confetti span {
position: absolute;
top: -32px;
line-height: 1;
animation: ms-fall 3.6s linear forwards;
}
@keyframes ms-fall {
to { transform: translateY(112vh) rotate(360deg); opacity: 0.25; }
}
```
- [ ] **Step 4: Mount the banner in the map layout**
In `frontend/src/components/map/MapLayout.tsx`, add the import:
```tsx
import { MidsummerBanner } from '../midsummer/MidsummerBanner';
```
Add `<MidsummerBanner />` as the first child inside `<div className="ml-layout">`:
```tsx
<div className="ml-layout">
<MidsummerBanner />
<Sidebar
```
- [ ] **Step 5: Mount the banner on the dashboard page**
In `frontend/src/components/PlayerDashboardFullPage.tsx`, add the import:
```tsx
import { MidsummerBanner } from './midsummer/MidsummerBanner';
```
Add `<MidsummerBanner />` as the first child inside the `<div className="ml-dashboard-page">`:
```tsx
<div className="ml-dashboard-page">
<MidsummerBanner />
<header className="ml-dashboard-header">
```
- [ ] **Step 6: Verify**
Run: `cd frontend && npm run build` → expect success.
In the browser with the theme on, on first load of a fresh tab: a "Glad midsummer!" banner shows at the top and a one-shot emoji confetti burst falls once. Reload in the same tab → banner persists, confetti does NOT re-fire (sessionStorage guard). Open a new tab → confetti fires again. Toggle theme off → banner disappears.
- [ ] **Step 7: Commit**
```bash
git add frontend/src/components/midsummer/confetti.ts frontend/src/components/midsummer/MidsummerBanner.tsx frontend/src/styles/midsummer.css frontend/src/components/map/MapLayout.tsx frontend/src/components/PlayerDashboardFullPage.tsx
git commit -m "feat(midsummer): glad midsommar banner + one-shot confetti"
```
---
## Task 7: Frog-hop easter egg (replaces the rickroll)
**Files:**
- Modify: `frontend/src/components/map/Sidebar.tsx`
- Modify: `frontend/src/styles/midsummer.css`
- [ ] **Step 1: Replace the rickroll onClick with the frog-hop**
In `frontend/src/components/map/Sidebar.tsx`, replace the entire `<span className="ml-sidebar-title" …>` element (the title click handler that currently builds the `/rick.mp4` overlay, roughly lines 62-80) with:
```tsx
<span className="ml-sidebar-title" style={{ cursor: 'pointer' }} onClick={() => {
// 🐸 Små grodorna hop — bounce the whole layout and send frogs
// leaping up the screen. Replaces the old rickroll.
const layout = document.querySelector('.ml-layout') as HTMLElement | null;
if (layout) {
layout.classList.remove('ms-hop');
void layout.offsetWidth; // force reflow so the animation restarts
layout.classList.add('ms-hop');
}
const frogs = document.createElement('div');
frogs.className = 'ms-hop-frogs';
for (let i = 0; i < 9; i++) {
const f = document.createElement('span');
f.textContent = '🐸';
f.style.left = (i * 11 + 3) + 'vw';
f.style.animationDelay = (i * 0.07).toFixed(2) + 's';
frogs.appendChild(f);
}
document.body.appendChild(frogs);
window.setTimeout(() => {
layout?.classList.remove('ms-hop');
frogs.remove();
}, 2600);
}}>Active Mosswart Enjoyers ({players.length})</span>
```
- [ ] **Step 2: Append the hop styles**
Append to `frontend/src/styles/midsummer.css`:
```css
.ml-layout.ms-hop {
animation: ms-bounce 0.6s ease-in-out 3;
}
@keyframes ms-bounce {
0%, 100% { transform: translateY(0); }
20% { transform: translateY(-16px); }
40% { transform: translateY(0); }
60% { transform: translateY(-9px); }
80% { transform: translateY(0); }
}
.ms-hop-frogs {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 999999;
}
.ms-hop-frogs span {
position: absolute;
bottom: -40px;
font-size: 34px;
line-height: 1;
animation: ms-hop-up 2.4s ease-in forwards;
}
@keyframes ms-hop-up {
to { bottom: 114vh; transform: rotate(18deg); }
}
@media (prefers-reduced-motion: reduce) {
.ml-layout.ms-hop { animation: none; }
.ms-hop-frogs span { animation-duration: 0.01s; }
}
```
> The hop classes are NOT scoped under `:root[data-midsummer]` on purpose — the easter egg works regardless of the theme toggle (it's a gag, not a palette). The `.ml-layout` bounce class is applied directly by the click handler.
- [ ] **Step 3: Verify the rickroll is gone**
Run: `cd frontend && npm run build` → expect success.
In the browser, open DevTools Network, click the "Active Mosswart Enjoyers" title: expect the whole layout to bounce and ~9 frogs to leap up the screen, then clean up after ~2.6s. Confirm NO request for `/rick.mp4` is made. Click again → it re-triggers cleanly without stacking.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/components/map/Sidebar.tsx frontend/src/styles/midsummer.css
git commit -m "feat(midsummer): frog-hop easter egg replaces the rickroll"
```
---
## Task 8: Små grodorna jingle (WebAudio synth)
**Files:**
- Modify (replace stub): `frontend/src/hooks/useMidsummerSound.ts`
- Modify: `frontend/src/components/map/MapLayout.tsx`
- Modify: `frontend/src/components/PlayerDashboardFullPage.tsx`
- [ ] **Step 1: Replace the stub with the full synth + gesture hook**
Replace the entire contents of `frontend/src/hooks/useMidsummerSound.ts` with:
```ts
import { useEffect } from 'react';
import { useMidsummer } from './useMidsummer';
const JINGLE_FLAG = 'mo-midsummer-jingle';
let ctx: AudioContext | null = null;
// Public-domain "Små grodorna" opening phrase (approximation), as
// [frequencyHz, durationSeconds]. Cheerful major-key triangle tones.
const MELODY: [number, number][] = [
[392, 0.22], [392, 0.22], [392, 0.22], [440, 0.22], [494, 0.42],
[494, 0.22], [440, 0.22], [494, 0.22], [523, 0.22], [587, 0.5],
];
/** Play the jingle once. Safe to call from any user gesture. No-op if
* WebAudio is unavailable. Reuses a single AudioContext (no leak). */
export function playSmaGrodorna(): void {
try {
const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
if (!AC) return;
if (!ctx) ctx = new AC();
if (ctx.state === 'suspended') void ctx.resume();
let t = ctx.currentTime + 0.05;
for (const [freq, dur] of MELODY) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(0.18, t + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t + dur);
osc.connect(gain).connect(ctx.destination);
osc.start(t);
osc.stop(t + dur);
t += dur;
}
} catch {
/* audio not available — ignore */
}
}
/** Plays the jingle once per session, on the first user gesture, when the
* theme and sound are both on. Browsers block audio before a gesture, so
* we wait for the first pointerdown/keydown. */
export function useMidsummerSound(): void {
const { enabled, soundOn } = useMidsummer();
useEffect(() => {
if (!enabled || !soundOn) return;
if (sessionStorage.getItem(JINGLE_FLAG)) return;
const fire = () => {
sessionStorage.setItem(JINGLE_FLAG, '1');
playSmaGrodorna();
cleanup();
};
const cleanup = () => {
window.removeEventListener('pointerdown', fire);
window.removeEventListener('keydown', fire);
};
window.addEventListener('pointerdown', fire);
window.addEventListener('keydown', fire);
return cleanup;
}, [enabled, soundOn]);
}
```
- [ ] **Step 2: Call the hook in the map layout**
In `frontend/src/components/map/MapLayout.tsx`, add the import:
```tsx
import { useMidsummerSound } from '../../hooks/useMidsummerSound';
```
Inside `MapLayout`, call the hook near the top of the component body (e.g. right after `const getColor = usePlayerColors();`):
```tsx
const getColor = usePlayerColors();
useMidsummerSound();
```
- [ ] **Step 3: Call the hook on the dashboard page**
In `frontend/src/components/PlayerDashboardFullPage.tsx`, add the import:
```tsx
import { useMidsummerSound } from '../hooks/useMidsummerSound';
```
Call it near the top of the `PlayerDashboardFullPage` component body (e.g. right after `const data = useLiveData();`):
```tsx
const data = useLiveData();
useMidsummerSound();
```
- [ ] **Step 4: Verify**
Run: `cd frontend && npm run build` → expect success.
In a fresh tab with theme + sound on: the *Små grodorna* phrase plays once on your first click anywhere. Reload → it does NOT replay (sessionStorage guard). Click `🔊 Jingle` to mute → label becomes `🔇`; click again to unmute → it plays immediately as confirmation. Toggle theme off then on → no audio-context errors in console across repeated toggles.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/hooks/useMidsummerSound.ts frontend/src/components/map/MapLayout.tsx frontend/src/components/PlayerDashboardFullPage.tsx
git commit -m "feat(midsummer): WebAudio Sma grodorna jingle, plays once on first gesture"
```
---
## Task 9: Build, deploy, verify in production
**Files:** none (deploy only)
- [ ] **Step 1: Full production build via the deploy script**
From the repo root:
Run: `bash deploy-frontend.sh`
Expected: it runs `npm run build`, copies `_build/` into `static/`, removes `_build/`. No errors.
- [ ] **Step 2: Commit the built static assets and push**
```bash
git add static/ frontend/
git commit -m "build(midsummer): deploy Sma grodorna theme to static bundle"
git push origin master
```
- [ ] **Step 3: Pull on the host (bind-mounted static, no restart)**
Run: `ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull --ff-only origin master"`
Expected: fast-forward updating `static/`.
- [ ] **Step 4: Verify in production**
Hard-refresh `https://overlord.snakedesert.se/` (Ctrl+Shift+R). Expect: pond-green theme on by default, maypole dancing at map centre, crowned dots, banner + confetti on first load, jingle on first click, `🐸 Midsommar on` toggle in the sidebar. Toggle off → base dark theme returns. Open `/?view=dashboard` → banner shows there too. Click the sidebar title → frog-hop, no `/rick.mp4` request.
- [ ] **Step 5: Confirmation**
No further commit. Report the deployed state to the user and confirm the 🐸 toggle defaults on.
---
## Self-review (completed during planning)
- **Spec coverage:** scoped overlay + state (Tasks 12), 🐸 toggle default-on (Task 3), dancing maypole at map centre (Task 4), frog/crown dots (Task 5), banner + confetti (Task 6), frog-hop replacing rickroll (Task 7), play-once unmuted WebAudio jingle with 🔇 control (Task 8), dashboard-page parity (Tasks 6 & 8), deploy (Task 9). All spec sections map to a task.
- **Type consistency:** `useMidsummer()` returns `{ enabled, toggle, soundOn, toggleSound }` — consumed identically in FrogToggle, Maypole, MidsummerBanner, useMidsummerSound. `playSmaGrodorna()` is stubbed in Task 3 and fully defined in Task 8 with the same signature `(): void`. `burstConfetti(count?)` and CSS class names (`ms-maypole`, `ms-banner`, `ms-confetti`, `ms-hop`, `ms-hop-frogs`, `ms-frog`) are consistent between the TS that adds them and the CSS that styles them.
- **Placeholder scan:** every code step contains complete code; no TBD/TODO.
- **Decisions honored:** maypole at map centre (constant), jingle plays once, toggle default on, no auto-date-gating.

View file

@ -1,433 +0,0 @@
# Inventory Search Spell Filters Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement the `legendary_cantrips`, `spell_contains`, and `has_spell` query params in the Go inventory service so the existing (currently dead) UI spell filters work, with AND semantics across checked cantrips.
**Architecture:** Match filter names against the in-memory spell enum map (`Server.spells`, loaded at boot from `comprehensive_enum_database_v2.json`) to obtain spell IDs, then emit `EXISTS (... spell_id IN (...))` conditions against the existing `item_spells` table — one per checked cantrip, ANDed — as ordinary WHERE conditions in `runSearch` so they compose with all other filters, sorting, pagination, and the count query. Spec: `docs/superpowers/specs/2026-07-14-inventory-spell-jewelry-filters-design.md`.
**Tech Stack:** Go 1.25, pgx/v5, plain `net/http`. No local Go toolchain exists — tests run in a throwaway `golang:1.25-bookworm` container on the deploy server (`overlord.snakedesert.se`); the Dockerfile's `RUN go test ./...` gates every image build.
**Working directory for all git commands:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`
**How to run tests (no local Go):** sync the source to a scratch dir on the server, run tests in Docker:
```bash
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
tar czf - go-services | ssh erik@overlord.snakedesert.se \
"rm -rf /tmp/tdd-verify && mkdir -p /tmp/tdd-verify && tar xzf - -C /tmp/tdd-verify" && \
ssh erik@overlord.snakedesert.se \
"docker run --rm -v /tmp/tdd-verify/go-services/inventory-go:/src -w /src golang:1.25-bookworm \
sh -c 'go mod tidy >/dev/null 2>&1; go test ./... -v -run <PATTERN> 2>&1'"
```
Replace `<PATTERN>` per step (use `.` for all tests). First run pulls modules (~30 s).
---
### Task 1: Spell name → ID matchers
The three lookup functions that translate UI names into spell IDs, mirroring the legacy Python matching rules (`inventory-service/main.py:3355-3427`).
**Files:**
- Create: `go-services/inventory-go/spell_filter.go`
- Test: `go-services/inventory-go/spell_filter_test.go` (create)
- [ ] **Step 1: Write the failing tests**
Create `go-services/inventory-go/spell_filter_test.go`:
```go
package main
import (
"reflect"
"testing"
)
// Minimal stand-in for the enum-DB spell map (Server.spells).
var testSpells = map[int]map[string]any{
1: {"name": "Legendary Invulnerability"},
2: {"name": "Epic Invulnerability"},
3: {"name": "Legendary Summoning Prowess"},
4: {"name": "Strength Other VI"},
5: {"name": "Summoning"}, // shorter name CONTAINED IN a cantrip label
6: {"name": ""}, // malformed entry must never match
}
func TestSpellIDsContaining(t *testing.T) {
if got := spellIDsContaining(testSpells, "invulnerability"); !reflect.DeepEqual(got, []int{1, 2}) {
t.Errorf("substring match = %v, want [1 2]", got)
}
if got := spellIDsContaining(testSpells, "INVULN"); !reflect.DeepEqual(got, []int{1, 2}) {
t.Errorf("case-insensitive match = %v, want [1 2]", got)
}
if got := spellIDsContaining(testSpells, "frostbite"); got != nil {
t.Errorf("no-match = %v, want nil", got)
}
}
func TestSpellIDsForCantrip(t *testing.T) {
// Forward direction: spell name contains the cantrip label.
if got := spellIDsForCantrip(testSpells, "Invulnerability"); !reflect.DeepEqual(got, []int{1, 2}) {
t.Errorf("forward contains = %v, want [1 2]", got)
}
// Both directions (legacy flexible rule): "Legendary Summoning Prowess"
// matches spell 3 (equal) AND spell 5 ("Summoning" is contained in the label).
if got := spellIDsForCantrip(testSpells, "Legendary Summoning Prowess"); !reflect.DeepEqual(got, []int{3, 5}) {
t.Errorf("both-direction = %v, want [3 5]", got)
}
if got := spellIDsForCantrip(testSpells, "Piercing Bane"); got != nil {
t.Errorf("no-match = %v, want nil", got)
}
}
func TestExactSpellID(t *testing.T) {
if id, ok := exactSpellID(testSpells, "legendary invulnerability"); !ok || id != 1 {
t.Errorf("exact case-insensitive = (%d,%v), want (1,true)", id, ok)
}
if _, ok := exactSpellID(testSpells, "Invulnerability"); ok {
t.Error("partial name must not exact-match")
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run the SSH+Docker test command above with `-run 'TestSpellIDsContaining|TestSpellIDsForCantrip|TestExactSpellID'`.
Expected: `FAIL ... [build failed]` with `undefined: spellIDsContaining` (and the other two names).
- [ ] **Step 3: Write the implementation**
Create `go-services/inventory-go/spell_filter.go`:
```go
package main
import (
"sort"
"strings"
)
// Spell-filter support for /search/items: implements the has_spell /
// spell_contains / legendary_cantrips params the Go port had ignored
// (design doc 2026-07-14; legacy inventory-service/main.py:3345-3452).
// Names are matched against the in-memory enum-DB spell map; the resulting
// IDs are filtered in SQL against the item_spells table.
// spellIDsContaining returns the IDs of all spells whose name contains q,
// case-insensitively, in ascending order (deterministic SQL args).
func spellIDsContaining(spells map[int]map[string]any, q string) []int {
q = strings.ToLower(strings.TrimSpace(q))
var ids []int
for id, sp := range spells {
name, _ := sp["name"].(string)
if name != "" && strings.Contains(strings.ToLower(name), q) {
ids = append(ids, id)
}
}
sort.Ints(ids)
return ids
}
// spellIDsForCantrip matches one cantrip display name using the legacy
// flexible rule: spell name contains the label OR the label contains the
// spell name (both case-insensitive).
func spellIDsForCantrip(spells map[int]map[string]any, cantrip string) []int {
c := strings.ToLower(strings.TrimSpace(cantrip))
var ids []int
for id, sp := range spells {
name, _ := sp["name"].(string)
n := strings.ToLower(name)
if n == "" {
continue
}
if strings.Contains(n, c) || strings.Contains(c, n) {
ids = append(ids, id)
}
}
sort.Ints(ids)
return ids
}
// exactSpellID returns the lowest ID whose name equals q case-insensitively.
func exactSpellID(spells map[int]map[string]any, q string) (int, bool) {
q = strings.ToLower(strings.TrimSpace(q))
best := -1
for id, sp := range spells {
name, _ := sp["name"].(string)
if strings.ToLower(name) == q && (best == -1 || id < best) {
best = id
}
}
return best, best != -1
}
```
- [ ] **Step 4: Run tests to verify they pass**
Same command as Step 2. Expected: all three tests `PASS`, plus the pre-existing suite still `ok`.
- [ ] **Step 5: Commit**
```bash
git add go-services/inventory-go/spell_filter.go go-services/inventory-go/spell_filter_test.go
git commit -m "feat(inventory-go): spell name -> ID matchers for search spell filters"
```
---
### Task 2: SQL condition builder
Turns the three query params into WHERE conditions using the Task 1 matchers and the existing `argBuilder` (`search.go:139`) for positional args.
**Files:**
- Modify: `go-services/inventory-go/spell_filter.go`
- Test: `go-services/inventory-go/spell_filter_test.go`
- [ ] **Step 1: Write the failing tests**
Append to `go-services/inventory-go/spell_filter_test.go`:
```go
func TestSpellFilterConds_Cantrips(t *testing.T) {
ab := &argBuilder{}
q := map[string][]string{"legendary_cantrips": {"Legendary Invulnerability, Legendary Summoning Prowess"}}
conds := spellFilterConds(q, testSpells, ab)
want := []string{
"EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($1))",
"EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($2, $3))",
}
if !reflect.DeepEqual(conds, want) {
t.Errorf("conds = %v, want %v", conds, want)
}
// "Legendary Invulnerability" exact-contains only id 1; the Summoning
// label matches 3 and 5. AND semantics = one EXISTS per cantrip.
if wantArgs := []any{1, 3, 5}; !reflect.DeepEqual(ab.args, wantArgs) {
t.Errorf("args = %v, want %v", ab.args, wantArgs)
}
}
func TestSpellFilterConds_NoMatchIsImpossible(t *testing.T) {
ab := &argBuilder{}
q := map[string][]string{"legendary_cantrips": {"Legendary Frostbite"}}
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, []string{"1 = 0"}) {
t.Errorf("unknown cantrip conds = %v, want [1 = 0]", conds)
}
ab = &argBuilder{}
q = map[string][]string{"spell_contains": {"frostbite"}}
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, []string{"1 = 0"}) {
t.Errorf("unknown spell_contains conds = %v, want [1 = 0]", conds)
}
}
func TestSpellFilterConds_ContainsAndHasSpell(t *testing.T) {
ab := &argBuilder{}
q := map[string][]string{"spell_contains": {"invulnerability"}}
want := []string{"EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($1, $2))"}
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, want) {
t.Errorf("spell_contains conds = %v, want %v", conds, want)
}
ab = &argBuilder{}
q = map[string][]string{"has_spell": {"Epic Invulnerability"}}
want = []string{"EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($1))"}
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, want) {
t.Errorf("has_spell conds = %v, want %v", conds, want)
}
if _, ok := exactSpellID(testSpells, "No Such Spell"); ok {
t.Fatal("precondition")
}
ab = &argBuilder{}
q = map[string][]string{"has_spell": {"No Such Spell"}}
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, []string{"1 = 0"}) {
t.Errorf("unknown has_spell conds = %v, want [1 = 0]", conds)
}
}
func TestSpellFilterConds_Empty(t *testing.T) {
ab := &argBuilder{}
if conds := spellFilterConds(map[string][]string{}, testSpells, ab); conds != nil {
t.Errorf("no params => nil conds, got %v", conds)
}
}
```
Note: `spellFilterConds` takes `url.Values` (which IS `map[string][]string`), so the literal maps above compile directly.
- [ ] **Step 2: Run tests to verify they fail**
Run with `-run 'TestSpellFilterConds'`. Expected: `FAIL ... [build failed]` with `undefined: spellFilterConds`.
- [ ] **Step 3: Write the implementation**
Append to `go-services/inventory-go/spell_filter.go` (add `"net/url"` to its imports):
```go
// spellExists renders the EXISTS clause for a set of spell IDs, binding each
// ID as a positional arg. ids must be non-empty.
func spellExists(ids []int, ab *argBuilder) string {
ph := make([]string, len(ids))
for i, id := range ids {
ph[i] = ab.add(id)
}
return "EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN (" + strings.Join(ph, ", ") + "))"
}
// spellFilterConds builds the WHERE conditions for has_spell, spell_contains,
// and legendary_cantrips. Cantrips use AND semantics — one EXISTS per checked
// cantrip (NOT the legacy COUNT>=N trick, which can false-positive when one
// label matches two spells on an item while another matches none). Any
// no-match filter yields the impossible condition, matching legacy intent.
func spellFilterConds(q url.Values, spells map[int]map[string]any, ab *argBuilder) []string {
var conds []string
impossible := func() { conds = append(conds, "1 = 0") }
if v := q.Get("has_spell"); v != "" {
if id, ok := exactSpellID(spells, v); ok {
conds = append(conds, spellExists([]int{id}, ab))
} else {
impossible()
}
}
if v := q.Get("spell_contains"); v != "" {
if ids := spellIDsContaining(spells, v); len(ids) > 0 {
conds = append(conds, spellExists(ids, ab))
} else {
impossible()
}
}
if v := q.Get("legendary_cantrips"); v != "" {
for _, name := range splitNonEmpty(v) {
if ids := spellIDsForCantrip(spells, name); len(ids) > 0 {
conds = append(conds, spellExists(ids, ab))
} else {
impossible()
}
}
}
return conds
}
```
(`splitNonEmpty` and `argBuilder` already exist in `search.go`.)
- [ ] **Step 4: Run tests to verify they pass**
Run with `-run 'TestSpellFilter|TestSpellIDs|TestExactSpell'`. Expected: all `PASS`.
- [ ] **Step 5: Commit**
```bash
git add go-services/inventory-go/spell_filter.go go-services/inventory-go/spell_filter_test.go
git commit -m "feat(inventory-go): build EXISTS conditions for spell filter params"
```
---
### Task 3: Wire into runSearch
**Files:**
- Modify: `go-services/inventory-go/search.go` (inside `runSearch`, after the `slot_names` block at ~line 295-303, before `where := ""`)
- [ ] **Step 1: Add the wiring**
In `search.go`, directly after the `slot_names` block (the `if v := q.Get("slot_names"); v != "" { ... }` closing brace) and before `where := ""`, insert:
```go
// --- spell filters: has_spell / spell_contains / legendary_cantrips ---
conds = append(conds, spellFilterConds(q, s.spells, ab)...)
```
This runs before the LIMIT/OFFSET args are appended, so the count query's `ab.args[:len(ab.args)-2]` slicing stays correct, and the conditions apply identically to the items query and the count query.
- [ ] **Step 2: Run the full suite**
Run the SSH+Docker test command with `-run .` (everything). Expected: all tests `PASS`, `ok` for the package.
- [ ] **Step 3: Commit**
```bash
git add go-services/inventory-go/search.go
git commit -m "feat(inventory-go): wire spell filters into /search/items"
```
---
### Task 4: Deploy and live verification
**Files:** none (operational).
- [ ] **Step 1: Sync source to the server's production checkout**
```bash
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
tar czf - go-services | ssh erik@overlord.snakedesert.se "tar xzf - -C /home/erik/MosswartOverlord/"
```
- [ ] **Step 2: Build (test-gated) and recreate the container**
```bash
ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \
export BUILD_VERSION="$(date -u +%Y.%-m.%-d.%H%M)-$(git rev-parse --short HEAD)" && \
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml build --build-arg BUILD_VERSION=$BUILD_VERSION inventory-go && \
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml -f go-services/docker-compose.cutover.yml \
up -d --no-deps inventory-go'
```
Expected: `RUN go test ./...` passes inside the build; container recreated and `Started`.
- [ ] **Step 3: Live verification (direct against inventory-go on 127.0.0.1:8772)**
AND semantics — every returned item must carry BOTH cantrips:
```bash
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8772/search/items?include_all_characters=true&jewelry_only=true&legendary_cantrips=Legendary%20Invulnerability,Legendary%20Summoning%20Prowess&limit=10'" \
| python -c "import json,sys; d=json.load(sys.stdin); print('total:', d['total_count']); [print(i['name'], '|', i.get('spell_names')) for i in d['items']]"
```
Expected: every listed item's `spell_names` includes both `Legendary Invulnerability` and `Legendary Summoning Prowess`. (If total is 0, relax to a single common cantrip to confirm the mechanism, e.g. only `Legendary Invulnerability`.)
Jewelry-type composition — same query plus `&slot_names=Ring`:
```bash
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8772/search/items?include_all_characters=true&jewelry_only=true&slot_names=Ring&legendary_cantrips=Legendary%20Invulnerability&limit=10'" \
| python -c "import json,sys; d=json.load(sys.stdin); print('total:', d['total_count']); [print(i['name'], '|', i.get('slot_name'), '|', i.get('spell_names')) for i in d['items']]"
```
Expected: only ring-slot items, all carrying Legendary Invulnerability.
`spell_contains`:
```bash
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8772/search/items?include_all_characters=true&spell_contains=Epic%20Invulnerability&limit=5'" \
| python -c "import json,sys; d=json.load(sys.stdin); print('total:', d['total_count']); [print(i['name'], '|', i.get('spell_names')) for i in d['items']]"
```
Expected: items whose `spell_names` include `Epic Invulnerability`; non-zero total if any exist in inventories.
Regression — a filterless jewelry search still works and `total_count` is consistent:
```bash
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8772/search/items?include_all_characters=true&jewelry_only=true&limit=1'" \
| python -c "import json,sys; d=json.load(sys.stdin); print('total:', d['total_count'], 'items:', len(d['items']))"
```
Expected: same total as before the deploy (spot-check it's a plausible large number, not 0).
- [ ] **Step 4: Check container logs for errors**
```bash
ssh erik@overlord.snakedesert.se "docker logs inventory-go --tail 20 2>&1"
```
Expected: normal request logs, no SQL errors.
- [ ] **Step 5: Push**
```bash
git push origin master
```
(Server checkout already matches via the tar sync; the push keeps git as source of truth.)

View file

@ -1,986 +0,0 @@
# Inventory Search Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace `static/inventory.html`/`inventory.js` with a dark-themed React full-page inventory search at `/?view=inventory` (facet sidebar, chips, instant search, detail panel), per the approved spec + mockup.
**Architecture:** New self-contained component tree under `frontend/src/components/inventory/` with a single `useInventorySearch` hook owning filter state, debounce, aborting fetch, and URL serialization. Zero backend changes — everything talks to the existing `GET /api/inv/search/items` (plus `/api/inv/characters/list`, `/api/inv/sets/list`, `/api/live`). Spec: `docs/superpowers/specs/2026-07-15-inventory-search-redesign-design.md`; visual ground truth: `docs/superpowers/specs/2026-07-15-inventory-search-redesign-mockup.html` (styling/spacing/colors MUST follow it).
**Tech Stack:** React 19 + TypeScript + Vite (existing app, NO new npm deps). No test framework exists — per-task verification is `npm run build` (runs `tsc -b`) green; live verification is the final task. npm exists on this Windows machine; run builds via the Bash tool.
**Working directory for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`
**Build command (every task):**
```bash
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord/frontend" && npm run build
```
(If `node_modules` is missing, run `npm install` once first.)
**API cheat-sheet (verified live):**
- `GET /api/inv/search/items?...``{items:[...], total_count, page, limit, has_next, has_previous}`. Item fields used here: `name, character_name, slot_name, value, burden, wield_level, workmanship, spell_names (string[]), is_equipped, is_bonded, is_attuned, is_rare, item_set_name, armor_level, max_damage, condition_percent, object_class_name, last_updated`.
- Params: `text, character, characters, include_all_characters, armor_only, jewelry_only, weapon_only, weapon_type, clothing_only, shirt_only, pants_only, slot_names, legendary_cantrips (CSV, AND), spell_contains, equipment_status (equipped|unequipped), bonded, attuned, is_rare, item_set, min_* rating params, max_level, min_value, min_workmanship, max_burden, sort_by, sort_dir, page, limit`.
- `GET /api/inv/characters/list``{characters:[{character_name, item_count, last_updated}]}`
- `GET /api/inv/sets/list``{sets:[{id, item_count, name}]}` — display `id` (e.g. "Defender's Set"); the `name` field has a broken "Unknown Set " prefix, ignore it. Send `item_set=<id>`.
- `GET /api/live``{players:[{character_name, ...}]}` — online characters.
---
### Task 1: Types + constants module
**Files:**
- Create: `frontend/src/components/inventory/types.ts`
- Create: `frontend/src/components/inventory/constants.ts`
- [ ] **Step 1: Create `types.ts`:**
```ts
// Item shape returned by /api/inv/search/items (subset we render).
export interface InvItem {
name: string;
character_name: string;
slot_name: string | null;
value: number | null;
burden: number | null;
wield_level: number | null;
workmanship: number | null;
spell_names?: string[];
is_equipped: boolean;
is_bonded: boolean;
is_attuned: boolean;
is_rare: boolean;
item_set_name?: string;
armor_level: number | null;
max_damage: number | null;
condition_percent: number | null;
object_class_name?: string;
material_name?: string;
last_updated?: string;
}
export interface SearchResponse {
items: InvItem[];
total_count: number;
page: number;
limit: number;
has_next: boolean;
has_previous: boolean;
error?: string;
}
export type ItemType = 'all' | 'armor' | 'jewelry' | 'weapon' | 'clothing' | 'shirt' | 'pants';
export interface SearchFilters {
text: string;
/** 'all' → include_all_characters=true; otherwise explicit list. */
characters: 'all' | string[];
itemType: ItemType;
weaponType: string; // '' = all weapon types
slots: string[];
cantrips: string[]; // full "Legendary X" value strings
spellContains: string;
/** min-rating param name -> value ('' = unset). */
ratings: Record<string, number | ''>;
itemSet: string; // set id, '' = none
equippedOnly: boolean;
bonded: boolean;
attuned: boolean;
rare: boolean;
maxLevel: number | '';
minValue: number | '';
minWorkmanship: number | '';
maxBurden: number | '';
sortBy: string;
sortDir: 'asc' | 'desc';
page: number;
}
export const DEFAULT_FILTERS: SearchFilters = {
text: '', characters: 'all', itemType: 'all', weaponType: '', slots: [],
cantrips: [], spellContains: '', ratings: {}, itemSet: '',
equippedOnly: false, bonded: false, attuned: false, rare: false,
maxLevel: '', minValue: '', minWorkmanship: '', maxBurden: '',
sortBy: 'name', sortDir: 'asc', page: 1,
};
```
- [ ] **Step 2: Create `constants.ts`** — the 47 cantrip values are lifted verbatim from the old page's checkboxes (they are what the backend's `legendary_cantrips` matcher expects); labels drop the "Legendary " prefix:
```ts
export interface CantripDef { value: string; label: string; }
export interface CantripGroup { group: string; items: CantripDef[]; }
const c = (value: string): CantripDef => ({ value, label: value.replace(/^Legendary /, '') });
export const CANTRIP_GROUPS: CantripGroup[] = [
{ group: 'Attributes', items: [
c('Legendary Strength'), c('Legendary Endurance'), c('Legendary Quickness'),
c('Legendary Coordination'), c('Legendary Willpower'), c('Legendary Focus'),
]},
{ group: 'Weapon skills', items: [
c('Legendary Heavy Weapon Aptitude'), c('Legendary Light Weapon Aptitude'),
c('Legendary Finesse Weapon Aptitude'), c('Legendary Missile Weapon Aptitude'),
c('Legendary Two Handed Combat Aptitude'), c('Legendary Dual Wield Aptitude'),
c('Legendary Shield Aptitude'), c('Legendary Sneak Attack Prowess'),
c('Legendary Dirty Fighting Prowess'), c('Legendary Recklessness Prowess'),
c('Legendary Defender'), c('Legendary Blood Thirst'),
]},
{ group: 'Magic', items: [
c('Legendary War Magic Aptitude'), c('Legendary Void Magic Aptitude'),
c('Legendary Creature Enchantment Aptitude'), c('Legendary Item Enchantment Aptitude'),
c('Legendary Life Magic Aptitude'), c('Legendary Mana Conversion Prowess'),
c('Legendary Arcane Prowess'), c('Legendary Hermetic Link'),
c('Legendary Spirit Thirst'), c('Legendary Magic Resistance'),
]},
{ group: 'Utility', items: [
c('Legendary Summoning Prowess'), c('Legendary Healing Prowess'),
c('Legendary Leadership'), c('Legendary Deception Prowess'),
c('Legendary Person Attunement'), c('Legendary Magic Item Tinkering Expertise'),
]},
{ group: 'Defense', items: [
c('Legendary Invulnerability'), c('Legendary Impenetrability'),
c('Legendary Impregnability'), c('Legendary Armor'),
]},
{ group: 'Wards & Banes', items: [
c('Legendary Flame Ward'), c('Legendary Frost Ward'), c('Legendary Acid Ward'),
c('Legendary Storm Ward'), c('Legendary Slashing Ward'), c('Legendary Piercing Ward'),
c('Legendary Bludgeoning Ward'), c('Legendary Piercing Bane'), c('Legendary Storm Bane'),
]},
];
export const JEWELRY_SLOTS = ['Ring', 'Bracelet', 'Neck', 'Trinket', 'Cloak'];
export const ARMOR_SLOTS = ['Head', 'Chest', 'Abdomen', 'Upper Arms', 'Lower Arms',
'Hands', 'Upper Legs', 'Lower Legs', 'Feet', 'Shield'];
export const WEAPON_TYPES: Array<{ value: string; label: string }> = [
{ value: '', label: 'All weapons' }, { value: 'heavy', label: 'Heavy' },
{ value: 'light', label: 'Light' }, { value: 'finesse', label: 'Finesse' },
{ value: 'two_handed', label: 'Two-handed' }, { value: 'bow', label: 'Bow' },
{ value: 'crossbow', label: 'Crossbow' }, { value: 'thrown', label: 'Thrown' },
{ value: 'caster', label: 'Wand/Staff/Orb' },
];
export interface RatingDef { param: string; label: string; common?: boolean; }
export const RATING_DEFS: RatingDef[] = [
{ param: 'min_damage_rating', label: 'Damage rating', common: true },
{ param: 'min_crit_damage_rating', label: 'Crit damage', common: true },
{ param: 'min_heal_boost_rating', label: 'Heal boost', common: true },
{ param: 'min_vitality_rating', label: 'Vitality', common: true },
{ param: 'min_armor', label: 'Armor level' },
{ param: 'min_damage_resist_rating', label: 'Damage resist' },
{ param: 'min_crit_resist_rating', label: 'Crit resist' },
{ param: 'min_crit_damage_resist_rating', label: 'Crit dmg resist' },
{ param: 'min_healing_resist_rating', label: 'Healing resist' },
{ param: 'min_nether_resist_rating', label: 'Nether resist' },
{ param: 'min_healing_rating', label: 'Healing rating' },
{ param: 'min_dot_resist_rating', label: 'DoT resist' },
{ param: 'min_life_resist_rating', label: 'Life resist' },
{ param: 'min_sneak_attack_rating', label: 'Sneak attack' },
{ param: 'min_recklessness_rating', label: 'Recklessness' },
{ param: 'min_deception_rating', label: 'Deception' },
{ param: 'min_pk_damage_rating', label: 'PK damage' },
{ param: 'min_pk_damage_resist_rating', label: 'PK dmg resist' },
{ param: 'min_gear_pk_damage_rating', label: 'Gear PK dmg' },
{ param: 'min_gear_pk_damage_resist_rating', label: 'Gear PK resist' },
{ param: 'min_tinks', label: 'Tinks' },
];
export interface ColumnDef {
key: string; label: string; sortKey?: string; defaultVisible: boolean;
}
// sortKey values must exist in the backend's sortMapping (search.go).
export const COLUMNS: ColumnDef[] = [
{ key: 'name', label: 'Item', sortKey: 'name', defaultVisible: true },
{ key: 'character_name', label: 'Character', sortKey: 'character_name', defaultVisible: true },
{ key: 'slot_name', label: 'Slot', defaultVisible: true },
{ key: 'value', label: 'Value', sortKey: 'value', defaultVisible: true },
{ key: 'wield_level', label: 'Wield', sortKey: 'level', defaultVisible: true },
{ key: 'spell_names', label: 'Spells / Cantrips', sortKey: 'spell_names', defaultVisible: true },
{ key: 'armor_level', label: 'Armor', sortKey: 'armor', defaultVisible: false },
{ key: 'max_damage', label: 'Max Dmg', sortKey: 'damage', defaultVisible: false },
{ key: 'workmanship', label: 'Work', sortKey: 'workmanship', defaultVisible: false },
{ key: 'item_set_name', label: 'Set', sortKey: 'item_set', defaultVisible: false },
{ key: 'object_class_name', label: 'Type', sortKey: 'item_type_name', defaultVisible: false },
{ key: 'burden', label: 'Burden', defaultVisible: false },
{ key: 'condition_percent', label: 'Cond %', defaultVisible: false },
{ key: 'last_updated', label: 'Updated', sortKey: 'last_updated', defaultVisible: false },
];
export const PAGE_SIZE = 200;
export const COLUMNS_LS_KEY = 'inv.visibleColumns';
```
- [ ] **Step 3: Build** — expected green (files compile standalone).
- [ ] **Step 4: Commit**
```bash
git add frontend/src/components/inventory/
git commit -m "feat(frontend): inventory search types + filter/column constants"
```
---
### Task 2: useInventorySearch hook
**Files:**
- Create: `frontend/src/components/inventory/useInventorySearch.ts`
- [ ] **Step 1: Create the hook** — owns filters, URL (de)serialization (single `q` JSON param alongside `view=inventory`), 400 ms debounce, AbortController fetch:
```ts
import { useCallback, useEffect, useRef, useState } from 'react';
import { DEFAULT_FILTERS, type SearchFilters, type SearchResponse } from './types';
import { PAGE_SIZE } from './constants';
/** Serialize only the keys that differ from defaults, as a compact q= JSON param. */
function filtersToUrl(f: SearchFilters): void {
const diff: Partial<SearchFilters> = {};
for (const k of Object.keys(DEFAULT_FILTERS) as Array<keyof SearchFilters>) {
if (JSON.stringify(f[k]) !== JSON.stringify(DEFAULT_FILTERS[k])) (diff as any)[k] = f[k];
}
const url = new URL(window.location.href);
url.searchParams.set('view', 'inventory');
if (Object.keys(diff).length) url.searchParams.set('q', JSON.stringify(diff));
else url.searchParams.delete('q');
window.history.replaceState(null, '', url);
}
function filtersFromUrl(): SearchFilters {
try {
const q = new URLSearchParams(window.location.search).get('q');
if (!q) return DEFAULT_FILTERS;
return { ...DEFAULT_FILTERS, ...JSON.parse(q) };
} catch {
return DEFAULT_FILTERS;
}
}
export function buildParams(f: SearchFilters): URLSearchParams {
const p = new URLSearchParams();
if (f.characters === 'all') p.set('include_all_characters', 'true');
else if (f.characters.length === 1) p.set('character', f.characters[0]);
else p.set('characters', f.characters.join(','));
if (f.text) p.set('text', f.text);
switch (f.itemType) {
case 'armor': p.set('armor_only', 'true'); break;
case 'jewelry': p.set('jewelry_only', 'true'); break;
case 'clothing': p.set('clothing_only', 'true'); break;
case 'shirt': p.set('shirt_only', 'true'); break;
case 'pants': p.set('pants_only', 'true'); break;
case 'weapon':
p.set('weapon_only', 'true');
if (f.weaponType) p.set('weapon_type', f.weaponType);
break;
}
if (f.slots.length) p.set('slot_names', f.slots.join(','));
if (f.cantrips.length) p.set('legendary_cantrips', f.cantrips.join(','));
if (f.spellContains) p.set('spell_contains', f.spellContains);
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') p.set(param, String(v));
if (f.itemSet) p.set('item_set', f.itemSet);
if (f.equippedOnly) p.set('equipment_status', 'equipped');
if (f.bonded) p.set('bonded', 'true');
if (f.attuned) p.set('attuned', 'true');
if (f.rare) p.set('is_rare', 'true');
if (f.maxLevel !== '') p.set('max_level', String(f.maxLevel));
if (f.minValue !== '') p.set('min_value', String(f.minValue));
if (f.minWorkmanship !== '') p.set('min_workmanship', String(f.minWorkmanship));
if (f.maxBurden !== '') p.set('max_burden', String(f.maxBurden));
p.set('sort_by', f.sortBy);
p.set('sort_dir', f.sortDir);
p.set('page', String(f.page));
p.set('limit', String(PAGE_SIZE));
return p;
}
export interface InventorySearch {
filters: SearchFilters;
/** Patch filters; resets page to 1 unless the patch itself sets page. */
update: (patch: Partial<SearchFilters>) => void;
reset: () => void;
result: SearchResponse | null;
loading: boolean;
error: string | null;
queryMs: number | null;
}
export function useInventorySearch(): InventorySearch {
const [filters, setFilters] = useState<SearchFilters>(filtersFromUrl);
const [result, setResult] = useState<SearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [queryMs, setQueryMs] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const update = useCallback((patch: Partial<SearchFilters>) => {
setFilters(prev => ({ ...prev, page: 'page' in patch ? prev.page : 1, ...patch }));
}, []);
const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []);
useEffect(() => {
filtersToUrl(filters);
const timer = setTimeout(async () => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
const t0 = performance.now();
try {
const res = await fetch(`/api/inv/search/items?${buildParams(filters)}`, {
credentials: 'include', signal: ctrl.signal,
});
if (!res.ok) throw new Error(`search: HTTP ${res.status}`);
const data: SearchResponse = await res.json();
if (data.error) throw new Error(data.error);
setResult(data);
setError(null);
setQueryMs(Math.round(performance.now() - t0));
} catch (e: any) {
if (e?.name !== 'AbortError') setError(String(e?.message ?? e));
} finally {
if (abortRef.current === ctrl) setLoading(false);
}
}, 400);
return () => clearTimeout(timer);
}, [filters]);
return { filters, update, reset, result, loading, error, queryMs };
}
```
- [ ] **Step 2: Build** — green.
- [ ] **Step 3: Commit**`feat(frontend): useInventorySearch hook (debounced abortable search, URL state)`
---
### Task 3: Routing, page scaffold, CSS
**Files:**
- Modify: `frontend/src/App.tsx`
- Create: `frontend/src/components/inventory/InventorySearchPage.tsx`
- Create: `frontend/src/styles/inventory.css`
- [ ] **Step 1: Route.** In `App.tsx`, add the import and branch (current file branches only on `'dashboard'`):
```tsx
import { InventorySearchPage } from './components/inventory/InventorySearchPage';
import './styles/inventory.css';
```
and change the ternary to:
```tsx
{view === 'dashboard' ? <PlayerDashboardFullPage />
: view === 'inventory' ? <InventorySearchPage />
: <DefaultApp />}
```
Also update the header comment to document `/?view=inventory`.
- [ ] **Step 2: Page scaffold**`InventorySearchPage.tsx`. Full layout with top bar wired to the hook; sidebar/table/detail rendered from components built in Tasks 4-6 (create with placeholder stubs now so this task builds; the stubs are replaced by the real components in their tasks):
```tsx
import { useState } from 'react';
import { useInventorySearch } from './useInventorySearch';
import { FilterSidebar } from './FilterSidebar';
import { ActiveChips } from './ActiveChips';
import { ResultsTable } from './ResultsTable';
import { DetailPanel } from './DetailPanel';
import type { InvItem } from './types';
export function InventorySearchPage() {
const search = useInventorySearch();
const [selected, setSelected] = useState<InvItem | null>(null);
return (
<div className="inv-page">
<div className="inv-topbar">
<span className="inv-title">⚔ Inventory Search</span>
<input
className="inv-searchbox"
placeholder="Search name or material…"
value={search.filters.text}
onChange={e => search.update({ text: e.target.value })}
/>
<button className="inv-btn" onClick={() => { search.reset(); setSelected(null); }}>Reset</button>
<span className="inv-count">
{search.error ? <span className="inv-error">{search.error}</span>
: search.result ? <><b>{search.result.total_count.toLocaleString()}</b> items
{search.queryMs != null && <> · {search.queryMs} ms</>}
{search.loading && ' · …'}</>
: 'loading…'}
</span>
</div>
<ActiveChips search={search} />
<div className="inv-main">
<FilterSidebar search={search} />
<ResultsTable search={search} selected={selected} onSelect={setSelected} />
{selected && <DetailPanel item={selected} onClose={() => setSelected(null)} />}
</div>
</div>
);
}
```
For this task only, create minimal stub files so the build passes (each replaced by its real task):
```tsx
// FilterSidebar.tsx (stub — replaced in Task 4)
import type { InventorySearch } from './useInventorySearch';
export function FilterSidebar(_: { search: InventorySearch }) { return <div className="inv-sidebar" />; }
```
```tsx
// ActiveChips.tsx (stub — replaced in Task 5)
import type { InventorySearch } from './useInventorySearch';
export function ActiveChips(_: { search: InventorySearch }) { return null; }
```
```tsx
// ResultsTable.tsx (stub — replaced in Task 5)
import type { InventorySearch } from './useInventorySearch';
import type { InvItem } from './types';
export function ResultsTable(_: { search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void }) {
return <div className="inv-results" />;
}
```
```tsx
// DetailPanel.tsx (stub — replaced in Task 6)
import type { InvItem } from './types';
export function DetailPanel(_: { item: InvItem; onClose: () => void }) { return null; }
```
- [ ] **Step 3: CSS**`frontend/src/styles/inventory.css`. Port the approved mockup's `<style>` block (see `docs/superpowers/specs/2026-07-15-inventory-search-redesign-mockup.html`) with class names prefixed `inv-` and BEM-ish nesting flattened. Required tokens: page bg `#111`, panels `#1a1a1a`, borders `#333`, accent `#88f`, legendary gold `#fc6` (chip bg `#2a2418`, border `#a80`), selected row bg `#20203a`, detail bg `#161620`, font `"Segoe UI", sans-serif`, sidebar width 210px, detail width 250px, sticky table header on `#191919`. Copy the mockup's rules for: `.topbar→.inv-topbar`, `.searchbox→.inv-searchbox`, `.btn→.inv-btn`, `.chipsrow/.chip→.inv-chipsrow/.inv-chip(.gold)`, `.sidebar/.grp/.grp-head/.grp-body/.badge/.minisearch/.fitem/.subhead/.linky/.range→.inv-*`, table rules (`.inv-results table/th/td`, `tr.inv-sel`, `.inv-leg`, `.inv-spells`, `.inv-equipped`, `.inv-pager/.inv-pg(.cur)`), `.detail→.inv-detail` with `.inv-kv/.inv-sp(.leg)/.inv-sphead/.inv-hint/.inv-closex`. Add `.inv-page{display:flex;flex-direction:column;height:100vh;background:#111;color:#eee;font-family:"Segoe UI",sans-serif}` and `.inv-main{display:flex;flex:1;overflow:hidden}`, `.inv-error{color:#c66}`.
- [ ] **Step 4: Build** — green. Manually sanity-check nothing else broke: `npm run build` output lists the new css/js chunks.
- [ ] **Step 5: Commit**`feat(frontend): /?view=inventory route + inventory page scaffold + dark CSS`
---
### Task 4: FilterSidebar (all filter groups)
**Files:**
- Replace stub: `frontend/src/components/inventory/FilterSidebar.tsx`
- Create: `frontend/src/components/inventory/CharacterFilter.tsx`
- Create: `frontend/src/components/inventory/CantripFilter.tsx`
- [ ] **Step 1: Shared collapsible group.** At the top of `FilterSidebar.tsx`:
```tsx
import { useEffect, useState } from 'react';
import type { InventorySearch } from './useInventorySearch';
import { CharacterFilter } from './CharacterFilter';
import { CantripFilter } from './CantripFilter';
import { ARMOR_SLOTS, JEWELRY_SLOTS, RATING_DEFS, WEAPON_TYPES } from './constants';
import type { ItemType } from './types';
import { apiFetch } from '../../api/client';
export function Group(props: {
title: string; badge?: string | number; defaultOpen?: boolean; children: React.ReactNode;
}) {
const [open, setOpen] = useState(props.defaultOpen ?? false);
return (
<div className={`inv-grp${open ? ' inv-open' : ''}`}>
<div className="inv-grp-head" onClick={() => setOpen(o => !o)}>
<span className="inv-arrow"></span> {props.title}
{props.badge ? <span className="inv-badge">{props.badge}</span> : null}
</div>
{open && <div className="inv-grp-body">{props.children}</div>}
</div>
);
}
```
- [ ] **Step 2: `FilterSidebar` body** — groups in mockup order. Item type radios (All/Armor/Jewelry/Weapons+subtype select/Clothing/Shirts/Pants), Slots (jewelry slots always visible, armor slots behind a "show armor slots" toggle), Ratings (common four + "all ratings" toggle), Sets (fetched once from `/inv/sets/list`, searchable, single-select radio list sending `id`), Item state, Reqs & value:
```tsx
export function FilterSidebar({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [showArmorSlots, setShowArmorSlots] = useState(false);
const [allRatings, setAllRatings] = useState(false);
const [sets, setSets] = useState<Array<{ id: string; item_count: number }>>([]);
const [setQuery, setSetQuery] = useState('');
useEffect(() => {
apiFetch<{ sets: Array<{ id: string; item_count: number }> }>('/inv/sets/list')
.then(r => setSets(r.sets)).catch(() => {});
}, []);
const toggleSlot = (s: string) => update({
slots: f.slots.includes(s) ? f.slots.filter(x => x !== s) : [...f.slots, s],
});
const setRating = (param: string, v: string) => update({
ratings: { ...f.ratings, [param]: v === '' ? '' : Number(v) },
});
const ratingCount = Object.values(f.ratings).filter(v => v !== '').length;
const stateCount = [f.equippedOnly, f.bonded, f.attuned, f.rare].filter(Boolean).length;
const reqCount = [f.maxLevel, f.minValue, f.minWorkmanship, f.maxBurden].filter(v => v !== '').length;
const types: Array<[ItemType, string]> = [['all', 'All items'], ['armor', 'Armor'],
['jewelry', 'Jewelry'], ['weapon', 'Weapons'], ['clothing', 'Clothing'],
['shirt', 'Shirts'], ['pants', 'Pants']];
return (
<div className="inv-sidebar">
<CharacterFilter search={search} />
<Group title="Item type" defaultOpen badge={f.itemType !== 'all' ? 1 : undefined}>
{types.map(([v, label]) => (
<label className="inv-fitem" key={v}>
<input type="radio" name="inv-t" checked={f.itemType === v}
onChange={() => update({ itemType: v, weaponType: '' })} /> {label}
</label>
))}
{f.itemType === 'weapon' && (
<select className="inv-minisearch" value={f.weaponType}
onChange={e => update({ weaponType: e.target.value })}>
{WEAPON_TYPES.map(w => <option key={w.value} value={w.value}>{w.label}</option>)}
</select>
)}
</Group>
<Group title="Slots" defaultOpen badge={f.slots.length || undefined}>
{JEWELRY_SLOTS.map(s => (
<label className="inv-fitem" key={s}>
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
</label>
))}
<span className="inv-linky" onClick={() => setShowArmorSlots(v => !v)}>
{showArmorSlots ? 'hide' : 'show'} armor slots {showArmorSlots ? '▴' : '▾'}
</span>
{showArmorSlots && ARMOR_SLOTS.map(s => (
<label className="inv-fitem" key={s}>
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
</label>
))}
</Group>
<CantripFilter search={search} />
<Group title="Ratings" badge={ratingCount || undefined}>
{RATING_DEFS.filter(r => allRatings || r.common).map(r => (
<div className="inv-range" key={r.param}>
<label>{r.label} ≥</label>
<input type="number" value={f.ratings[r.param] ?? ''} placeholder="min"
onChange={e => setRating(r.param, e.target.value)} />
</div>
))}
<span className="inv-linky" onClick={() => setAllRatings(v => !v)}>
{allRatings ? 'common ratings ▴' : `all ${RATING_DEFS.length} ratings ▾`}
</span>
</Group>
<Group title="Equipment sets" badge={f.itemSet ? 1 : undefined}>
<input className="inv-minisearch" placeholder="find set…" value={setQuery}
onChange={e => setSetQuery(e.target.value)} />
<label className="inv-fitem">
<input type="radio" name="inv-set" checked={f.itemSet === ''}
onChange={() => update({ itemSet: '' })} /> Any set
</label>
{sets.filter(s => s.id.toLowerCase().includes(setQuery.toLowerCase())).map(s => (
<label className="inv-fitem" key={s.id}>
<input type="radio" name="inv-set" checked={f.itemSet === s.id}
onChange={() => update({ itemSet: s.id })} /> {s.id} <span className="inv-dim">({s.item_count})</span>
</label>
))}
</Group>
<Group title="Item state" badge={stateCount || undefined}>
<label className="inv-fitem"><input type="checkbox" checked={f.equippedOnly}
onChange={e => update({ equippedOnly: e.target.checked })} /> Equipped only</label>
<label className="inv-fitem"><input type="checkbox" checked={f.bonded}
onChange={e => update({ bonded: e.target.checked })} /> Bonded</label>
<label className="inv-fitem"><input type="checkbox" checked={f.attuned}
onChange={e => update({ attuned: e.target.checked })} /> Attuned</label>
<label className="inv-fitem"><input type="checkbox" checked={f.rare}
onChange={e => update({ rare: e.target.checked })} /> Rare</label>
</Group>
<Group title="Reqs & value" badge={reqCount || undefined}>
<div className="inv-range"><label>Wield lvl ≤</label>
<input type="number" value={f.maxLevel} placeholder="max"
onChange={e => update({ maxLevel: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Value ≥</label>
<input type="number" value={f.minValue} placeholder="min"
onChange={e => update({ minValue: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Workmanship ≥</label>
<input type="number" value={f.minWorkmanship} placeholder="min"
onChange={e => update({ minWorkmanship: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Burden ≤</label>
<input type="number" value={f.maxBurden} placeholder="max"
onChange={e => update({ maxBurden: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
</Group>
</div>
);
}
```
Add `.inv-dim{color:#666;font-size:10px}` to inventory.css.
- [ ] **Step 3: `CharacterFilter.tsx`** — full list from `/inv/characters/list`, online set from `/live` (one-shot fetches, no WebSocket), searchable, All/None/Online links:
```tsx
import { useEffect, useMemo, useState } from 'react';
import { apiFetch } from '../../api/client';
import type { InventorySearch } from './useInventorySearch';
import { Group } from './FilterSidebar';
export function CharacterFilter({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [names, setNames] = useState<string[]>([]);
const [online, setOnline] = useState<Set<string>>(new Set());
const [q, setQ] = useState('');
useEffect(() => {
apiFetch<{ characters: Array<{ character_name: string }> }>('/inv/characters/list')
.then(r => setNames(r.characters.map(c => c.character_name).sort((a, b) => a.localeCompare(b))))
.catch(() => {});
apiFetch<{ players: Array<{ character_name: string }> }>('/live')
.then(r => setOnline(new Set(r.players.map(p => p.character_name))))
.catch(() => {});
}, []);
const checked = useMemo(() =>
f.characters === 'all' ? new Set(names) : new Set(f.characters), [f.characters, names]);
const toggle = (n: string) => {
const next = new Set(checked);
if (next.has(n)) next.delete(n); else next.add(n);
update({ characters: next.size === names.length ? 'all' : [...next] });
};
const shown = names.filter(n => n.toLowerCase().includes(q.toLowerCase()));
return (
<Group title="Characters" defaultOpen
badge={f.characters === 'all' ? 'All' : f.characters.length}>
<input className="inv-minisearch" placeholder="filter characters…"
value={q} onChange={e => setQ(e.target.value)} />
<div className="inv-links">
<span className="inv-linky" onClick={() => update({ characters: 'all' })}>All</span>{' · '}
<span className="inv-linky" onClick={() => update({ characters: [] })}>None</span>{' · '}
<span className="inv-linky" onClick={() => update({ characters: names.filter(n => online.has(n)) })}>Online</span>
</div>
{shown.map(n => (
<label className="inv-fitem" key={n}>
<input type="checkbox" checked={checked.has(n)} onChange={() => toggle(n)} />
{n} {online.has(n) && <span className="inv-online" />}
</label>
))}
</Group>
);
}
```
Add to inventory.css: `.inv-online{width:6px;height:6px;border-radius:50%;background:#4c4;display:inline-block;margin-left:4px}` and `.inv-links{margin-bottom:4px;font-size:10px}`.
Note: `characters: []` (None) sends `characters=` empty → backend returns the "Empty characters list" error object; the hook surfaces it as an error string. Acceptable — the count area shows the message and choosing any character recovers. (Matches old-page behavior of requiring a character selection.)
- [ ] **Step 4: `CantripFilter.tsx`** — find-as-you-type, grouped, checked pinned to a Selected section, plus the any-tier `spell_contains` input:
```tsx
import { useState } from 'react';
import { CANTRIP_GROUPS } from './constants';
import type { InventorySearch } from './useInventorySearch';
import { Group } from './FilterSidebar';
export function CantripFilter({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [q, setQ] = useState('');
const toggle = (v: string) => update({
cantrips: f.cantrips.includes(v) ? f.cantrips.filter(x => x !== v) : [...f.cantrips, v],
});
const match = (label: string) => label.toLowerCase().includes(q.toLowerCase());
return (
<Group title="Cantrips" defaultOpen badge={f.cantrips.length || undefined}>
<input className="inv-minisearch" placeholder="find cantrip… e.g. invuln"
value={q} onChange={e => setQ(e.target.value)} />
{f.cantrips.length > 0 && (<>
<div className="inv-subhead">Selected</div>
{f.cantrips.map(v => (
<label className="inv-fitem inv-gold" key={v}>
<input type="checkbox" checked onChange={() => toggle(v)} />
{v.replace(/^Legendary /, '')}
</label>
))}
</>)}
{CANTRIP_GROUPS.map(g => {
const items = g.items.filter(i => !f.cantrips.includes(i.value) && match(i.label));
if (!items.length) return null;
return (
<div key={g.group}>
<div className="inv-subhead">{g.group}</div>
{items.map(i => (
<label className="inv-fitem" key={i.value}>
<input type="checkbox" checked={false} onChange={() => toggle(i.value)} /> {i.label}
</label>
))}
</div>
);
})}
<div className="inv-subhead">Any-tier spell search</div>
<input className="inv-minisearch" placeholder="spell name contains… e.g. Epic Invuln"
value={f.spellContains} onChange={e => update({ spellContains: e.target.value })} />
</Group>
);
}
```
Add `.inv-gold{color:#fc6}` to inventory.css.
- [ ] **Step 5: Build** — green.
- [ ] **Step 6: Commit**`feat(frontend): inventory filter sidebar (characters, type, slots, cantrips, ratings, sets, state, reqs)`
---
### Task 5: ActiveChips + ResultsTable
**Files:**
- Replace stubs: `frontend/src/components/inventory/ActiveChips.tsx`, `frontend/src/components/inventory/ResultsTable.tsx`
- [ ] **Step 1: `ActiveChips.tsx`** — derive chips from filters; × patches the filter away; legendary chips gold:
```tsx
import type { InventorySearch } from './useInventorySearch';
import { RATING_DEFS } from './constants';
import { DEFAULT_FILTERS } from './types';
interface Chip { label: string; gold?: boolean; remove: () => void; }
export function ActiveChips({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const chips: Chip[] = [];
if (f.text) chips.push({ label: `"${f.text}"`, remove: () => update({ text: '' }) });
if (f.characters !== 'all') chips.push({
label: `${f.characters.length} character${f.characters.length === 1 ? '' : 's'}`,
remove: () => update({ characters: 'all' }),
});
if (f.itemType !== 'all') chips.push({
label: f.itemType[0].toUpperCase() + f.itemType.slice(1) + (f.weaponType ? `: ${f.weaponType}` : ''),
remove: () => update({ itemType: 'all', weaponType: '' }),
});
for (const s of f.slots) chips.push({ label: `Slot: ${s}`, remove: () => update({ slots: f.slots.filter(x => x !== s) }) });
for (const c of f.cantrips) chips.push({
label: c.replace(/^Legendary /, 'Leg. '), gold: true,
remove: () => update({ cantrips: f.cantrips.filter(x => x !== c) }),
});
if (f.spellContains) chips.push({ label: `Spell: ${f.spellContains}`, remove: () => update({ spellContains: '' }) });
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') {
const def = RATING_DEFS.find(r => r.param === param);
chips.push({ label: `${def?.label ?? param} ≥ ${v}`, remove: () => update({ ratings: { ...f.ratings, [param]: '' } }) });
}
if (f.itemSet) chips.push({ label: `Set: ${f.itemSet}`, remove: () => update({ itemSet: '' }) });
if (f.equippedOnly) chips.push({ label: 'Equipped', remove: () => update({ equippedOnly: false }) });
if (f.bonded) chips.push({ label: 'Bonded', remove: () => update({ bonded: false }) });
if (f.attuned) chips.push({ label: 'Attuned', remove: () => update({ attuned: false }) });
if (f.rare) chips.push({ label: 'Rare', remove: () => update({ rare: false }) });
if (f.maxLevel !== '') chips.push({ label: `Wield ≤ ${f.maxLevel}`, remove: () => update({ maxLevel: '' }) });
if (f.minValue !== '') chips.push({ label: `Value ≥ ${f.minValue}`, remove: () => update({ minValue: '' }) });
if (f.minWorkmanship !== '') chips.push({ label: `Work ≥ ${f.minWorkmanship}`, remove: () => update({ minWorkmanship: '' }) });
if (f.maxBurden !== '') chips.push({ label: `Burden ≤ ${f.maxBurden}`, remove: () => update({ maxBurden: '' }) });
if (!chips.length) return null;
return (
<div className="inv-chipsrow">
<span className="inv-chips-lbl">ACTIVE</span>
{chips.map((c, i) => (
<span className={`inv-chip${c.gold ? ' inv-chip-gold' : ''}`} key={i}>
{c.label} <span className="inv-chip-x" onClick={c.remove}>×</span>
</span>
))}
</div>
);
}
```
(Unused import `DEFAULT_FILTERS` must not remain — do not include it; shown here as a reminder NOT to add it.) Remove that import line before committing.
- [ ] **Step 2: `ResultsTable.tsx`** — sticky sortable header, column picker (localStorage), spells with gold legendaries, equipped marker, pagination:
```tsx
import { useEffect, useMemo, useRef, useState } from 'react';
import { COLUMNS, COLUMNS_LS_KEY } from './constants';
import type { InventorySearch } from './useInventorySearch';
import type { InvItem } from './types';
function loadVisible(): Set<string> {
try {
const raw = localStorage.getItem(COLUMNS_LS_KEY);
if (raw) return new Set(JSON.parse(raw));
} catch { /* fall through */ }
return new Set(COLUMNS.filter(c => c.defaultVisible).map(c => c.key));
}
function cell(item: InvItem, key: string): React.ReactNode {
switch (key) {
case 'name':
return <>{item.name}{item.is_equipped && <span className="inv-equipped"></span>}</>;
case 'spell_names':
return <span className="inv-spells">{(item.spell_names ?? []).map((s, i) => (
<span key={i}>{i > 0 && ', '}<span className={/legendary/i.test(s) ? 'inv-leg' : ''}>{s}</span></span>
))}</span>;
case 'value':
return item.value != null ? item.value.toLocaleString() : '—';
case 'last_updated':
return item.last_updated ? item.last_updated.slice(0, 16).replace('T', ' ') : '—';
default: {
const v = (item as any)[key];
return v == null || v === -1 ? '—' : String(v);
}
}
}
export function ResultsTable({ search, selected, onSelect }: {
search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void;
}) {
const { filters: f, update, result } = search;
const [visible, setVisible] = useState<Set<string>>(loadVisible);
const [pickerOpen, setPickerOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const cols = useMemo(() => COLUMNS.filter(c => visible.has(c.key)), [visible]);
const items = result?.items ?? [];
useEffect(() => {
localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible]));
}, [visible]);
// Keyboard: ↑/↓ moves selection, Esc clears — ignore while typing in inputs.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement)?.tagName === 'INPUT' || (e.target as HTMLElement)?.tagName === 'SELECT') return;
if (e.key === 'Escape') { onSelect(null); return; }
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const idx = selected ? items.indexOf(selected) : -1;
const next = e.key === 'ArrowDown' ? Math.min(idx + 1, items.length - 1) : Math.max(idx - 1, 0);
if (items[next]) onSelect(items[next]);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [items, selected, onSelect]);
const sortOn = (sortKey?: string) => {
if (!sortKey) return;
if (f.sortBy === sortKey) update({ sortDir: f.sortDir === 'asc' ? 'desc' : 'asc' });
else update({ sortBy: sortKey, sortDir: 'asc' });
};
const totalPages = result ? Math.max(1, Math.ceil(result.total_count / result.limit)) : 1;
const goto = (p: number) => {
update({ page: Math.min(Math.max(1, p), totalPages) });
scrollRef.current?.scrollTo(0, 0);
};
return (
<div className="inv-results" ref={scrollRef}>
<div className="inv-colpicker-anchor">
<button className="inv-btn inv-colpicker-btn" onClick={() => setPickerOpen(o => !o)}>⚙ Columns</button>
{pickerOpen && (
<div className="inv-colpicker">
{COLUMNS.map(c => (
<label className="inv-fitem" key={c.key}>
<input type="checkbox" checked={visible.has(c.key)} onChange={() => {
const next = new Set(visible);
if (next.has(c.key)) next.delete(c.key); else next.add(c.key);
setVisible(next);
}} /> {c.label}
</label>
))}
</div>
)}
</div>
<table>
<thead><tr>
{cols.map(c => (
<th key={c.key} className={f.sortBy === c.sortKey ? `inv-sorted-${f.sortDir}` : ''}
onClick={() => sortOn(c.sortKey)}>{c.label}</th>
))}
</tr></thead>
<tbody>
{items.map((it, i) => (
<tr key={i} className={it === selected ? 'inv-sel' : ''}
onClick={() => onSelect(it === selected ? null : it)}>
{cols.map(c => <td key={c.key}>{cell(it, c.key)}</td>)}
</tr>
))}
{!items.length && <tr><td colSpan={cols.length} className="inv-dim">No items match.</td></tr>}
</tbody>
</table>
<div className="inv-pager">
<span className="inv-pg" onClick={() => goto(f.page - 1)}>◀</span>
<span>page {result?.page ?? 1} / {totalPages}</span>
<span className="inv-pg" onClick={() => goto(f.page + 1)}>▶</span>
<span className="inv-pager-right">{result?.limit ?? 200} / page</span>
</div>
</div>
);
}
```
Move the "⚙ Columns" button here (remove it from the top bar if Task 3 put one there — Task 3's top bar has no Columns button, correct). CSS additions: `.inv-colpicker-anchor{position:relative;align-self:flex-end;padding:4px 10px}`, `.inv-colpicker{position:absolute;right:10px;top:30px;background:#1a1a1a;border:1px solid #444;border-radius:4px;padding:8px;z-index:5;max-height:300px;overflow-y:auto}`, `.inv-sorted-asc::after{content:" ▲";font-size:9px}`, `.inv-sorted-desc::after{content:" ▼";font-size:9px}`, `.inv-pager-right{margin-left:auto}`.
- [ ] **Step 3: Build** — green.
- [ ] **Step 4: Commit**`feat(frontend): inventory chips row + results table (sort, columns, paging, keyboard)`
---
### Task 6: DetailPanel
**Files:**
- Replace stub: `frontend/src/components/inventory/DetailPanel.tsx`
- [ ] **Step 1: Implement:**
```tsx
import type { InvItem } from './types';
function Row({ k, v }: { k: string; v: React.ReactNode }) {
return <div className="inv-kv"><span>{k}</span><b>{v ?? '—'}</b></div>;
}
export function DetailPanel({ item, onClose }: { item: InvItem; onClose: () => void }) {
return (
<div className="inv-detail">
<span className="inv-closex" onClick={onClose}>×</span>
<h3>{item.name}</h3>
<div className="inv-detail-sub">
{item.slot_name ?? item.object_class_name ?? ''} · {item.is_equipped ? '⚔ Equipped' : '📦 Inventory'}
{item.is_rare && ' · ★ Rare'}
</div>
<Row k="Character" v={item.character_name} />
<Row k="Value" v={item.value?.toLocaleString()} />
<Row k="Burden" v={item.burden} />
<Row k="Wield req" v={item.wield_level ? `Level ${item.wield_level}` : '—'} />
<Row k="Workmanship" v={item.workmanship ?? ''} />
{item.armor_level != null && item.armor_level > 0 && <Row k="Armor" v={item.armor_level} />}
{item.max_damage != null && item.max_damage > 0 && <Row k="Max damage" v={item.max_damage} />}
{item.condition_percent != null && <Row k="Condition" v={`${item.condition_percent}%`} />}
{item.item_set_name && <Row k="Set" v={item.item_set_name} />}
{(item.is_bonded || item.is_attuned) && <Row k="Binding" v={[item.is_bonded && 'Bonded', item.is_attuned && 'Attuned'].filter(Boolean).join(', ')} />}
<hr />
<div className="inv-sphead">Spells ({item.spell_names?.length ?? 0})</div>
{(item.spell_names ?? []).map((s, i) => (
<div className={`inv-sp${/legendary/i.test(s) ? ' inv-leg' : ''}`} key={i}>{s}</div>
))}
<div className="inv-hint">↑/↓ next item · Esc close</div>
</div>
);
}
```
- [ ] **Step 2: Build** — green.
- [ ] **Step 3: Commit**`feat(frontend): inventory item detail panel`
---
### Task 7: Cutover — link + delete old page
**Files:**
- Modify: `frontend/src/components/map/Sidebar.tsx:101`
- Delete: `static/inventory.html`, `static/inventory.js`
- [ ] **Step 1:** In `Sidebar.tsx` line 101 change
`<a href="/inventory.html" target="_blank" className="ml-tool-link">🔍 Inv Search</a>`
to
`<a href="/?view=inventory" target="_blank" className="ml-tool-link">🔍 Inv Search</a>`
- [ ] **Step 2:** `git rm static/inventory.html static/inventory.js`
- [ ] **Step 3:** Grep for stragglers: `grep -rn "inventory.html" frontend/src static/*.html static/classic docs/superpowers/specs/2026-07-15*.md` — only historical docs/specs may reference it (leave those; they're records). If `static/classic/` links to it, update that link the same way.
- [ ] **Step 4: Build** — green.
- [ ] **Step 5: Commit**`feat(frontend)!: replace inventory.html with /?view=inventory (set analysis dropped)`
---
### Task 8: Deploy + live verification
- [ ] **Step 1:** `bash deploy-frontend.sh` from the repo root (runs the production build and copies `_build/` into `static/`).
- [ ] **Step 2:** `git add static/ && git commit -m "build(frontend): deploy inventory search redesign" && git push origin master`
- [ ] **Step 3:** `ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull --ff-only origin master"` (bind mount serves it; no restart).
- [ ] **Step 4: Smoke checks** (unauthenticated, from local shell):
- `curl -s https://overlord.snakedesert.se/ | grep -o 'assets/index-[^"]*\.js'` — new asset hash present.
- `curl -s -o /dev/null -w '%{http_code}' https://overlord.snakedesert.se/inventory.html` — no longer the old page (SPA fallback 200 serving index.html is expected; verify body contains the React root div, not "Inventory Search - Dereth Tracker" title).
- [ ] **Step 5: User verification** (requires login — ask the user): open `/?view=inventory`, confirm: jewelry + Invuln + Summon → the known 2 items; Ring slot + Invuln → 56; chips remove correctly; sort by Value; column picker persists after reload; detail panel + arrow keys; a bookmarked URL with filters restores them.
---
## Self-review notes
- Spec coverage: every spec bullet maps to a task (routing→3, sidebar groups→4, chips→5, table/sort/columns/paging→5, detail+keyboard→5/6, URL sharing→2, cutover→7, deploy/verify→8). Set analysis intentionally absent.
- The `characters: []` edge intentionally surfaces the backend's error message (documented in Task 4 Step 3).
- Keyboard handling lives in ResultsTable (needs items+selected); spec said "page" — acceptable placement, noted.
- No new deps; no backend changes anywhere.

View file

@ -1,303 +0,0 @@
# Weapon OD from Loot Profile — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Replace the OD calculation with the user's VTank loot-profile ladder so `od_rating` is the 010 (casters 07) integer they see on ident. Spec: `docs/superpowers/specs/2026-07-15-weapon-od-loot-profile-design.md`.
**Architecture:** Rewrite `go-services/inventory-go/od.go` around an ordered bucket table (below, extracted from `Loot.utl`). `OD = clamp(stat baseline, 1, cap)` for the first matching bucket. Backfill; the frontend renders integer OD.
**Tech Stack:** Go 1.25 (server-only tests in `golang:1.25-bookworm`), React (npm build).
**Working dir for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`
**Go test command:**
```bash
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
tar czf - go-services | ssh erik@overlord.snakedesert.se \
"rm -rf /tmp/tdd-verify && mkdir -p /tmp/tdd-verify && tar xzf - -C /tmp/tdd-verify" && \
ssh erik@overlord.snakedesert.se \
"docker run --rm -v /tmp/tdd-verify/go-services/inventory-go:/src -w /src golang:1.25-bookworm \
sh -c 'go mod tidy >/dev/null 2>&1; go test ./... -run <PATTERN> -v 2>&1'"
```
**Item keys:** object_class from `raw["ObjectClass"]`; skill `IntValues["218103840"]` (melee/missile) / `IntValues["159"]` (casters); mastery `IntValues["353"]`; MaxDamage `IntValues["218103842"]`; missile dmg `IntValues["218103839"]`; elemVsMon `DoubleValues["152"]`; name `raw["Name"]`. Helpers `bag/ivI/dvF/rawI/rawS` exist in process.go.
---
### Task 1: Rewrite od.go with the loot-profile table (TDD)
**Files:** rewrite `go-services/inventory-go/od.go`; rewrite `go-services/inventory-go/od_test.go`.
- [ ] **Step 1: Write tests first** (`od_test.go`, replacing the old content):
```go
package main
import "testing"
func TestOD_MeleeTetsubo(t *testing.T) {
// 2H Cleaving baseline 45, name "Tetsubo", maxdmg 85 → clamp(85-45,1,10)=10.
raw := map[string]any{"ObjectClass": 1.0, "Name": "Tetsubo",
"IntValues": map[string]any{"218103840": 41.0, "353": 11.0, "218103842": 85.0}}
if od, ok := computeOD(raw); !ok || od != 10 {
t.Fatalf("Tetsubo OD=%v ok=%v want 10", od, ok)
}
}
func TestOD_MeleeMidTier(t *testing.T) {
// Heavy Axe baseline 74, name "War Axe", maxdmg 79 → clamp(79-74,1,10)=5.
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze War Axe",
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 79.0}}
if od, ok := computeOD(raw); !ok || od != 5 {
t.Fatalf("War Axe OD=%v ok=%v want 5", od, ok)
}
}
func TestOD_MeleeBelowBaselineNull(t *testing.T) {
// Heavy Axe baseline 74, maxdmg 74 → stat-baseline=0 (<1) null.
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze War Axe",
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 74.0}}
if _, ok := computeOD(raw); ok {
t.Fatal("maxdmg == baseline must be null")
}
}
func TestOD_MeleeNameGateNull(t *testing.T) {
// Heavy Axe bucket requires name in {Silifi,Lugian,War Axe,Battle}. A skill-44
// mastery-3 item NOT matching any name and matching no other bucket → null.
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze Frobnicator",
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 84.0}}
if _, ok := computeOD(raw); ok {
t.Fatal("name not matching the Heavy Axe filter must be null")
}
}
func TestOD_Crossbow(t *testing.T) {
// Xbow baseline 77, k839=89 → clamp(89-77,1,10)=10.
raw := map[string]any{"ObjectClass": 9.0, "Name": "Fire Compound Crossbow",
"IntValues": map[string]any{"218103840": 47.0, "353": 9.0, "218103839": 89.0}}
if od, ok := computeOD(raw); !ok || od != 10 {
t.Fatalf("Xbow OD=%v ok=%v want 10", od, ok)
}
}
func TestOD_WarCaster(t *testing.T) {
// War caster: elemVsMon 1.40 → clamp(round((1.40-1.18)*100),1,7)=clamp(22,1,7)=7.
raw := map[string]any{"ObjectClass": 31.0, "Name": "Diamond Frost Baton",
"IntValues": map[string]any{"159": 34.0},
"DoubleValues": map[string]any{"152": 1.40}}
if od, ok := computeOD(raw); !ok || od != 7 {
t.Fatalf("War caster OD=%v ok=%v want 7", od, ok)
}
}
func TestOD_VoidCasterTier(t *testing.T) {
// Void caster: name must contain "Nether"; elemVsMon 1.22 → round((1.22-1.18)*100)=4.
raw := map[string]any{"ObjectClass": 31.0, "Name": "Silver Nether Staff",
"IntValues": map[string]any{"159": 43.0},
"DoubleValues": map[string]any{"152": 1.22}}
if od, ok := computeOD(raw); !ok || od != 4 {
t.Fatalf("Void caster OD=%v ok=%v want 4", od, ok)
}
}
func TestOD_BowDeferredNull(t *testing.T) {
// Bows (mastery 8) are deferred → always null in this pass.
raw := map[string]any{"ObjectClass": 9.0, "Name": "Corsair's Arc",
"IntValues": map[string]any{"218103840": 47.0, "353": 8.0, "218103839": 70.0}}
if _, ok := computeOD(raw); ok {
t.Fatal("bows are deferred → null")
}
}
```
- [ ] **Step 2: Run → RED** (`-run TestOD`): fails (old computeOD signature returns float; these expect int). Build/logic failure expected.
- [ ] **Step 3: Rewrite `od.go`** (delete ALL old content — table, spell dicts, variance math — and replace):
```go
package main
import "strings"
// Weapon OD = the user's VirindiTank Loot.utl classification (010, casters
// 07), printed by Mag-Tools on ident. OD = clamp(stat baseline, 1, cap) for
// the first matching bucket, else null. Buckets/baselines transcribed from
// Loot.utl (see design doc 2026-07-15-weapon-od-loot-profile).
type odBucket struct {
objectClass int // 1 melee, 9 missile, 31 caster
skill int // 218103840 for melee/missile, 159 for casters; 0 = any
mastery int // IntValues 353; -1 = not checked
nameAlts []string // OR of case-sensitive substrings; empty = no name gate
statKey string // IntValues key (melee/missile) or ""
dblStatKey string // DoubleValues key (casters) or ""
baseline float64
cap int
}
// Ordered: name-gated buckets are effectively specific; first match wins.
var odBuckets = []odBucket{
// --- Melee (object_class 1, stat MaxDamage 218103842, cap 10) ---
{1, 44, 3, []string{"Silifi", "Lugian", "War Axe", "Battle"}, "218103842", "", 74, 10}, // (H) Axe
{1, 45, 3, []string{"Dolabra", "Ono", "Hand", "War Hammer"}, "218103842", "", 61, 10}, // (L) Axe
{1, 46, 3, []string{"Hatchet", "Shou-ono", "Tungi", "Hammer"}, "218103842", "", 61, 10}, // (F) Axe
{1, 44, 6, []string{"Stiletto", "Jambiya"}, "218103842", "", 38, 10}, // (H) Dagger MS
{1, 44, 6, nil, "218103842", "", 71, 10}, // (H) Dagger
{1, 45, 6, nil, "218103842", "", 58, 10}, // (L) Dagger (MS shares names=nil; see note)
{1, 46, 6, []string{"Knife", "Lancet"}, "218103842", "", 28, 10}, // (F) Dagger MS
{1, 46, 6, nil, "218103842", "", 58, 10}, // (F) Dagger
{1, 44, 4, []string{"Mazule", "Mace", "Morning"}, "218103842", "", 69, 10}, // (H) Mace
{1, 45, 4, []string{"Club", "Kasrullah"}, "218103842", "", 56, 10}, // (L) Mace
{1, 46, 4, []string{"Board", "Tofun", "Dabus"}, "218103842", "", 56, 10}, // (F) Mace
{1, 44, 5, []string{"Glaive", "Trident", "Partizan"}, "218103842", "", 72, 10}, // (H) Spear
{1, 45, 5, []string{"Spear", "Yari"}, "218103842", "", 60, 10}, // (L) Spear
{1, 44, 7, []string{"Nabut", "Stick"}, "218103842", "", 69, 10}, // (H) Staff
{1, 45, 7, nil, "218103842", "", 57, 10}, // (L) Staff
{1, 46, 7, []string{"Jo", "Bastone"}, "218103842", "", 57, 10}, // (F) Staff
{1, 44, 2, []string{"Takuba", "Flamberge", "Ken", "Long", "Tachi"}, "218103842", "", 71, 10}, // (H) Sword
{1, 45, 2, []string{"Dericost", "Broad", "Shamshir", "Kaskara", "Spada"}, "218103842", "", 58, 10}, // (L) Sword
{1, 46, 2, []string{"Scimitar", "Yaoji", "Short", "Sabra", "Simi"}, "218103842", "", 58, 10}, // (F) Sword
{1, 44, 1, []string{"Nekode", "Cestus"}, "218103842", "", 59, 10}, // (H) UA
{1, 45, 1, []string{"Katar", "Knuckles"}, "218103842", "", 48, 10}, // (L) UA
{1, 46, 1, []string{"Claw", "Wraps"}, "218103842", "", 48, 10}, // (F) UA
{1, 41, -1, []string{"Nodachi", "Shashqa", "Spadone", "Greataxe", "Quadrelle", "Khanda-handled", "Tetsubo", "Star"}, "218103842", "", 45, 10}, // (2H) Cleaving
{1, 41, -1, []string{"Assagai", "Pike", "Magari", "Corsesca"}, "218103842", "", 48, 10}, // (2H) Spear
// --- Crossbow (object_class 9, mastery 9, stat 218103839, cap 10) ---
{9, 47, 9, nil, "218103839", "", 77, 10},
// --- Casters (object_class 31, stat elemVsMon 152, cap 7) ---
{31, 34, -1, []string{"Baton", "Sceptre", "Staff"}, "", "152", 1.18, 7}, // War
{31, 43, -1, []string{"Nether"}, "", "152", 1.18, 7}, // Void
// Bows (mastery 8) and Thrown (mastery 10) are intentionally ABSENT → null.
}
func nameMatches(name string, alts []string) bool {
if len(alts) == 0 {
return true
}
for _, a := range alts {
if strings.Contains(name, a) {
return true
}
}
return false
}
// computeOD returns the loot-profile OD (integer, as float64 for the DB column)
// and ok=false when no bucket matches or the stat is at/below baseline.
func computeOD(raw map[string]any) (float64, bool) {
oc := rawI(raw, "ObjectClass", 0)
iv := bag(raw, "IntValues")
dv := bag(raw, "DoubleValues")
name := rawS(raw, "Name")
skillMelee := ivI(iv, "218103840", 0)
skillCaster := ivI(iv, "159", 0)
mastery := ivI(iv, "353", -999)
for _, b := range odBuckets {
if b.objectClass != oc {
continue
}
sk := skillMelee
if b.objectClass == 31 {
sk = skillCaster
}
if b.skill != 0 && b.skill != sk {
continue
}
if b.mastery != -1 && b.mastery != mastery {
continue
}
if !nameMatches(name, b.nameAlts) {
continue
}
var stat float64
if b.dblStatKey != "" {
stat = dvF(dv, b.dblStatKey, 0)
} else {
stat = float64(ivI(iv, b.statKey, 0))
}
od := stat - b.baseline
if b.dblStatKey != "" {
od = od * 100 // caster elemVsMon: percentage points
}
odInt := int(od + 0.5) // round
if odInt < 1 {
return 0, false
}
if odInt > b.cap {
odInt = b.cap
}
return float64(odInt), true
}
return 0, false
}
```
Note on the (L) Dagger MS bucket: the profile has an `(L) Dagger MS` (baseline 28) with no name filter, which would collide with `(L) Dagger` (baseline 58, also no name). Two nameless same-skill/mastery buckets can't be distinguished by our fields; the plan keeps only `(L) Dagger` (baseline 58) — the more conservative (higher-baseline, fewer false OD10s). Document this as a known minor gap in the commit message.
- [ ] **Step 4: Run → GREEN** (`-run TestOD`) all pass; then `-run .` whole package + `go vet ./...` clean.
- [ ] **Step 5: Commit**
```
feat(inventory-go): recompute OD from VTank loot-profile ladder (0-10 / casters 0-7)
Replaces the UtilityBelt over-retail float with the user's actual in-game OD:
per-weapon-type MaxDamage/elemVsMon thresholds from Loot.utl. Bows/thrown
deferred (null) pending a computed-stat calibration. (L) Dagger MS bucket
folded into (L) Dagger (indistinguishable by captured fields).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
```
---
### Task 2: Frontend integer rendering
**Files:** `frontend/src/components/inventory/ResultsTable.tsx`, `DetailPanel.tsx`.
- [ ] **Step 1:** In `ResultsTable.tsx` `cell()` `case 'od_rating'`, render integer:
```ts
case 'od_rating': {
const v = item.od_rating;
return v == null ? '—' : String(v);
}
```
- [ ] **Step 2:** In `DetailPanel.tsx`, change the OD row to plain integer:
```tsx
{item.od_rating != null && <Row k="Weapon OD" v={String(item.od_rating)} />}
```
- [ ] **Step 3:** Build `cd frontend && npm run build`; delete `static/_build`. Commit:
```
feat(frontend): render weapon OD as integer (0-10 loot-profile scale)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
```
---
### Task 3: Deploy backend + backfill + verify
- [ ] **Step 1:** Sync + test-gated build + recreate inventory-go (cutover override) — standard flow from CLAUDE.md.
- [ ] **Step 2:** Backfill: `ssh erik@overlord.snakedesert.se "curl -s -X POST http://127.0.0.1:8772/admin/backfill-od"`. Expect `{scanned, updated}`.
- [ ] **Step 3:** Verify distribution is 010 / casters ≤7 and spot-check known items:
```bash
ssh erik@overlord.snakedesert.se "docker exec inventory-db psql -U inventory_user -d inventory_db -tc \"SELECT min(od_rating),max(od_rating),count(*) FILTER (WHERE od_rating IS NOT NULL) FROM items WHERE object_class IN (1,9,31);\""
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8770/inv/search/items?include_all_characters=true&weapon_only=true&min_od=1&sort_by=od&sort_dir=desc&limit=10' | python3 -c \"import json,sys;d=json.load(sys.stdin);[print(i.get('od_rating'),'|',i['name']) for i in d['items']]\""
```
Expect max = 10, casters max 7, Tetsubo = 10.
---
### Task 4: Deploy frontend + user verify
- [ ] `bash deploy-frontend.sh`, commit `static/`+`frontend/`, push, server `git pull` (handle the tar-sync working-tree blocker as before: verify blockers match origin, checkout/rm, re-pull).
- [ ] **User check:** ID a few weapons in-game and confirm the site's OD column matches the grey OD text (melee + crossbows + casters). Bows/thrown show `—` (deferred) — confirm that's acceptable and, if wanted, provide one bow OD reading to finish them.
---
## Self-review
- Melee 25 buckets + xbow + 2 casters implemented; bows/thrown null (documented).
- (L) Dagger MS folded into (L) Dagger — noted.
- Old variance/retail od.go fully deleted.
- od_rating stays the same column/param/sort; only values + rendering change.

View file

@ -1,388 +0,0 @@
# Weapon OD Rating Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Compute a Mag-Tools "OD" (over-damage vs best retail) rating for melee/missile/wand weapons at ingest, store it on `items.od_rating`, backfill existing rows, and expose `min_od`/`max_od` search filters + an OD column/detail row in the React inventory search.
**Architecture:** New `inventory-go/od.go` ports UtilityBelt's OD calc (`UtilityBelt/UtilityBelt/Tools/ItemInfo.cs` + `Lib/ItemInfoHelper/{WeaponMods,MiscCalcs,Dictionaries}.cs`, all in this workspace). `processItem` calls it and writes `od_rating` into the `items` map (the dynamic `buildInsert` in `ingest.go` picks it up with no other change). A backfill endpoint recomputes over stored `item_raw_data.original_json`. Search adds `min_od`/`max_od`/sort. Frontend adds one rating + one column + one detail row. Spec: `docs/superpowers/specs/2026-07-15-weapon-od-rating-design.md`.
**Tech Stack:** Go 1.25 (backend); React 19 + TS + Vite (frontend). Go has no local toolchain — tests run in a throwaway `golang:1.25-bookworm` container on `overlord.snakedesert.se`; the Dockerfile's `RUN go test ./...` gates the image build. Frontend build = `npm run build` locally.
**Working dir for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`
**Go test command (sync + run in container):**
```bash
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
tar czf - go-services | ssh erik@overlord.snakedesert.se \
"rm -rf /tmp/tdd-verify && mkdir -p /tmp/tdd-verify && tar xzf - -C /tmp/tdd-verify" && \
ssh erik@overlord.snakedesert.se \
"docker run --rm -v /tmp/tdd-verify/go-services/inventory-go:/src -w /src golang:1.25-bookworm \
sh -c 'go mod tidy >/dev/null 2>&1; go test ./... -run <PATTERN> -v 2>&1'"
```
**Key facts (verified against live data + source):**
- Raw item passed to `processItem(raw)`: `raw["IntValues"]`, `raw["DoubleValues"]` are `map[string]any` (JSON number values are `float64`); `raw["Spells"]` / `raw["ActiveSpells"]` are arrays. Helpers `bag`, `ivI`, `dvF`, `toIntList` already exist in `process.go`.
- Int keys: `159` EquipSkill, `353` Mastery, `160` WieldReqValue, `218103842` MaxDamage, `204` ElementalDmgBonus, `171` Tinks, `179` Imbued (nonzero ⇒ imbued), `131` Material, `47` WeaponType(masterid).
- Double keys (in DoubleValues): `167772171` Variance, `167772174` DamageBonus, `152` ElementalDamageVsMonsters, `167772169` SalvageWorkmanship.
- `object_class`: 1 melee, 9 missile, 31 wand/caster.
- Best-values table + spell-effect dicts: transcribe from the workspace source files named above — DO NOT invent values.
---
### Task 1: OD calculation core (`od.go`) — TDD
**Files:**
- Create: `go-services/inventory-go/od.go`
- Create: `go-services/inventory-go/od_test.go`
- [ ] **Step 1: Write the failing tests.** Create `od_test.go`. Golden values are computed by hand from the formulas + the workspace table; verify each against the source before trusting.
```go
package main
import (
"math"
"testing"
)
func approx(a, b float64) bool { return math.Abs(a-b) < 0.01 }
// Wand: OD = (buffedElemVsMonsters - tableMaxElemVsMonsters) * 100.
// Live "Frost Baton": skill 34 (war), wieldreq 385, ElemVsMonsters 1.40,
// no spirit-drinker spells. Table max for (34,*,*,385) = 1.18 → OD = +22.
func TestOD_Wand(t *testing.T) {
raw := map[string]any{
"ObjectClass": 31.0,
"IntValues": map[string]any{"159": 34.0, "160": 385.0},
"DoubleValues": map[string]any{"152": 1.40, "167772169": 8.0},
"Spells": []any{}, "ActiveSpells": []any{},
}
od, ok := computeOD(raw)
if !ok || !approx(od, 22.0) {
t.Fatalf("wand OD = %v ok=%v, want +22", od, ok)
}
}
// Non-loot (no workmanship) → no OD.
func TestOD_NonLootNull(t *testing.T) {
raw := map[string]any{
"ObjectClass": 31.0,
"IntValues": map[string]any{"159": 34.0, "160": 385.0},
"DoubleValues": map[string]any{"152": 1.40},
"Spells": []any{}, "ActiveSpells": []any{},
}
if _, ok := computeOD(raw); ok {
t.Fatal("no-workmanship item must have no OD")
}
}
// Table lookup miss (unknown tier) → no OD.
func TestOD_UnknownTierNull(t *testing.T) {
raw := map[string]any{
"ObjectClass": 31.0,
"IntValues": map[string]any{"159": 34.0, "160": 999.0},
"DoubleValues": map[string]any{"152": 1.40, "167772169": 8.0},
"Spells": []any{}, "ActiveSpells": []any{},
}
if _, ok := computeOD(raw); ok {
t.Fatal("unknown wieldreq tier must have no OD")
}
}
// Melee: OD = buffedMaxDmg - varianceTinks - tableMaxDmg,
// varianceTinks = round(log(tableMaxVar/variance)/log(0.8), 2).
// Heavy (44) sword (2) non-MS, wieldreq 430: tableMaxDmg=71, tableMaxVar=0.47.
// A perfect retail-max item: maxDmg 71, variance 0.47 → varianceTinks=0 → OD 0.
func TestOD_MeleeRetailMaxIsZero(t *testing.T) {
raw := map[string]any{
"ObjectClass": 1.0,
"IntValues": map[string]any{"159": 44.0, "353": 2.0, "160": 430.0, "218103842": 71.0},
"DoubleValues": map[string]any{"167772171": 0.47, "167772169": 10.0},
"Spells": []any{}, "ActiveSpells": []any{},
}
od, ok := computeOD(raw)
if !ok || !approx(od, 0.0) {
t.Fatalf("melee retail-max OD = %v ok=%v, want 0", od, ok)
}
}
// Melee with tighter variance (0.376, one granite tink BETTER than the 0.47 baseline):
// varianceTinks = round(log(0.47/0.376)/log(0.8), 2) = -1.00 → OD = 71-(-1)-71 = +1.
// (Table MaxVar is the type's baseline variance; lower variance = higher OD.)
func TestOD_MeleeOneTinkVariance(t *testing.T) {
raw := map[string]any{
"ObjectClass": 1.0,
"IntValues": map[string]any{"159": 44.0, "353": 2.0, "160": 430.0, "218103842": 71.0},
"DoubleValues": map[string]any{"167772171": 0.376, "167772169": 10.0},
"Spells": []any{}, "ActiveSpells": []any{},
}
od, ok := computeOD(raw)
if !ok || !approx(od, 1.0) {
t.Fatalf("melee OD = %v ok=%v, want +1", od, ok)
}
}
// Melee buffed: innate Legendary Blood Thirst (id 6089, +10 MaxDamage).
// maxDmg 61 + 10 = 71, variance 0.47 → OD 0 (vs 71 table).
func TestOD_MeleeBuffedByBloodThirst(t *testing.T) {
raw := map[string]any{
"ObjectClass": 1.0,
"IntValues": map[string]any{"159": 44.0, "353": 2.0, "160": 430.0, "218103842": 61.0},
"DoubleValues": map[string]any{"167772171": 0.47, "167772169": 10.0},
"Spells": []any{6089.0}, "ActiveSpells": []any{},
}
od, ok := computeOD(raw)
if !ok || !approx(od, 0.0) {
t.Fatalf("melee buffed OD = %v ok=%v, want 0", od, ok)
}
}
```
- [ ] **Step 2: Run tests, verify they fail** with `undefined: computeOD` (pattern `TestOD`).
- [ ] **Step 3: Implement `od.go`.** Structure:
- A `bestValue` struct `{maxDmg, maxVar, maxDmgMod, maxElemBonus, maxElemVsMon float64}` and a lookup `map[bestKey]bestValue` keyed by `bestKey{skill, mastery, multiStrike, wieldReq int}`, transcribed from `UtilityBelt/.../WeaponMods.cs` — EVERY row (heavy/light/finesse/two-handed/missile/wand regions). Column order in the source `Rows.Add` is `(Skill, Mastery, MultiStrike, WieldReq, MaxDmg, MaxVar, MaxDmgMod, MaxElementalDmgBonus, MaxElementalDmgVsMonsters)`; rows that omit trailing columns default them to 0. Melee rows use `(skill,mastery,ms,wieldreq,maxdmg,maxvar)`; missile rows use `...,maxDmgMod(col7)=0? no` — READ the source: missile rows are `(47, mastery, 0, wieldreq, 0,0, MaxDmgMod-col? )`. Actually missile rows fill `MaxElementalDmgBonus` (col 8) — e.g. `table.Rows.Add(47, 8, 0, 0, 0, 0, 110, 0)` means MaxDmg=0,MaxVar=0,MaxDmgMod=110,MaxElemBonus=0. Wand rows fill col 9 (MaxElementalDmgVsMonsters), e.g. `(34,0,0,290,0,0,0,0,1.03)`. Map each `Rows.Add(...)` positionally onto the struct fields; verify with a couple of spot values in a test comment.
- Spell-effect maps transcribed from `UtilityBelt/.../Dictionaries.cs`:
- `maxDamageSpellBonus map[int]int`: 1616:20, 2096:22, 5183:24, 4395:24, 2598:2, 2586:4, 4661:7, 6089:10, 3688:300.
- `elemVsMonSpellBonus map[int]float64`: 3258:.06, 3259:.07, 5182:.08, 4414:.08, 3251:.01, 3250:.03, 4670:.05, 6098:.07.
- `buffedMaxDamage(raw, iv)`: `ivI(iv,"218103842",0)` + Σ bonus over innate `raw["Spells"]` Σ bonus over `raw["ActiveSpells"]`, using `maxDamageSpellBonus`.
- `buffedElemVsMon(raw, dv)`: `dvF(dv,"152",0)` + Σ over Spells Σ over ActiveSpells using `elemVsMonSpellBonus`.
- MultiStrike detection: `iv[47] ∈ {160,166,486}` OR (`iv[47]==4` AND `iv[353]==11`). (If key 47 absent, ms=0.)
- `computeOD(raw) (float64, bool)`:
- `oc := rawI(raw,"ObjectClass",0)`; only 1/9/31 proceed, else `(0,false)`.
- Gate: `dvF(dv,"167772169",0) > 0` else `(0,false)`.
- Look up `bestValue` by `{skill=ivI(iv,"159",0), mastery=ivI(iv,"353",0), multiStrike, wieldReq=ivI(iv,"160",0)}`; miss → `(0,false)`.
- Melee (oc==1): `variance := dvF(dv,"167772171",0)`; if variance<=0 → `(0,false)`; `varianceTinks := round(math.Log(bv.maxVar/variance)/math.Log(0.8), 2)`; `od := float64(buffedMaxDamage) - varianceTinks - bv.maxDmg`.
- Missile (oc==9): `arrowMax` by mastery 8→40,9→53,10→42 (else 0); `tinks := ivI(iv,"171",0)`; `remaining := 10 - tinks; if tinks==0 { remaining = 9 } else if imbued==0 { remaining-- }; if remaining<0 {remaining=0}` where `imbued := ivI(iv,"179",0)`; `dmgMod := dvF(dv,"167772174",0)*100 - 100`; `bDmg := float64(buffedMaxDamage); if bDmg<=10 { bDmg+=24 }`; `bElem := float64(ivI(iv,"204",0))` (no elem spell buff needed for missile per UB); `maxMod := (bv.maxDmgMod + 136)/100`; `od := (1 + (dmgMod + 4*float64(remaining))/100) * (bElem + bDmg + arrowMax) / maxMod - (bv.maxElemBonus + 24 + arrowMax)`.
- Wand (oc==31): `od := (buffedElemVsMon - bv.maxElemVsMon) * 100`.
- Return `round(od,2), true`. Provide a local `round2(x float64) float64` = `math.Round(x*100)/100`.
- [ ] **Step 4: Run tests, verify PASS** (pattern `TestOD`), then whole package (`-run .`) green.
- [ ] **Step 5: Commit**
```bash
git add go-services/inventory-go/od.go go-services/inventory-go/od_test.go
git commit -m "feat(inventory-go): Mag-Tools OD weapon rating calculation (od.go)"
```
(append `Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>`)
---
### Task 2: Wire OD into ingest + schema
**Files:**
- Modify: `go-services/inventory-go/process.go` (in `processItem`, after the `items` map is built ~line 137, before combat)
- Modify: `go-services/inventory-go/schema.go` (items table DDL)
- [ ] **Step 1: Set od_rating in processItem.** After the `items := map[string]any{...}` literal closes, add:
```go
if od, ok := computeOD(raw); ok {
items["od_rating"] = od
}
```
(When not ok, the key is absent → buildInsert omits it → column stays NULL. Correct.)
- [ ] **Step 2: Add the column to schema.go.** Find the `CREATE TABLE ... items (` DDL and add `od_rating DOUBLE PRECISION,` alongside the other nullable numeric columns. (Fresh installs only; prod uses SKIP_SCHEMA_INIT and gets the manual ALTER in Task 5.)
- [ ] **Step 3: Build/test** — full package green (`-run .`). No new test needed here (Task 1 covers the math; ingest wiring is exercised in Task 5 live).
- [ ] **Step 4: Commit**
```bash
git add go-services/inventory-go/process.go go-services/inventory-go/schema.go
git commit -m "feat(inventory-go): store od_rating at ingest + schema column"
```
---
### Task 3: Backfill endpoint
**Files:**
- Modify: `go-services/inventory-go/ingest.go` (add handler) and `go-services/inventory-go/main.go` (register route)
- [ ] **Step 1: Add handler in ingest.go:**
```go
// POST /admin/backfill-od — recompute od_rating for all weapon rows from stored
// raw JSON. Internal-only (service is bound to loopback/compose). Idempotent.
func (s *Server) handleBackfillOD(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rows, err := s.pool.Query(ctx,
`SELECT rd.item_id, rd.original_json FROM item_raw_data rd
JOIN items i ON i.id = rd.item_id
WHERE i.object_class IN (1,9,31)`)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
type upd struct {
id int
od float64
}
var updates []upd
scanned := 0
for rows.Next() {
var id int
var oj map[string]any
if err := rows.Scan(&id, &oj); err != nil {
rows.Close()
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
scanned++
if od, ok := computeOD(oj); ok {
updates = append(updates, upd{id, od})
}
}
rows.Close()
updated := 0
for _, u := range updates {
if _, err := s.pool.Exec(ctx, "UPDATE items SET od_rating=$1 WHERE id=$2", u.od, u.id); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "updated": updated})
return
}
updated++
}
writeJSON(w, http.StatusOK, map[string]any{"scanned": scanned, "updated": updated})
}
```
Note: pgx scans a `jsonb` column into `map[string]any` directly; `computeOD` accepts exactly that shape (numbers as `float64`), same as ingest.
- [ ] **Step 2: Register in main.go** next to the other `mux.HandleFunc("POST /...")` lines:
```go
mux.HandleFunc("POST /admin/backfill-od", srv.handleBackfillOD)
```
- [ ] **Step 3: Build/test** green (`-run .`).
- [ ] **Step 4: Commit**
```bash
git add go-services/inventory-go/ingest.go go-services/inventory-go/main.go
git commit -m "feat(inventory-go): POST /admin/backfill-od recompute endpoint"
```
---
### Task 4: Search filter + sort + column
**Files:**
- Modify: `go-services/inventory-go/search.go`
- [ ] **Step 1: Expose od_rating in the CTE.** In `cteSelect`, add `i.od_rating,` to the selected columns (near `i.value, i.burden`). It flows into the row maps and out to JSON automatically (queryRowsAsMaps).
- [ ] **Step 2: Add the filters.** `geFilters` (search.go ~line 209) uses `strconv.ParseFloat` and `leFilters` (~line 230) uses int-only `qInt`. OD can be negative and fractional, so:
- Add `min_od` to the `geFilters` slice: `{"min_od", "od_rating"}` (float path — correct).
- Do NOT add `max_od` to `leFilters` (int-only would truncate negatives/decimals). Instead add a float block right after the existing `min_attack_bonus` block (~line 239), mirroring it:
```go
if v := q.Get("max_od"); v != "" {
if n, err := strconv.ParseFloat(v, 64); err == nil {
conds = append(conds, "od_rating <= "+ab.add(n))
}
}
```
- [ ] **Step 3: Add the sort key.** In `sortMapping`, add `"od": "od_rating",`.
- [ ] **Step 4: Build/test** green (`-run .`).
- [ ] **Step 5: Commit**
```bash
git add go-services/inventory-go/search.go
git commit -m "feat(inventory-go): min_od/max_od search filters + od sort"
```
---
### Task 5: Deploy backend + backfill + live verify
- [ ] **Step 1: Manual ALTER on the live inventory DB** (prod skips schema init):
```bash
ssh erik@overlord.snakedesert.se "docker exec inventory-db psql -U inventory_user -d inventory_db -c 'ALTER TABLE items ADD COLUMN IF NOT EXISTS od_rating double precision;'"
```
- [ ] **Step 2: Sync + build + recreate inventory-go** (test-gated build):
```bash
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
tar czf - go-services | ssh erik@overlord.snakedesert.se "tar xzf - -C /home/erik/MosswartOverlord/"
ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \
export BUILD_VERSION="$(date -u +%Y.%-m.%-d.%H%M)-$(git rev-parse --short HEAD)" && \
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml build --build-arg BUILD_VERSION=$BUILD_VERSION inventory-go && \
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml -f go-services/docker-compose.cutover.yml up -d --no-deps inventory-go'
```
- [ ] **Step 3: Run the backfill:**
```bash
ssh erik@overlord.snakedesert.se "curl -s -X POST http://127.0.0.1:8772/admin/backfill-od"
```
Expected: `{"scanned":<n>,"updated":<m>}` with m > 0.
- [ ] **Step 4: Verify the known Frost Baton (+22) and a sort:**
```bash
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8772/search/items?include_all_characters=true&weapon_only=true&min_od=20&sort_by=od&sort_dir=desc&limit=10'" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print('total:',d['total_count']); [print(i['name'],'|',i.get('object_class_name'),'|',i.get('od_rating')) for i in d['items']]"
```
Expected: results include the Frost Baton with od_rating ≈ 22; all listed od_rating ≥ 20, descending.
- [ ] **Step 5: Sanity — melee spot check** (pick a heavy sword the user knows in-game; compare od_rating sign to its Mag-Tools OD). Non-weapon search unaffected:
```bash
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8772/search/items?include_all_characters=true&jewelry_only=true&limit=1' | python3 -c \"import json,sys;d=json.load(sys.stdin);print('jewelry total',d['total_count'])\""
```
- [ ] **Step 6: Commit** (no code change; this task is operational). If a git commit is desired for the version bump it happens via the frontend push later.
---
### Task 6: Frontend — filter + column + detail row
**Files:**
- Modify: `frontend/src/components/inventory/constants.ts`
- Modify: `frontend/src/components/inventory/types.ts`
- Modify: `frontend/src/components/inventory/DetailPanel.tsx`
- [ ] **Step 1: types.ts** — add to `InvItem`:
```ts
od_rating?: number | null;
```
- [ ] **Step 2: constants.ts** — add an OD rating filter and column. In `RATING_DEFS`, add as the first entry (so it appears with the common ratings):
```ts
{ param: 'min_od', label: 'Weapon OD', common: true },
```
In `COLUMNS`, add after `max_damage`:
```ts
{ key: 'od_rating', label: 'OD', sortKey: 'od', defaultVisible: false },
```
- [ ] **Step 3: ResultsTable cell rendering.** In `ResultsTable.tsx` `cell()`, add a case so OD shows signed with 2 decimals and `—` for null:
```ts
case 'od_rating': {
const v = item.od_rating;
if (v == null) return '—';
return v > 0 ? `+${v.toFixed(2)}` : v.toFixed(2);
}
```
(Place it alongside the other explicit cases, before `default`.)
- [ ] **Step 4: DetailPanel.tsx** — add an OD row (only when present) after Max damage:
```tsx
{item.od_rating != null && <Row k="Weapon OD" v={item.od_rating > 0 ? `+${item.od_rating.toFixed(2)}` : item.od_rating.toFixed(2)} />}
```
- [ ] **Step 5: Build green:** `cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord/frontend" && npm run build`; delete `static/_build` after.
- [ ] **Step 6: Commit**
```bash
git add frontend/src/components/inventory/
git commit -m "feat(frontend): weapon OD rating filter, column, and detail row"
```
Note: `min_od` chip label comes free via `RATING_DEFS` in `ActiveChips` ("Weapon OD ≥ N"); the rating sidebar input is auto-generated from `RATING_DEFS`. No changes needed in FilterSidebar/ActiveChips.
---
### Task 7: Deploy frontend + final verify
- [ ] **Step 1:** `bash deploy-frontend.sh` from repo root.
- [ ] **Step 2:** `git add static/ frontend/ && git commit -m "build(frontend): deploy weapon OD rating" && git push origin master`
- [ ] **Step 3:** `ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull --ff-only origin master"`
- [ ] **Step 4: User verification** (login-gated, ask the user): open `/?view=inventory`, select Weapons, enter Weapon OD ≥ 10, confirm high-OD weapons appear; add the OD column via the picker; sort by OD; click a weapon and confirm the OD row in the detail panel; confirm a known good weapon's OD roughly matches its in-game Mag-Tools OD.
---
## Self-review notes
- Spec coverage: melee/missile/wand formulas (Task 1), ingest+schema (2), backfill (3), filters+sort (4), deploy+backfill (5), UI (6), deploy (7). All spec bullets covered.
- The best-values table + spell dicts are transcribed from named in-repo source files, not invented — reviewer must diff against those files.
- `min_od` is a float filter (negative values valid) — placed in the ParseFloat `geFilters` path, not the int `leFilters` path. `max_od` likewise should accept floats: if `leFilters` is int-only (`qInt`), add `max_od` via the float `min_attack_bonus`-style manual block instead. Implementer: check `leFilters`'s parse path and use a float path for `max_od`.

View file

@ -1,326 +0,0 @@
# Weapon-type (by skill) filter + OD column Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Multi-select weapon-type filter by skill (Heavy/Light/Finesse/Two-Handed/Missile/War/Void), and auto-show the OD column on weapon searches.
**Architecture:** Backend reworks `weaponTypeClause` to be object-class/key aware and adds a `weapon_types` CSV param (OR of per-type clauses) in `runSearch`; legacy `weapon_type` kept. Frontend changes `weaponType: string``weaponTypes: string[]` (multi-select checkboxes), redefines `WEAPON_TYPES` to seven skill categories, and unions `od_rating` into the visible columns when item type is Weapons. Spec: `docs/superpowers/specs/2026-07-15-weapon-type-skill-filter-design.md`.
**Tech Stack:** Go 1.25 (backend, no local toolchain — test in `golang:1.25-bookworm` on the server); React 19 + Vite (`npm run build` locally).
**Working dir for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`
**Go test command:**
```bash
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
tar czf - go-services | ssh erik@overlord.snakedesert.se \
"rm -rf /tmp/tdd-verify && mkdir -p /tmp/tdd-verify && tar xzf - -C /tmp/tdd-verify" && \
ssh erik@overlord.snakedesert.se \
"docker run --rm -v /tmp/tdd-verify/go-services/inventory-go:/src -w /src golang:1.25-bookworm \
sh -c 'go mod tidy >/dev/null 2>&1; go test ./... -run <PATTERN> -v 2>&1'"
```
**Verified field keys:** melee `object_class=1` + `int_values->>'218103840'` (44/45/46/41); missile `object_class=9` + `218103840=47`, mastery `353` (8 bow/9 xbow/10 thrown); wand `object_class=31` + equip skill `int_values->>'159'` (34 war/43 void).
---
### Task 1: Backend — reworked weaponTypeClause + weapon_types param (TDD)
**Files:**
- Modify: `go-services/inventory-go/search.go` (`weaponTypeClause` ~line 545; the `weapon_only` case ~line 193)
- Create: `go-services/inventory-go/search_test.go`
- [ ] **Step 1: Write failing tests.** Create `search_test.go`:
```go
package main
import (
"net/url"
"strings"
"testing"
)
func TestWeaponTypeClause(t *testing.T) {
cases := map[string][]string{
"heavy": {"object_class = 1", "'218103840')::int = 44"},
"light": {"object_class = 1", "= 45"},
"finesse": {"object_class = 1", "= 46"},
"two_handed": {"object_class = 1", "= 41"},
"missile": {"object_class = 9", "'218103840')::int = 47"},
"bow": {"object_class = 9", "'218103840')::int = 47", "'353')::int = 8"},
"crossbow": {"object_class = 9", "'353')::int = 9"},
"thrown": {"object_class = 9", "'353')::int = 10"},
"war": {"object_class = 31", "'159')::int = 34"},
"void": {"object_class = 31", "'159')::int = 43"},
"caster": {"object_class = 31"},
}
for wt, subs := range cases {
got := weaponTypeClause(wt)
for _, s := range subs {
if !strings.Contains(got, s) {
t.Errorf("weaponTypeClause(%q) = %q, missing %q", wt, got, s)
}
}
}
if got := weaponTypeClause("nonsense"); !strings.Contains(got, "object_class IN (1, 9, 31)") {
t.Errorf("unknown type = %q, want all-weapons fallback", got)
}
}
func TestWeaponOnlyClause(t *testing.T) {
// No types → all weapons.
if got := weaponOnlyClause(url.Values{}); !strings.Contains(got, "object_class IN (1, 9, 31)") {
t.Errorf("empty = %q, want all weapons", got)
}
// Single type via weapon_types.
got := weaponOnlyClause(url.Values{"weapon_types": {"heavy"}})
if !strings.Contains(got, "= 44") || strings.Contains(got, " OR ") {
t.Errorf("single = %q, want just heavy, no OR", got)
}
// Multiple → parenthesized OR, one clause per type.
got = weaponOnlyClause(url.Values{"weapon_types": {"heavy,two_handed,war"}})
if !strings.HasPrefix(got, "(") || !strings.HasSuffix(got, ")") ||
strings.Count(got, " OR ") != 2 ||
!strings.Contains(got, "= 44") || !strings.Contains(got, "= 41") || !strings.Contains(got, "= 34") {
t.Errorf("multi = %q, want (heavy OR two_handed OR war)", got)
}
// Legacy weapon_type still honored, deduped against weapon_types.
got = weaponOnlyClause(url.Values{"weapon_type": {"missile"}})
if !strings.Contains(got, "object_class = 9") {
t.Errorf("legacy weapon_type = %q, want missile", got)
}
got = weaponOnlyClause(url.Values{"weapon_types": {"heavy"}, "weapon_type": {"heavy"}})
if strings.Contains(got, " OR ") {
t.Errorf("dedup = %q, want single heavy clause", got)
}
}
```
- [ ] **Step 2: Run tests → RED** (`-run 'TestWeaponTypeClause|TestWeaponOnlyClause'`): `undefined: weaponOnlyClause` build failure.
- [ ] **Step 3: Implement.** Replace the whole `weaponTypeClause` func (~545-575) with:
```go
func weaponTypeClause(wt string) string {
meleeSkill := func(skill int) string {
return fmt.Sprintf("(object_class = 1 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = %d))", skill)
}
missileMastery := func(mastery int) string {
return fmt.Sprintf("(object_class = 9 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = 47 AND (wrd.int_values->>'353')::int = %d))", mastery)
}
casterSkill := func(skill int) string {
return fmt.Sprintf("(object_class = 31 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'159')::int = %d))", skill)
}
switch strings.ToLower(wt) {
case "heavy":
return meleeSkill(44)
case "light":
return meleeSkill(45)
case "finesse":
return meleeSkill(46)
case "two_handed":
return meleeSkill(41)
case "missile":
return "(object_class = 9 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = 47))"
case "bow":
return missileMastery(8)
case "crossbow":
return missileMastery(9)
case "thrown":
return missileMastery(10)
case "war":
return casterSkill(34)
case "void":
return casterSkill(43)
case "caster":
return "object_class = 31"
default:
return "object_class IN (1, 9, 31)"
}
}
// weaponOnlyClause builds the WHERE fragment for weapon_only searches: an OR of
// the per-type clauses named in weapon_types (CSV) plus the legacy single
// weapon_type. Empty selection = all weapons. Types are deduped.
func weaponOnlyClause(q url.Values) string {
var types []string
types = append(types, splitNonEmpty(q.Get("weapon_types"))...)
if wt := strings.TrimSpace(q.Get("weapon_type")); wt != "" {
types = append(types, wt)
}
seen := map[string]bool{}
var clauses []string
for _, t := range types {
key := strings.ToLower(strings.TrimSpace(t))
if key == "" || seen[key] {
continue
}
seen[key] = true
clauses = append(clauses, weaponTypeClause(key))
}
if len(clauses) == 0 {
return "object_class IN (1, 9, 31)"
}
if len(clauses) == 1 {
return clauses[0]
}
return "(" + strings.Join(clauses, " OR ") + ")"
}
```
Then change the `weapon_only` case (~line 193-194) from
`conds = append(conds, weaponTypeClause(q.Get("weapon_type")))`
to
`conds = append(conds, weaponOnlyClause(q))`.
(`url` and `strings` and `fmt` and `splitNonEmpty` are already imported/defined in search.go.)
- [ ] **Step 4: Run tests → GREEN** (`-run 'TestWeapon'`) then whole package (`-run .`) + `go vet ./...` clean.
- [ ] **Step 5: Commit**
```bash
git add go-services/inventory-go/search.go go-services/inventory-go/search_test.go
git commit -m "feat(inventory-go): weapon_types multi-select filter by skill (melee/missile/war/void)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
```
---
### Task 2: Frontend — multi-select weapon type + auto OD column
**Files:**
- Modify: `frontend/src/components/inventory/constants.ts`
- Modify: `frontend/src/components/inventory/types.ts`
- Modify: `frontend/src/components/inventory/useInventorySearch.ts`
- Modify: `frontend/src/components/inventory/FilterSidebar.tsx`
- Modify: `frontend/src/components/inventory/ActiveChips.tsx`
- Modify: `frontend/src/components/inventory/ResultsTable.tsx`
- [ ] **Step 1: constants.ts** — replace the `WEAPON_TYPES` array with the seven skill categories:
```ts
export const WEAPON_TYPES: Array<{ value: string; label: string }> = [
{ value: 'heavy', label: 'Heavy' },
{ value: 'light', label: 'Light' },
{ value: 'finesse', label: 'Finesse' },
{ value: 'two_handed', label: 'Two-Handed' },
{ value: 'missile', label: 'Missile' },
{ value: 'war', label: 'War Magic' },
{ value: 'void', label: 'Void Magic' },
];
```
(Removed the leading `{ value: '', label: 'All weapons' }` — multi-select uses "none checked = all".)
- [ ] **Step 2: types.ts** — in `SearchFilters` change `weaponType: string;` to `weaponTypes: string[];`; in `DEFAULT_FILTERS` change `weaponType: '',` to `weaponTypes: [],`.
- [ ] **Step 3: useInventorySearch.ts**
- `validateFilterValue`: add a case
```ts
case 'weaponTypes':
return isStringArray(value) ? value : undefined;
```
(place near the `slots`/`cantrips` case; `isStringArray` already exists.)
- `buildParams`: in the `case 'weapon':` block, replace
```ts
p.set('weapon_only', 'true');
if (f.weaponType) p.set('weapon_type', f.weaponType);
```
with
```ts
p.set('weapon_only', 'true');
if (f.weaponTypes.length) p.set('weapon_types', f.weaponTypes.join(','));
```
- Anywhere `weaponType` is referenced elsewhere (e.g. `update({ itemType: v, weaponType: '' })` resets) — grep and change to `weaponTypes: []`.
- [ ] **Step 4: FilterSidebar.tsx** — replace the weapon-type `<select>` (the `{f.itemType === 'weapon' && (<select>...)}` block, ~line 72-77) with a checkbox group:
```tsx
{f.itemType === 'weapon' && (
<div className="inv-subgroup">
{WEAPON_TYPES.map(w => (
<label className="inv-fitem" key={w.value}>
<input type="checkbox" checked={f.weaponTypes.includes(w.value)}
onChange={() => update({
weaponTypes: f.weaponTypes.includes(w.value)
? f.weaponTypes.filter(x => x !== w.value)
: [...f.weaponTypes, w.value],
})} /> {w.label}
</label>
))}
</div>
)}
```
Also update the item-type radio `onChange` that currently does `update({ itemType: v, weaponType: '' })``update({ itemType: v, weaponTypes: [] })`. Add `.inv-subgroup{margin:2px 0 2px 10px}` to inventory.css.
- [ ] **Step 5: ActiveChips.tsx** — replace the single item-type/weaponType chip logic. The current block:
```ts
if (f.itemType !== 'all') chips.push({
label: f.itemType[0].toUpperCase() + f.itemType.slice(1) + (f.weaponType ? `: ${f.weaponType}` : ''),
remove: () => update({ itemType: 'all', weaponType: '' }),
});
```
becomes:
```ts
if (f.itemType !== 'all') chips.push({
label: f.itemType[0].toUpperCase() + f.itemType.slice(1),
remove: () => update({ itemType: 'all', weaponTypes: [] }),
});
for (const wt of f.weaponTypes) {
const label = WEAPON_TYPES.find(w => w.value === wt)?.label ?? wt;
chips.push({ label, remove: () => update({ weaponTypes: f.weaponTypes.filter(x => x !== wt) }) });
}
```
Add `import { WEAPON_TYPES } from './constants';` if not present (RATING_DEFS import is already there).
- [ ] **Step 6: ResultsTable.tsx** — make the OD column auto-visible on weapon searches. Change the `cols` memo:
```ts
const cols = useMemo(() => {
const eff = new Set(visible);
if (f.itemType === 'weapon') eff.add('od_rating');
return COLUMNS.filter(c => eff.has(c.key));
}, [visible, f.itemType]);
```
(`f` is already destructured from `search`.)
- [ ] **Step 7: Build**`cd frontend && npm run build`; delete `static/_build` after. Grep to confirm no stray `weaponType` (singular) references remain: `grep -rn "weaponType\b" frontend/src/components/inventory` should only match `weaponTypes`.
- [ ] **Step 8: Commit**
```bash
git add frontend/src/components/inventory/ frontend/src/styles/inventory.css
git commit -m "feat(frontend): multi-select weapon-type-by-skill filter + auto OD column
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
```
---
### Task 3: Deploy + verify
- [ ] **Step 1: Backend** — sync + test-gated build + recreate:
```bash
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
tar czf - go-services | ssh erik@overlord.snakedesert.se "tar xzf - -C /home/erik/MosswartOverlord/"
ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \
export BUILD_VERSION="$(date -u +%Y.%-m.%-d.%H%M)-$(git rev-parse --short HEAD)" && \
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml build --build-arg BUILD_VERSION=$BUILD_VERSION inventory-go && \
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml -f go-services/docker-compose.cutover.yml up -d --no-deps inventory-go'
```
- [ ] **Step 2: Frontend**`bash deploy-frontend.sh`, then `git add static/ frontend/ && git commit -m "build(frontend): deploy weapon-type filter" && git push origin master`.
- [ ] **Step 3: Server pull**`ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull --ff-only origin master"`. If it aborts on the tar-synced working-tree files, verify each blocker matches origin (`git show origin/master:<f> | diff -q - <f>`), `git checkout --`/`rm` them, then re-pull. Confirm the served bundle hash changed.
- [ ] **Step 4: Backend live checks** (through the `/inv` proxy the browser uses):
```bash
ssh erik@overlord.snakedesert.se "for t in heavy two_handed missile war void; do echo -n \"\$t: \"; curl -s \"http://127.0.0.1:8770/inv/search/items?include_all_characters=true&weapon_only=true&weapon_types=\$t&limit=1\" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d[\"total_count\"])'; done"
```
Expected: each returns a plausible non-zero count. Then a multi-select + no-Bowl check:
```bash
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8770/inv/search/items?include_all_characters=true&weapon_only=true&weapon_types=missile&limit=200' | python3 -c \"import json,sys;d=json.load(sys.stdin);names=[i['name'] for i in d['items']];print('missile total',d['total_count'],'| any Bowl?', any('Bowl' in n for n in names))\""
```
Expected: total > 0, `any Bowl? False`.
- [ ] **Step 5: User verification** (login-gated — ask the user): open `/?view=inventory`, select Weapons, check Heavy + Two-Handed, confirm results; confirm the OD column shows automatically; check War Magic / Void Magic return casters; chips add/remove per type.
---
## Self-review
- Spec coverage: multi-select by skill (T1 backend, T2 frontend), OD column on weapon searches (T2 step 6), verify (T3). All covered.
- Back-compat: legacy `weapon_type` still honored by `weaponOnlyClause`; URL state with old `weaponType` string is dropped by validation (falls back to `[]`), harmless.
- Bugfix ride-along: missile subtypes now mastery-based (fixes "Bowl").

View file

@ -1,167 +0,0 @@
# Midsummer theme — "Små grodorna" — design spec
Date: 2026-06-19
Repo: MosswartOverlord (React frontend in `frontend/`)
Status: approved design, ready for implementation plan
## Goal
A "SUPER epic" Swedish-midsummer takeover of the Overlord dashboard, themed
around *Små grodorna* (the little frogs) — fitting because Asheron's Call
mosswarts are frog-men. Full visual takeover with a dancing maypole, frog +
flower-crown player markers, a "Glad midsommar!" banner, and a frog-hop
easter egg. Per-browser 🐸 toggle, **default ON**, with an unmuted jingle.
## Scope
In scope: the React frontend only (`frontend/`). The classic v1 frontend
(`static/classic/`) and legacy vanilla pages are dead and explicitly NOT
themed.
Out of scope: backend changes, DB, the plugin. No server-side flag — the
theme is a pure client concern toggled per browser.
## Approach: scoped overlay, not a rewrite
A single attribute `data-midsummer` on `<html>` (`document.documentElement`)
gates the entire theme. All midsummer styling lives in a NEW stylesheet
`frontend/src/styles/midsummer.css`, every rule scoped under
`:root[data-midsummer] …`, layered on top of the untouched base
`map-layout.css`. Removing the attribute fully reverts the UI — the base
theme remains the single source of truth.
Rejected alternative: swapping in a full second stylesheet (à la the old
`christmastheme/`). Too heavy and it drifts from the base theme on every
future change. The scoped overlay avoids duplication.
## State & toggle
- `frontend/src/hooks/useMidsummer.ts` — a hook backed by a tiny context so
every component reads one source of truth.
- Reads `localStorage["mo-midsummer"]`; **absent ⇒ enabled (default ON)**.
Only the literal string `"off"` disables it.
- On change, sets/removes `data-midsummer` on `document.documentElement`
and persists `"on"`/`"off"`.
- Exposes `{ enabled, toggle, soundOn, toggleSound }`.
- Provider mounted at the top of `App.tsx` so both the default app and the
`?view=dashboard` page inherit it.
- 🐸 toggle button added to the sidebar tool-links
(`components/sidebar/SidebarWindowButtons.tsx`), label reflects state.
## Components (all gated by `enabled`)
### 1. Maypole — `components/midsummer/Maypole.tsx`
- Rendered as a sibling of `PlayerDots` **inside `.ml-map-group`** in
`MapView.tsx` (so it pans/zooms with the world automatically), only when
`imgSize.w > 0 && enabled`.
- Positioned via `worldToPx(MAYPOLE_EW, MAYPOLE_NS, imgW, imgH)`.
**Default location: map center** (the midpoint of the Dereth image / a
central hub). Coordinate is a single named constant, trivial to move.
- Visual: a midsommarstång (pole + flowered cross-bar + ribbons) built in
CSS/SVG — no image asset — so it inherits theme colors and stays crisp at
any zoom.
- Carries its OWN ring of CSS-animated decorative frogs circling the pole
(keyframe rotation on a wrapper). The spectacle is independent of live
data, so it always looks alive even with nobody online. Real player dots
near the pole read as "joining the dance" by proximity.
- Pure CSS animation (transform-based) for 60fps; respects
`prefers-reduced-motion` (ring holds still).
### 2. Frog + flower-crown player dots
- No change to `PlayerDots.tsx` data flow. Under `[data-midsummer]`,
`midsummer.css` decorates `.ml-dot` with a wildflower-crown ring via a
`::before` pseudo-element, and turns the hovered/selected dot
(`.ml-dot-selected`) into a little frog (pseudo-element eyes + green body).
- Falls back gracefully: if a dot has an inline `backgroundColor`, the crown
sits on top; the frog variant overrides the fill only on select/hover.
### 3. Glad midsummer banner + confetti — `components/midsummer/MidsummerBanner.tsx`
- A festive top strip ("Glad midsommar! 🐸") rendered at the app shell level
(in `MapLayout.tsx`, and on the dashboard page) when `enabled`.
- One-shot snaps-glass / flower confetti burst on first load **per session**
(guarded by `sessionStorage`), a lightweight self-removing CSS-particle
effect (no library). Honors `prefers-reduced-motion` (skips the burst).
### 4. Frog-hop easter egg (replaces the rickroll)
- `Sidebar.tsx:62-80` currently appends a fullscreen `/rick.mp4` overlay +
shake on title click. Replace it with a *Små grodorna* hop: clicking the
title toggles a body class running a bounce/hop keyframe across the UI for
a few seconds, with frogs hopping across the screen. Self-cleans; clicking
again re-triggers without stacking. The `rick.mp4` reference is removed.
- This easter egg is active regardless of the theme toggle (it's a gag, not
a palette), but uses the same frog assets.
### 5. Jingle — `hooks/useMidsummerSound.ts`
- Plays the *Små grodorna* melody **once** (not looping — a looping jingle
on a left-open dashboard is grating).
- **No audio asset**: the melody is synthesized with WebAudio oscillators
from the public-domain folk tune (note frequencies in code). This removes
any licensing question and ships nothing for the service worker to cache.
- Browser reality: WebAudio cannot start before a user gesture, so the tune
fires on the **first user interaction (any click) or the moment the 🐸/🔊
control is used** — never silently on page-paint. A 🔇 control disables
it; preference persisted (`soundOn`, default on).
- Single module-level `AudioContext`, reused and `resume()`d on gesture — no
per-play allocation (the audit flagged per-notification `AudioContext`
leaks elsewhere; don't repeat that pattern).
## File plan
New:
- `frontend/src/hooks/useMidsummer.ts` (context + hook)
- `frontend/src/hooks/useMidsummerSound.ts`
- `frontend/src/styles/midsummer.css`
- `frontend/src/components/midsummer/Maypole.tsx`
- `frontend/src/components/midsummer/MidsummerBanner.tsx`
- `frontend/src/components/midsummer/FrogToggle.tsx`
- `frontend/src/components/midsummer/confetti.ts` (tiny helper)
- (no audio asset — jingle is WebAudio-synthesized)
Edited:
- `App.tsx` — wrap in `MidsummerProvider`; import `midsummer.css`.
- `components/map/MapView.tsx` — mount `<Maypole>` inside `.ml-map-group`.
- `components/map/MapLayout.tsx` — mount `<MidsummerBanner>`.
- `components/map/Sidebar.tsx` — replace rickroll block with frog-hop.
- `components/sidebar/SidebarWindowButtons.tsx` — add 🐸 toggle (+ 🔊).
- `components/PlayerDashboardFullPage.tsx` — render banner/toggle so the
new-tab dashboard matches.
## Decisions (locked)
- Maypole location: **map center** (named constant, easy to relocate).
- Jingle: **plays once**, unmuted, fires on first gesture.
- Toggle default: **ON**, per-browser via `localStorage`.
- Auto-date-gating: NOT implemented (user chose manual toggle). A future
enhancement could default the toggle from the date (~Jun 1925).
## Deploy
`bash deploy-frontend.sh && git add static/ && git commit && git push`, then
`git pull` on the host (bind-mounted `static/`). No container restart. No new
runtime assets (jingle is synthesized), so the service worker needs no
changes.
## Testing / verification
- Toggle off ⇒ `data-midsummer` removed, UI identical to today (base theme
intact). Toggle on ⇒ full takeover. Preference survives reload.
- Maypole sits at map center and stays pinned to the world through
pan/zoom; frogs animate; `prefers-reduced-motion` stops motion.
- Player dots show crowns; hover/select shows frog.
- Banner shows once per session; confetti does not re-fire on every render.
- Easter egg hops and self-cleans; no `rick.mp4` request remains.
- Jingle plays once after first interaction; 🔇 stops it; no audio-context
leak across repeated toggles.
- `npm run build` succeeds; bundle includes the new chunk.
## Risks / caveats
- **Default-on + shared dashboard**: anyone opening it during the demo gets
the full theme. That's intended; the 🐸 toggle is one click to calm it.
- **Unmuted autoplay** is gesture-gated by browsers — communicated above;
not a bug.
- **Animation perf**: the map already re-renders on high-frequency
telemetry; keep all midsummer animation pure-CSS/transform and outside
React state so it doesn't add re-renders. Cap decorative frog count.
- **Asset licensing**: use a clearly royalty-free / public-domain audio clip
and note its source in the plan.

View file

@ -1,104 +0,0 @@
# Inventory search: spell filters + jewelry-type selection
**Date:** 2026-07-14
**Status:** Approved
## Problem
The inventory search UI (`static/inventory.html`) has a Legendary Cantrips
checkbox grid and a "Spell name contains..." text box, and `inventory.js`
sends them as `legendary_cantrips` / `spell_contains` query params — but the
Go inventory service (`go-services/inventory-go/search.go`) never implemented
these filters, so they are silently ignored. The legacy Python service
(`inventory-service/main.py:3345-3452`) supported them.
Additionally there is no way to narrow a jewelry search to a specific type
(ring / bracelet / necklace / trinket), even though the backend already
supports this via the `slot_names` param (used by the suitbuilder).
Goal: check e.g. Invulnerability + Summoning, pick jewelry type Ring, and get
back only rings carrying BOTH cantrips.
## Decisions
- **AND semantics** for multiple checked cantrips (matches legacy intent):
an item must carry every checked cantrip.
- **Jewelry type: reuse the existing Equipment Slots card.** During planning
we found `inventory.html` already has Ring / Bracelet / Neck / Trinket
checkboxes (`#all-slots`) that `inventory.js` sends as `slot_names`, which
the Go backend fully supports. No new UI — verify the existing checkboxes
compose correctly with the new spell filters.
- **Also wire `spell_contains`** (the dead free-text box) and `has_spell`
(exact-name variant referenced by the agent tools) in the same pass — same
mechanism, nearly free.
## Backend design (`go-services/inventory-go/search.go`)
Three new query params handled in `runSearch`, emitted as ordinary `WHERE`
conditions on the `items_with_slots` CTE so they compose with all existing
filters, sorting, pagination, and the count query:
1. **`legendary_cantrips`** — CSV of display names (e.g.
`Legendary Invulnerability,Legendary Summoning Prowess`). For each name,
case-insensitive substring match (both directions, mirroring Python's
flexible matching) against the in-memory spell enum map (`s.spells`,
loaded from `comprehensive_enum_database_v2.json`) to collect that
cantrip's spell IDs. Each cantrip emits:
```sql
EXISTS (SELECT 1 FROM item_spells sp
WHERE sp.item_id = db_item_id AND sp.spell_id IN ($n, ...))
```
Cantrips are ANDed. A cantrip matching zero spells contributes `1 = 0`.
Note: this deliberately does NOT replicate Python's
`COUNT(DISTINCT spell_id) >= N` trick, which can false-positive when one
cantrip name matches two spell IDs on the same item while another matches
zero. Per-cantrip EXISTS is strictly correct AND semantics.
2. **`spell_contains`** — free text; collect all spell IDs whose name
contains the text (case-insensitive); a single `EXISTS ... IN (ids)`.
No matching spell → `1 = 0`.
3. **`has_spell`** — exact name match (case-insensitive) to one spell ID; a
single `EXISTS`. Unknown name → `1 = 0`.
Spell IDs originate from our own enum map, not user input, but are still
bound via the existing `argBuilder` positional params for consistency.
## Frontend design
**No frontend changes.** The cantrip grid, spell text box, and Equipment
Slots checkboxes (Ring / Bracelet / Neck / Trinket) already send the correct
params (`legendary_cantrips`, `spell_contains`, `slot_names`); the existing
`slotNameClause` per-type OR approaches (including the Trinket clause's
`%bracelet%` exclusion) are reused untouched. Live verification must confirm
jewelry-type + cantrip searches compose end-to-end.
## Testing
TDD in `go-services/inventory-go/search_test.go` (new file):
- Name→spell-ID matcher: substring both-directions semantics, case
insensitivity, zero-match behavior.
- Condition builder: correct EXISTS SQL shape, one clause per cantrip ANDed,
`1 = 0` on no-match, arg binding via argBuilder.
Query execution against the real DB is validated manually after deploy
(consistent with the rest of search.go, which has no DB-backed tests).
The Dockerfile's `RUN go test ./...` gates the image build.
## Deploy
- Backend: standard inventory-go sync + build + recreate (cutover override).
- Frontend: `inventory.html`/`inventory.js` are plain static files on the
bind mount — `git pull` on the server picks them up; `deploy-frontend.sh`
(React build) is NOT involved.
## Out of scope
- Cantrip tiers other than Legendary in the checkbox grid (the free-text box
covers Epic/Major/etc. searches).
- React frontend inventory window changes.
- Suitbuilder behavior (unchanged; it passes its own slot_names).

View file

@ -1,115 +0,0 @@
# Inventory Search redesign — React full-page view
**Date:** 2026-07-15
**Status:** Approved (layout + behaviors validated interactively in visual-companion mockup)
**Mockup:** `2026-07-15-inventory-search-redesign-mockup.html` (same directory — the approved clickable reference)
## Problem
The inventory search UI (`static/inventory.html` + `inventory.js`, ~2100 lines of
2010s-era dense gray vanilla HTML) is functionally complete (all filters work
against the Go `inventory-go` service, including the July 2026 spell filters)
but visually dated and inconsistent with the React dashboard. Full redesign
requested.
## Decisions (user-validated)
1. **Platform:** new full-page view in the existing React app (`frontend/`),
reached via `/?view=inventory` — same routing pattern as `?view=dashboard`
in `App.tsx`.
2. **Layout:** facet sidebar (direction A of three mocked options) + active-
filter chips row borrowed from direction B.
3. **Search trigger:** instant auto-search, debounced ~400 ms, no Search
button; in-flight requests aborted via `AbortController`.
4. **Item details:** right-side detail panel opened by row click; ↑/↓ moves
selection, Esc closes.
5. **Set analysis:** dropped entirely. The new page fully replaces
`inventory.html`/`inventory.js` — both deleted at cutover; the "Analyze
Sets" feature ceases to exist.
6. **Shareable URLs:** filter state serialized to the query string.
## Layout (per approved mockup)
- **Top bar:** title, name/material search box (`text` param), Reset (clears
all filters), column picker (⚙), result count + query time.
- **Chips row:** one removable chip per active filter; legendary-cantrip chips
styled gold (#fc6 on #2a2418).
- **Facet sidebar** (~210 px, collapsible groups with active-count badges):
- **Characters** — searchable checkbox list, All/None/Online links, green
online dots (from the `/live` data the app already polls).
- **Item type** — radios: All / Armor / Jewelry / Weapons (+subtype) /
Clothing (maps to `armor_only`/`jewelry_only`/`weapon_only`+`weapon_type`/
`clothing_only`; shirts/pants reachable as clothing subtypes).
- **Slots** — jewelry slots (Ring/Bracelet/Neck/Trinket/Cloak) up front,
armor slots behind a "show armor slots" expander (`slot_names` CSV).
- **Cantrips** — find-as-you-type filter over the legendary cantrip list,
grouped (Attributes / Skills / Defense / Banes / Other); checked items pin
to a "Selected" section on top; gold styling; plus an "any-tier spell
search" text input (`spell_contains`). Checkbox values = the same display
names the old page sent (`legendary_cantrips` CSV, AND semantics).
- **Ratings** (collapsed) — min inputs for the 4 common ratings, "all 20
ratings" expander for the rest (`min_*` params).
- **Equipment sets** (collapsed) — searchable set list (`item_set`/`item_sets`).
- **Item state** (collapsed) — equipped-only, bonded, attuned, rare
(`equipment_status`, `bonded`, `attuned`, `is_rare`).
- **Reqs & value** (collapsed) — wield level ≤, value ≥, workmanship ≥,
burden ≤ (`max_level`, `min_value`, `min_workmanship`, `max_burden`).
- **Results table:** server-side sort via `sort_by`/`sort_dir` (click headers);
sticky header; equipped marker (green ⚔); Spells column with legendary names
gold; pagination footer (200/page default, `page`/`limit`). Column set
togglable via the ⚙ picker, persisted to `localStorage`.
- **Detail panel** (~250 px): item name, slot, equipped state, character,
value/burden/wield/workmanship/mana, full ordered spell list (legendary
gold). Keyboard: ↑/↓/Esc.
## Implementation shape
- **No backend changes.** The existing `GET /api/inv/search/items` supports
every filter above (validated; the spell filters shipped 2026-07-14).
- New directory `frontend/src/components/inventory/`:
- `InventorySearchPage.tsx` — page layout, keyboard handling.
- `FilterSidebar.tsx` + one small component per filter group.
- `ActiveChips.tsx`, `ResultsTable.tsx`, `DetailPanel.tsx`.
- `useInventorySearch.ts` — single-reducer filter state, debounce,
AbortController fetch, URL (de)serialization.
- Filter state ↔ URL: serialize non-default filters into query params on
change (replaceState), parse on mount — bookmarkable searches.
- Styling: `frontend/src/styles/inventory.css`, `.inv-*` class prefix,
dashboard dark tokens (#111 bg, #1a1a1a panels, #333 borders, #88f accent,
#fc6 legendary gold, Segoe UI) — mirrors `map-layout.css` conventions.
- **No new npm dependencies.**
- Cantrip list + grouping: lift the value strings from the old
`inventory.html` checkbox grid (they are the strings the backend matches);
store as a typed constant module.
- Character list: reuse the app's existing live-players data source for names
+ online status; fall back to `/api/inv/characters/list` for characters that
are offline/never online.
## Cutover
Same commit series: add the new view → repoint the dashboard tool-link that
currently opens `/inventory.html` to `/?view=inventory` → delete
`static/inventory.html` and `static/inventory.js`. `/inventory.html` then 404s
to the SPA fallback; acceptable (single-user tool, no external links).
## Testing & verification
The frontend has no test framework (build = `tsc -b && vite build`).
Verification per task: TypeScript build green + live checks in the Vite dev
server against the production API, then post-deploy spot checks of the same
scenarios used for the spell-filter validation (Invuln+Summon → 2 items;
Ring+Invuln → 56; filterless jewelry → ~4.8k). Adding a test harness is out
of scope.
## Deploy
`bash deploy-frontend.sh` (builds + copies into `static/`), commit `static/`
+ `frontend/` + deletions, push; server picks it up via bind mount, no
restart.
## Out of scope
- Set analysis (dropped per decision 5).
- Suitbuilder page (untouched).
- React-window (`InventoryWindow.tsx`) per-character inventory — unrelated.
- Mobile layout (desktop tool); no test framework introduction.

View file

@ -1,294 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Inventory Search — mockup</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body { background:#111; color:#eee; font-family:"Segoe UI",sans-serif; font-size:13px; display:flex; flex-direction:column; }
/* ── Top bar ── */
.topbar { display:flex; align-items:center; gap:10px; padding:10px 14px; background:#1a1a1a; border-bottom:2px solid #333; }
.topbar .title { color:#88f; font-weight:600; font-size:15px; white-space:nowrap; }
.searchbox { flex:1; max-width:520px; background:#222; border:1px solid #444; border-radius:4px; padding:7px 12px; color:#eee; font-size:13px; outline:none; }
.searchbox:focus { border-color:#88f; }
.searchbox::placeholder { color:#666; }
.btn { background:#333; border:1px solid #555; color:#ccc; border-radius:4px; padding:6px 14px; font-size:12px; cursor:pointer; }
.btn:hover { background:#444; color:#fff; }
.count { margin-left:auto; color:#888; font-size:12px; white-space:nowrap; }
.count b { color:#eee; }
/* ── Chips ── */
.chipsrow { display:flex; align-items:center; flex-wrap:wrap; gap:6px; padding:8px 14px; background:#151515; border-bottom:1px solid #2a2a2a; min-height:37px; }
.chipsrow .lbl { color:#666; font-size:10px; letter-spacing:1px; }
.chip { display:inline-flex; align-items:center; gap:6px; background:#23233a; border:1px solid #55f; border-radius:12px; padding:2px 10px; font-size:11px; color:#bbd; cursor:default; }
.chip .x { color:#c66; cursor:pointer; font-weight:bold; }
.chip .x:hover { color:#f88; }
.chip.gold { border-color:#a80; color:#fc6; background:#2a2418; }
/* ── Main ── */
.main { display:flex; flex:1; overflow:hidden; }
/* ── Sidebar ── */
.sidebar { width:210px; min-width:210px; background:#1a1a1a; border-right:2px solid #333; overflow-y:auto; padding:8px 10px; }
.grp { border-bottom:1px solid #262626; padding:6px 0; }
.grp-head { display:flex; align-items:center; gap:6px; cursor:pointer; color:#999; text-transform:uppercase; font-size:10px; letter-spacing:1px; padding:3px 0; user-select:none; }
.grp-head:hover { color:#ccc; }
.grp-head .arrow { font-size:9px; transition:transform .15s; }
.grp.open .arrow { transform:rotate(90deg); }
.grp-head .badge { margin-left:auto; background:#3a3a6e; color:#cce; border-radius:8px; padding:0 7px; font-size:9px; }
.grp-body { display:none; padding:5px 0 3px; }
.grp.open .grp-body { display:block; }
.minisearch { width:100%; background:#222; border:1px solid #3a3a3a; border-radius:3px; padding:4px 8px; color:#ccc; font-size:11px; outline:none; margin-bottom:5px; }
.minisearch::placeholder { color:#555; }
.fitem { display:flex; align-items:center; gap:6px; padding:2px 0; font-size:12px; color:#bbb; cursor:pointer; }
.fitem:hover { color:#fff; }
.fitem input { accent-color:#88f; }
.fitem .online { width:6px; height:6px; border-radius:50%; background:#4c4; }
.fitem.gold { color:#fc6; }
.subhead { color:#666; font-size:9px; text-transform:uppercase; letter-spacing:1px; margin:6px 0 2px; }
.linky { color:#88f; font-size:10px; cursor:pointer; }
.linky:hover { text-decoration:underline; }
.range { display:flex; gap:4px; align-items:center; margin:2px 0; }
.range label { flex:1; color:#999; font-size:11px; }
.range input { width:52px; background:#222; border:1px solid #3a3a3a; border-radius:3px; color:#ccc; padding:2px 5px; font-size:11px; }
/* ── Results ── */
.results { flex:1; overflow-y:auto; display:flex; flex-direction:column; }
table { width:100%; border-collapse:collapse; }
thead { position:sticky; top:0; background:#191919; z-index:1; }
th { text-align:left; color:#88f; font-size:11px; font-weight:600; padding:8px 10px; border-bottom:2px solid #333; cursor:pointer; white-space:nowrap; user-select:none; }
th:hover { color:#aaf; }
th.sorted::after { content:" ▼"; font-size:9px; }
td { padding:6px 10px; border-bottom:1px solid #1e1e1e; font-size:12px; color:#bbb; vertical-align:top; }
tbody tr { cursor:pointer; }
tbody tr:hover td { background:#191922; }
tbody tr.sel td { background:#20203a; }
td .leg { color:#fc6; }
td .spells { color:#889; font-size:11px; }
td.num { text-align:right; font-variant-numeric:tabular-nums; }
.equipped { color:#4c4; font-size:10px; }
.pager { display:flex; align-items:center; gap:8px; padding:8px 14px; color:#888; font-size:12px; border-top:1px solid #262626; background:#151515; margin-top:auto; }
.pager .pg { padding:2px 8px; border:1px solid #333; border-radius:3px; cursor:pointer; }
.pager .pg.cur { background:#3a3a6e; border-color:#88f; color:#fff; }
/* ── Detail panel ── */
.detail { width:250px; min-width:250px; background:#161620; border-left:2px solid #333; overflow-y:auto; padding:12px; }
.detail h3 { color:#88f; font-size:14px; margin-bottom:2px; }
.detail .sub { color:#777; font-size:11px; margin-bottom:10px; }
.kv { display:flex; justify-content:space-between; padding:2px 0; font-size:12px; color:#999; }
.kv b { color:#ddd; font-weight:normal; text-align:right; }
.detail hr { border:none; border-top:1px solid #2a2a35; margin:9px 0; }
.detail .sphead { color:#666; font-size:10px; text-transform:uppercase; letter-spacing:1px; margin-bottom:4px; }
.sp { padding:2px 0; font-size:12px; color:#bbb; }
.sp.leg { color:#fc6; }
.detail .hint { color:#555; font-size:10px; margin-top:12px; }
.closex { float:right; color:#666; cursor:pointer; font-size:14px; }
.closex:hover { color:#f88; }
</style>
</head>
<body>
<div class="topbar">
<span class="title">⚔ Inventory Search</span>
<input class="searchbox" placeholder="Search name or material… (e.g. 'sapphire ring')">
<button class="btn" onclick="document.querySelectorAll('.chip').forEach(c=>c.remove())">Reset</button>
<button class="btn">⚙ Columns</button>
<span class="count"><b>56</b> items · 24 ms</span>
</div>
<div class="chipsrow">
<span class="lbl">ACTIVE</span>
<span class="chip">Jewelry <span class="x" onclick="this.parentNode.remove()">×</span></span>
<span class="chip">Slot: Ring <span class="x" onclick="this.parentNode.remove()">×</span></span>
<span class="chip gold">Legendary Invulnerability <span class="x" onclick="this.parentNode.remove()">×</span></span>
<span class="chip gold">Legendary Summoning <span class="x" onclick="this.parentNode.remove()">×</span></span>
</div>
<div class="main">
<div class="sidebar">
<div class="grp open">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Characters <span class="badge">All</span></div>
<div class="grp-body">
<input class="minisearch" placeholder="filter characters…">
<div style="margin-bottom:4px"><span class="linky">All</span> · <span class="linky">None</span> · <span class="linky">Online</span></div>
<div class="fitem"><input type="checkbox" checked> Bank of Sawato</div>
<div class="fitem"><input type="checkbox" checked> Bowgod <span class="online"></span></div>
<div class="fitem"><input type="checkbox" checked> Larsson <span class="online"></span></div>
<div class="fitem"><input type="checkbox" checked> Mrbow</div>
<div class="fitem"><input type="checkbox" checked> Kosmo Kramer <span class="online"></span></div>
<div class="fitem" style="color:#555">… 33 more</div>
</div>
</div>
<div class="grp open">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Item type <span class="badge">1</span></div>
<div class="grp-body">
<div class="fitem"><input type="radio" name="t"> All items</div>
<div class="fitem"><input type="radio" name="t"> Armor</div>
<div class="fitem"><input type="radio" name="t" checked> Jewelry</div>
<div class="fitem"><input type="radio" name="t"> Weapons</div>
<div class="fitem"><input type="radio" name="t"> Clothing</div>
</div>
</div>
<div class="grp open">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Slots <span class="badge">1</span></div>
<div class="grp-body">
<div class="fitem"><input type="checkbox" checked> Ring</div>
<div class="fitem"><input type="checkbox"> Bracelet</div>
<div class="fitem"><input type="checkbox"> Neck</div>
<div class="fitem"><input type="checkbox"> Trinket</div>
<div class="fitem"><input type="checkbox"> Cloak</div>
<span class="linky">show armor slots ▾</span>
</div>
</div>
<div class="grp open">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Cantrips <span class="badge">2</span></div>
<div class="grp-body">
<input class="minisearch" id="cantripSearch" placeholder="find cantrip… e.g. invuln" oninput="filterCantrips(this.value)">
<div class="subhead">Selected</div>
<div class="fitem gold"><input type="checkbox" checked> Invulnerability</div>
<div class="fitem gold"><input type="checkbox" checked> Summoning</div>
<div class="subhead">Attributes</div>
<div class="fitem cf"><input type="checkbox"> Strength</div>
<div class="fitem cf"><input type="checkbox"> Endurance</div>
<div class="fitem cf"><input type="checkbox"> Quickness</div>
<div class="fitem cf"><input type="checkbox"> Focus</div>
<div class="subhead">Skills</div>
<div class="fitem cf"><input type="checkbox"> Heavy Weapon</div>
<div class="fitem cf"><input type="checkbox"> War Magic</div>
<div class="fitem cf"><input type="checkbox"> Defender</div>
<div class="subhead">Defense</div>
<div class="fitem cf"><input type="checkbox"> Impenetrability</div>
<div class="fitem cf"><input type="checkbox"> Magic Resistance</div>
<div class="fitem" style="color:#555">… all 45 in real page</div>
<div class="subhead">Any-tier spell search</div>
<input class="minisearch" placeholder="spell name contains… e.g. Epic Invuln">
</div>
</div>
<div class="grp">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Ratings</div>
<div class="grp-body">
<div class="range"><label>Dmg rating ≥</label><input placeholder="min"></div>
<div class="range"><label>Crit dmg ≥</label><input placeholder="min"></div>
<div class="range"><label>Heal boost ≥</label><input placeholder="min"></div>
<div class="range"><label>Vitality ≥</label><input placeholder="min"></div>
<span class="linky">all 20 ratings ▾</span>
</div>
</div>
<div class="grp">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Equipment sets</div>
<div class="grp-body"><input class="minisearch" placeholder="find set…"></div>
</div>
<div class="grp">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Item state</div>
<div class="grp-body">
<div class="fitem"><input type="checkbox"> Equipped only</div>
<div class="fitem"><input type="checkbox"> Bonded</div>
<div class="fitem"><input type="checkbox"> Rare</div>
</div>
</div>
<div class="grp">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Reqs & value</div>
<div class="grp-body">
<div class="range"><label>Wield lvl ≤</label><input placeholder="max"></div>
<div class="range"><label>Value ≥</label><input placeholder="min"></div>
<div class="range"><label>Workmanship ≥</label><input placeholder="min"></div>
</div>
</div>
</div>
<div class="results">
<table>
<thead>
<tr><th>Item</th><th>Character</th><th>Slot</th><th class="num sorted">Value</th><th>Wield</th><th>Spells / Cantrips</th></tr>
</thead>
<tbody id="rows"></tbody>
</table>
<div class="pager">
<span class="pg"></span><span class="pg cur">1</span><span class="pg">2</span><span class="pg">3</span><span class="pg"></span>
<span style="margin-left:auto">200 / page</span>
</div>
</div>
<div class="detail" id="detail"></div>
</div>
<script>
const items = [
{ name:"Gold Ring", char:"Bowgod", slot:"Ring", value:24510, wield:"180", equipped:false, burden:50, work:"—", mana:"142/200",
spells:[["Legendary Invulnerability",1],["Legendary Summoning Prowess",1],["Major Heavy Weapon Aptitude",0],["Blessing of the Arrow Turner",0]] },
{ name:"Sapphire Ring", char:"Larsson", slot:"Ring", value:18200, wield:"150", equipped:true, burden:50, work:"—", mana:"98/180",
spells:[["Legendary Invulnerability",1],["Epic Focus",0],["Minor Impenetrability",0]] },
{ name:"Bronze Ring", char:"Mrbow", slot:"Ring", value:9340, wield:"125", equipped:false, burden:50, work:"—", mana:"77/120",
spells:[["Legendary Invulnerability",1],["Epic Willpower",0]] },
{ name:"Diamond Ring", char:"Kosmo Kramer", slot:"Ring", value:31000, wield:"200", equipped:false, burden:50, work:"—", mana:"200/200",
spells:[["Legendary Invulnerability",1],["Legendary Willpower",1],["Epic Mana Conversion Prowess",0]] },
{ name:"Ivory Ring", char:"Bank of Sawato", slot:"Ring", value:12750, wield:"150", equipped:false, burden:50, work:"—", mana:"64/150",
spells:[["Legendary Invulnerability",1],["Major Void Magic Aptitude",0]] },
{ name:"Black Opal Ring", char:"Bowgod", slot:"Ring", value:27890, wield:"180", equipped:true, burden:50, work:"—", mana:"180/220",
spells:[["Legendary Invulnerability",1],["Legendary Critical Damage",1]] },
];
function render() {
const tb = document.getElementById('rows');
tb.innerHTML = items.map((it,i) => `
<tr onclick="sel(${i})" id="r${i}">
<td>${it.name}${it.equipped ? ' <span class="equipped"></span>' : ''}</td>
<td>${it.char}</td><td>${it.slot}</td>
<td class="num">${it.value.toLocaleString()}</td><td>${it.wield}</td>
<td class="spells">${it.spells.map(([s,l]) => l ? `<span class="leg">${s}</span>` : s).join(', ')}</td>
</tr>`).join('');
}
function sel(i) {
document.querySelectorAll('tbody tr').forEach(r=>r.classList.remove('sel'));
document.getElementById('r'+i).classList.add('sel');
const it = items[i];
document.getElementById('detail').innerHTML = `
<span class="closex" onclick="document.getElementById('detail').innerHTML=hint">×</span>
<h3>${it.name}</h3>
<div class="sub">${it.slot} · ${it.equipped ? '⚔ Equipped' : '📦 Inventory'}</div>
<div class="kv"><span>Character</span><b>${it.char}</b></div>
<div class="kv"><span>Value</span><b>${it.value.toLocaleString()}</b></div>
<div class="kv"><span>Burden</span><b>${it.burden}</b></div>
<div class="kv"><span>Wield req</span><b>Level ${it.wield}</b></div>
<div class="kv"><span>Workmanship</span><b>${it.work}</b></div>
<div class="kv"><span>Mana</span><b>${it.mana}</b></div>
<hr>
<div class="sphead">Spells (${it.spells.length})</div>
${it.spells.map(([s,l]) => `<div class="sp${l ? ' leg' : ''}">${s}</div>`).join('')}
<div class="hint">↑/↓ next item · Esc close</div>`;
}
function filterCantrips(q) {
q = q.toLowerCase();
document.querySelectorAll('.cf').forEach(el => {
el.style.display = el.textContent.toLowerCase().includes(q) ? '' : 'none';
});
}
const hint = '<div class="hint">Click a row to inspect an item.</div>';
document.getElementById('detail').innerHTML = hint;
render();
sel(0);
document.addEventListener('keydown', e => {
const cur = document.querySelector('tbody tr.sel');
if (!cur) return;
const i = +cur.id.slice(1);
if (e.key === 'ArrowDown' && i < items.length-1) { sel(i+1); e.preventDefault(); }
if (e.key === 'ArrowUp' && i > 0) { sel(i-1); e.preventDefault(); }
if (e.key === 'Escape') { document.querySelectorAll('tbody tr').forEach(r=>r.classList.remove('sel')); document.getElementById('detail').innerHTML = hint; }
});
</script>
</body>
</html>

View file

@ -1,77 +0,0 @@
# Weapon OD — recompute from the VTank loot profile (010 scale)
**Date:** 2026-07-15
**Status:** Approved (user directed the rebuild)
## Problem
The shipped OD (`od.go`) ports UtilityBelt's unbounded "over-retail damage"
float (16 … +43). The user's actual in-game OD is a **010** (casters 07)
integer they see on ident. Investigation (this session) proved:
- Mag-Tools has **no** OD calculation. Its `ItemInfoPrinter` prints the item
info **plus the matching VirindiTank loot-rule name** (`GetLootRuleInfoFromItemInfo`).
- The user's `VirindiTank\Loot.utl` defines a ladder of rules named
`(H) Axe (OD +10)`, `(OD +9)`, … per weapon bucket. Mag-Tools prints whichever
matched — **that** is the OD the user sees.
The thresholds are the same across all the user's profiles (user-confirmed), so
one table drives every weapon and caster.
## The rule (decoded from Loot.utl)
Each bucket is `OD = clamp(stat baseline, 1, cap)`, where the bucket is matched
by **weapon skill + mastery + (optional) weapon-name filter**, and `stat` is a
raw captured value. Ladders step by 1 per tier.
- **Melee** (object_class 1): `stat = MaxDamage` (int key `218103842`), cap 10.
27 buckets (skill `218103840`, mastery `353`, name filter, baseline) — see the
table in the plan. Example: 2H Cleaving baseline 45 → Tetsubo (maxdmg 85) = OD
**+10** (matches in-game); Heavy Axe baseline 74.
- **Crossbow** (object_class 9, mastery `353`=9): `stat = int key 218103839`
("effective missile damage"), baseline **77**, cap 10 (OD+10=87 … OD+1=78,
verified on Fire Compound Crossbow k839=89 → +10).
- **Casters** (object_class 31): `stat = ElementalDamageVersusMonsters` (double
key `152`), baseline **1.18**, ×100, cap **7**. War bucket: skill `159`=34,
name `Baton|Sceptre|Staff`; Void bucket: skill 43, name `Nether`. OD+7 at
elemVsMon 1.25, OD+1 at 1.19. Frost Baton (1.40) → +7.
- **No match / stat below baseline+1 → NULL** (item shows no OD, exactly like
in-game when no OD rule matches).
## Deferred: Bows & Thrown (mastery 8 / 10)
Their OD rules threshold a **computed decimal** damage stat (bow OD+10 = 78.6,
thrown = 85.3) on a key not reliably identifiable from the profile alone (bow
`218103839` values 4570 don't reach 78.6; the rule uses a DoubleValKeyGE the
`.utl` encodes indirectly). To avoid showing a *wrong* number (the very problem
we're fixing), **bows and thrown get NULL OD** in this pass. Reopened once the
user supplies one in-game bow OD reading to calibrate the stat.
## Design
- **`od.go` rewritten** around an ordered bucket table (extracted from
`Loot.utl`, committed as data): `{objectClass, skill, mastery, nameAlts[],
statKey, baseline, cap}`. `computeOD` finds the first bucket whose
objectClass+skill+mastery+name match, then `OD = clamp(stat baseline, 1,
cap)`; returns null if none match or stat ≤ baseline. Name match = any
alternative is a substring of the item name (VTank substring semantics).
- **Type**: `od_rating` stays `DOUBLE PRECISION NULL` but now holds an integer
110 (or 17). Frontend already renders it; drop the `+`/decimals for the new
integer (show plain `7`, `10`, `—`).
- Search `min_od`/`max_od`/sort unchanged. Backfill via existing
`POST /admin/backfill-od`.
- The best-values table, spell-effect dicts, and variance math in the old
`od.go` are **deleted** (wrong metric).
## Testing
Go golden tests from real items: Tetsubo → 10, Frost Baton → 7, Fire Compound
Crossbow → 10, a mid melee → its expected tier, a below-baseline weapon → null,
a bow → null (deferred). Then live backfill + spot-checks; the column must read
010/07 and the user confirms against ident.
## Out of scope
- Bows/thrown OD (deferred, null).
- The loot-rule name filters are reproduced as substring lists; exotic renamed
items may differ from a live VTank match — acceptable.

View file

@ -1,132 +0,0 @@
# Weapon OD rating — compute, store, filter
**Date:** 2026-07-15
**Status:** Approved
## Problem
The inventory search has weak weapon/wand support. Mag-Tools (and UtilityBelt's
port of it, the "retail comparison" / OD feature) classifies weapons by **OD
("over damage")** — how much better a weapon's optimally-tinked output is than
the best possible *retail* loot weapon of its class and wield tier. Users want
to filter weapons by OD.
## Authoritative algorithm (researched 2026-07-15)
Source: `UtilityBelt/UtilityBelt/Tools/ItemInfo.cs` (sections titled "Slightly
modified MagTools item description classes", OD display at lines ~631-647) +
`UtilityBelt/UtilityBelt/Lib/ItemInfoHelper/{MiscCalcs,WeaponMods,Dictionaries}.cs`
in this workspace. WeaponMods.cs holds the **best-values table**: rows keyed by
`(Skill, Mastery, MultiStrike, WieldReq)` with columns
`MaxDmg, MaxVar, MaxDmgMod, MaxElementalDmgBonus, MaxElementalDmgVsMonsters`
(sourced from acpedia; ~330 rows covering heavy/light/finesse/two-handed melee
subtypes, bow/xbow/thrown, war/void casters).
Raw item keys (verified present in our `item_raw_data`):
- IntValues: `159` EquipSkill, `353` Mastery/WeaponType, `160` WieldReqValue,
`218103842` MaxDamage, `204` ElementalDmgBonus, `171` Tinks, `179` Imbued
(bitmask, nonzero = imbued), `131` Material.
- `original_json->DoubleValues`: `167772171` Variance, `167772174` DamageBonus
(missile dmg modifier, e.g. 2.73 = 173%), `152` ElementalDmgVsMonsters
(wands), `167772169` SalvageWorkmanship.
- `original_json->Spells` / `ActiveSpells`: innate and active spell id arrays.
**Buffed values** (GetBuffedIntValueKey / GetBuffedDoubleValueKey semantics):
value + Σ **bonus**(innate Spells) Σ **change**(ActiveSpells). Each spell
entry is a {change, bonus} pair (`SpellInfo(key, change, bonus=0)`,
Dictionaries.cs:356). Old-style item spells were converted to player auras in
2012, so their innate **bonus is 0** — only their active-enchant change is
subtracted; the Blood/Spirit Thirst cantrips have bonus == change:
- MaxDamage — 1616 BD VI {20,0}, 2096 Infected Caress {22,0}, 5183/4395
Incant. BD {24,0}, 2598 Minor BT {2,2}, 2586 Major BT {4,4}, 4661 Epic BT
{7,7}, 6089 Legendary BT {10,10}, 3688 Prodigal BD {300,0}.
- ElemVsMonsters — 3258 SD VI {.06,0}, 3259 Infected Spirit Caress {.07,0},
5182/4414 Incant. SD {.08,0}, 3251 Minor ST {.01,.01}, 3250 Major ST
{.03,.03}, 4670 Epic ST {.05,.05}, 6098 Legendary ST {.07,.07},
3735 Prodigal Spirit Drinker {.15,0}.
**Missile dmgMod key**: UB's literal CalcMissileDamage reads `DoubleValues[63]`,
but no DoubleValueKey ordinal 63 exists in Decal — the populated key is
`167772174` (DamageBonus), consistent with UB's own CalcedBuffedMissileDamage.
We use 167772174 deliberately.
**Melee variance orientation**: the table's MaxVar is the type's *baseline*
variance; a weapon with LOWER variance than baseline yields a NEGATIVE
varianceTinks (log ratio < 1 over log 0.8) and therefore a HIGHER OD
`varianceTinks = round(log(MaxVar_t / variance)/log(0.8), 2)` exactly as
ItemInfo.cs:1096.
**MultiStrike** weapons: WeaponType (key 47 in UB code = our int key 47? no —
UB reads LongValueKey 47 = WeaponType master id; the multistrike check is:
`IntValues[47] ∈ {160,166,486}` or (`47==4` and mastery `353==11`)). Verify key
47 exists in our data during implementation; if absent, derive from mastery
subtype comments in the table (msdagger/mssword/cleaver rows are the
MultiStrike=1 rows).
Formulas (OD, higher = better; retail max = 0):
- **Melee** (object_class 1):
`varianceTinks = round(log(MaxVar_t / variance) / log(0.8), 2)`;
`OD = buffedMaxDmg varianceTinks MaxDmg_t`.
- **Missile** (object_class 9):
`dmgMod = buffedDmgBonus·100 100`; `arrowMax` by mastery: bow(8)=40,
xbow(9)=53, thrown(10)=42; `remainingTinks`: 10 tinks, 1 more if not
imbued, floor 0 (loot-gen only, see gate below; UB nuance: if tinks==0,
remainingTinks=9);
`buffedDmg = buffedMaxDmg; if ≤10 then +24`;
`maxMod = (MaxDmgMod_t + 100 + 36)/100`;
`OD = (1 + (dmgMod + 4·remainingTinks)/100) · (buffedElemBonus + buffedDmg + arrowMax) / maxMod (MaxElemBonus_t + 24 + arrowMax)`.
- **Wand** (object_class 31, skill 34 war / 43 void):
`OD = (buffedElemVsMonsters MaxElemVsMonsters_t) · 100` (percentage points;
e.g. live Frost Baton: (1.40 1.18)·100 = +22).
- **Gate**: OD only for loot-gen items (`SalvageWorkmanship > 0`); otherwise
NULL. Table lookup miss (unknown skill/mastery/wieldreq combo) → NULL.
Round stored OD to 2 decimals.
## Decisions (user-approved)
1. **Raw OD number** with a `min OD ≥` filter (not a bucketed 0-10 scale).
2. **All classes**: melee, missile, wands.
3. **Computed at ingest**, stored on `items.od_rating DOUBLE PRECISION NULL`,
with a one-off backfill for existing rows.
## Backend (inventory-go)
- New `od.go`: best-values table ported from UB `WeaponMods.cs` (verify row
count and spot-check values against the source file), buffed-value helpers,
`computeOD(raw map[string]any) (float64, bool)`. TDD with golden values
(incl. the live Frost Baton +22 case).
- `processItem` sets `items["od_rating"]` (or omits when NULL) — the dynamic
insert in ingest.go picks it up automatically.
- `schema.go`: add `od_rating DOUBLE PRECISION` to the items DDL (fresh
installs). Live DB gets a manual
`ALTER TABLE items ADD COLUMN IF NOT EXISTS od_rating double precision`
(prod runs SKIP_SCHEMA_INIT=true).
- Search: `i.od_rating` added to the CTE; `min_od` (>=) and `max_od` (<=)
params; sort key `od`. NULL od never matches the filters (SQL NULL
semantics) and sorts NULLS LAST/FIRST as usual.
- Backfill: `POST /admin/backfill-od` — iterates items of object_class 1/9/31
joined to item_raw_data, recomputes, UPDATEs `od_rating`; returns counts.
Service is internal-only (127.0.0.1:8772 / compose network).
## Frontend
- `RATING_DEFS` gains `{param:'min_od', label:'Weapon OD', common:true}`
flows through the existing ratings record, sidebar inputs, chips, and URL
state with no new plumbing.
- `COLUMNS` gains `{key:'od_rating', label:'OD', sortKey:'od', defaultVisible:false}`;
cell renders signed 2-decimal (`+3.25` / `-1.29`), `—` for null.
- `InvItem.od_rating?: number | null`; DetailPanel row "OD" for weapons.
## Verification
- Go unit tests (golden formula values + table spot checks) gate the image
build as usual.
- Live: backfill count > 0; `min_od=20` returns the Frost Baton; melee/missile
spot checks against UB in-game values if available; sort by OD desc.
## Out of scope
- Bucketed OD tiers (can be derived later).
- The old suitbuilder page.
- Recomputing OD when spell/enchant state changes intra-session (ingest
updates overwrite it naturally via the debounced item updates).

View file

@ -1,78 +0,0 @@
# Weapon-type (by skill) multi-select filter + OD column for weapon searches
**Date:** 2026-07-15
**Status:** Pre-approved by user
## Problem
Weapon searching is weak: the current weapon-type filter is a single-select
dropdown (`weapon_type`, shown only under the "Weapons" item type) and the OD
column is hidden by default. The user wants to search across weapon types by
skill (Heavy, Light, Finesse, Two-Handed, Missile, War/Void casters), pick
more than one at once, and see the OD value in the results columns.
## Field semantics (verified against live data)
- Melee (`object_class = 1`): WeaponSkill `int_values->>'218103840'` = 44 Heavy,
45 Light, 46 Finesse, 41 Two-Handed.
- Missile (`object_class = 9`): WeaponSkill `218103840` = 47; subtype from
mastery `int_values->>'353'` = 8 Bow, 9 Crossbow, 10 Thrown. Arrows/ammo lack
skill 47, so gating on it excludes them — and fixes the existing bug where
the name-based `%bow%` clause matched "Bowl".
- Wands (`object_class = 31`): WeaponSkill is null; use equip skill
`int_values->>'159'` = 34 War Magic, 43 Void Magic (the only caster schools
the OD table rates).
## Decisions
1. **Weapon-type becomes multi-select** (checkbox group) with seven
skill-level categories: Heavy, Light, Finesse, Two-Handed, Missile,
War Magic, Void Magic. Any combination; none = all weapons. Shown when the
"Weapons" item type is selected (same nesting the single dropdown used).
2. **Backend** accepts a new `weapon_types` CSV param → OR of per-type clauses,
ANDed into the query. The legacy single `weapon_type` param is kept for
back-compat. `weaponTypeClause` is reworked to be object-class/key aware
(melee by 218103840, missile launcher by 218103840=47 + mastery, casters by
159), replacing the melee-only `exists(skill)` helper. Existing type keys
(bow/crossbow/thrown/caster/light/finesse/heavy/two_handed) keep working;
bow/crossbow/thrown switch to the robust mastery-based clause.
3. **OD column auto-shows for weapon searches**: when `itemType === 'weapon'`,
the results table includes the `od_rating` column regardless of the user's
saved column set (union, non-destructive — their other prefs and the column
picker still work; OD just can't be hidden while weapon-searching). No
change to default sort.
## Frontend model
- `SearchFilters.weaponType: string``weaponTypes: string[]`. Update
DEFAULT_FILTERS, `validateFilterValue` (string array), `buildParams`
(`weapon_types` CSV when non-empty), FilterSidebar (checkbox group replacing
the `<select>`), ActiveChips (one chip per selected type, removable).
- `WEAPON_TYPES` in constants becomes the seven skill categories
`{value,label}`: heavy/light/finesse/two_handed/missile/war/void.
- ResultsTable computes effective visible columns = saved set
(`itemType==='weapon'` ? {od_rating} : {}).
## Testing
- Backend: Go unit tests on the reworked `weaponTypeClause` (correct SQL per
type: object_class, keys, mastery) and the `weapon_types` CSV → OR
composition in `runSearch` (via the existing search-string test approach if
present, else targeted clause tests). No DB-backed tests (consistent with the
rest of search.go); live verification after deploy.
- Frontend: `npm run build` (tsc) green; live browser verification.
## Verification (live)
- `weapon_types=heavy,two_handed` returns only skill-44 and skill-41 melee.
- `weapon_types=missile` returns bows/xbows/thrown, no "Bowl".
- `weapon_types=war` / `void` return the respective caster schools.
- Multi-select in the UI; OD column visible automatically on weapon searches;
chips add/remove; sort by OD works.
## Out of scope
- Changing OD math or the best-values table.
- Non-weapon filters, suitbuilder.
- Splitting missile back into per-subtype UI checkboxes (Missile is one
category; the backend still supports bow/crossbow/thrown individually).

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,32 +0,0 @@
import { MapLayout } from './components/map/MapLayout';
import { PlayerDashboardFullPage } from './components/PlayerDashboardFullPage';
import { InventorySearchPage } from './components/inventory/InventorySearchPage';
import { MidsummerProvider } from './hooks/useMidsummer';
import { useLiveData } from './hooks/useLiveData';
import './styles/map-layout.css';
import './styles/midsummer.css';
import './styles/inventory.css';
/**
* Single SPA entry. Branches on `?view=` query param:
* /?view=dashboard fullscreen PlayerDashboardFullPage (new-tab target)
* /?view=inventory fullscreen InventorySearchPage (new-tab target)
* / default map + sidebar layout
*/
export default function App() {
const view = new URLSearchParams(window.location.search).get('view');
return (
<MidsummerProvider>
{view === 'dashboard' ? <PlayerDashboardFullPage />
: view === 'inventory' ? <InventorySearchPage />
: <DefaultApp />}
</MidsummerProvider>
);
}
/** 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,55 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useLiveData } from '../hooks/useLiveData';
import { PlayerDashboardContent } from './windows/PlayerDashboardWindow';
import { MidsummerBanner } from './midsummer/MidsummerBanner';
import { MidsummerRain } from './midsummer/MidsummerRain';
/**
* 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">
<MidsummerBanner />
<MidsummerRain />
<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,62 +0,0 @@
import type { InventorySearch } from './useInventorySearch';
import { RATING_DEFS, WEAPON_TYPES } from './constants';
interface Chip { label: string; gold?: boolean; remove: () => void; }
export function ActiveChips({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const chips: Chip[] = [];
if (f.text) chips.push({ label: `"${f.text}"`, remove: () => update({ text: '' }) });
if (f.characters !== 'all') chips.push({
label: `${f.characters.length} character${f.characters.length === 1 ? '' : 's'}`,
remove: () => update({ characters: 'all' }),
});
if (f.itemType !== 'all') chips.push({
label: f.itemType[0].toUpperCase() + f.itemType.slice(1),
remove: () => update({ itemType: 'all', weaponTypes: [] }),
});
if (f.itemType === 'weapon') for (const wt of f.weaponTypes) {
const label = WEAPON_TYPES.find(w => w.value === wt)?.label ?? wt;
chips.push({ label, remove: () => update({ weaponTypes: f.weaponTypes.filter(x => x !== wt) }) });
}
for (const s of f.slots) chips.push({ label: `Slot: ${s}`, remove: () => update({ slots: f.slots.filter(x => x !== s) }) });
for (const c of f.cantrips) chips.push({
label: c.replace(/^Legendary /, 'Leg. '), gold: true,
remove: () => update({ cantrips: f.cantrips.filter(x => x !== c) }),
});
if (f.spellContains) chips.push({ label: `Spell: ${f.spellContains}`, remove: () => update({ spellContains: '' }) });
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') {
const def = RATING_DEFS.find(r => r.param === param);
chips.push({
label: `${def?.label ?? param}${v}`,
remove: () => {
const { [param]: _omit, ...rest } = f.ratings;
update({ ratings: rest });
},
});
}
if (f.itemSet) chips.push({ label: `Set: ${f.itemSet}`, remove: () => update({ itemSet: '' }) });
if (f.equipStatus) chips.push({
label: f.equipStatus === 'equipped' ? 'Equipped' : 'Not equipped',
remove: () => update({ equipStatus: '' }),
});
if (f.bonded) chips.push({ label: 'Bonded', remove: () => update({ bonded: false }) });
if (f.attuned) chips.push({ label: 'Attuned', remove: () => update({ attuned: false }) });
if (f.rare) chips.push({ label: 'Rare', remove: () => update({ rare: false }) });
if (f.maxLevel !== '') chips.push({ label: `Wield ≤ ${f.maxLevel}`, remove: () => update({ maxLevel: '' }) });
if (f.minValue !== '') chips.push({ label: `Value ≥ ${f.minValue}`, remove: () => update({ minValue: '' }) });
if (f.minWorkmanship !== '') chips.push({ label: `Work ≥ ${f.minWorkmanship}`, remove: () => update({ minWorkmanship: '' }) });
if (f.maxBurden !== '') chips.push({ label: `Burden ≤ ${f.maxBurden}`, remove: () => update({ maxBurden: '' }) });
if (!chips.length) return null;
return (
<div className="inv-chipsrow">
<span className="inv-chips-lbl">ACTIVE</span>
{chips.map((c, i) => (
<span className={`inv-chip${c.gold ? ' inv-chip-gold' : ''}`} key={i}>
{c.label} <span className="inv-chip-x" onClick={c.remove}>×</span>
</span>
))}
</div>
);
}

View file

@ -1,46 +0,0 @@
import { useState } from 'react';
import { CANTRIP_GROUPS } from './constants';
import type { InventorySearch } from './useInventorySearch';
import { Group } from './FilterSidebar';
export function CantripFilter({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [q, setQ] = useState('');
const toggle = (v: string) => update({
cantrips: f.cantrips.includes(v) ? f.cantrips.filter(x => x !== v) : [...f.cantrips, v],
});
const match = (label: string) => label.toLowerCase().includes(q.toLowerCase());
return (
<Group title="Cantrips" defaultOpen badge={f.cantrips.length || undefined}>
<input className="inv-minisearch" placeholder="find cantrip… e.g. invuln"
value={q} onChange={e => setQ(e.target.value)} />
{f.cantrips.length > 0 && (<>
<div className="inv-subhead">Selected</div>
{f.cantrips.map(v => (
<label className="inv-fitem inv-gold" key={v}>
<input type="checkbox" checked onChange={() => toggle(v)} />
{v.replace(/^Legendary /, '')}
</label>
))}
</>)}
{CANTRIP_GROUPS.map(g => {
const items = g.items.filter(i => !f.cantrips.includes(i.value) && match(i.label));
if (!items.length) return null;
return (
<div key={g.group}>
<div className="inv-subhead">{g.group}</div>
{items.map(i => (
<label className="inv-fitem" key={i.value}>
<input type="checkbox" checked={false} onChange={() => toggle(i.value)} /> {i.label}
</label>
))}
</div>
);
})}
<div className="inv-subhead">Any-tier spell search</div>
<input className="inv-minisearch" placeholder="spell name contains… e.g. Epic Invuln"
value={f.spellContains} onChange={e => update({ spellContains: e.target.value })} />
</Group>
);
}

View file

@ -1,48 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { apiFetch } from '../../api/client';
import type { InventorySearch } from './useInventorySearch';
import { Group } from './FilterSidebar';
export function CharacterFilter({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [names, setNames] = useState<string[]>([]);
const [online, setOnline] = useState<Set<string>>(new Set());
const [q, setQ] = useState('');
useEffect(() => {
apiFetch<{ characters: Array<{ character_name: string }> }>('/inv/characters/list')
.then(r => setNames(r.characters.map(c => c.character_name).sort((a, b) => a.localeCompare(b))))
.catch(() => {});
apiFetch<{ players: Array<{ character_name: string }> }>('/live')
.then(r => setOnline(new Set(r.players.map(p => p.character_name))))
.catch(() => {});
}, []);
const checked = useMemo(() =>
f.characters === 'all' ? new Set(names) : new Set(f.characters), [f.characters, names]);
const toggle = (n: string) => {
const next = new Set(checked);
if (next.has(n)) next.delete(n); else next.add(n);
update({ characters: next.size === names.length ? 'all' : [...next] });
};
const shown = names.filter(n => n.toLowerCase().includes(q.toLowerCase()));
return (
<Group title="Characters" defaultOpen
badge={f.characters === 'all' ? 'All' : f.characters.length}>
<input className="inv-minisearch" placeholder="filter characters…"
value={q} onChange={e => setQ(e.target.value)} />
<div className="inv-links">
<span className="inv-linky" onClick={() => update({ characters: 'all' })}>All</span>{' · '}
<span className="inv-linky" onClick={() => update({ characters: [] })}>None</span>{' · '}
<span className="inv-linky" onClick={() => update({ characters: names.filter(n => online.has(n)) })}>Online</span>
</div>
{shown.map(n => (
<label className="inv-fitem" key={n}>
<input type="checkbox" checked={checked.has(n)} onChange={() => toggle(n)} />
{n} {online.has(n) && <span className="inv-online" />}
</label>
))}
</Group>
);
}

View file

@ -1,36 +0,0 @@
import React from 'react';
import type { InvItem } from './types';
function Row({ k, v }: { k: string; v: React.ReactNode }) {
return <div className="inv-kv"><span>{k}</span><b>{v ?? '—'}</b></div>;
}
export function DetailPanel({ item, onClose }: { item: InvItem; onClose: () => void }) {
return (
<div className="inv-detail">
<span className="inv-closex" onClick={onClose}>×</span>
<h3>{item.name}</h3>
<div className="inv-detail-sub">
{item.slot_name ?? item.object_class_name ?? ''} · {item.is_equipped ? '⚔ Equipped' : '📦 Inventory'}
{item.is_rare && ' · ★ Rare'}
</div>
<Row k="Character" v={item.character_name} />
<Row k="Value" v={item.value?.toLocaleString()} />
<Row k="Burden" v={item.burden} />
<Row k="Wield req" v={item.wield_level ? `Level ${item.wield_level}` : '—'} />
<Row k="Workmanship" v={item.workmanship ?? '—'} />
{item.armor_level != null && item.armor_level > 0 && <Row k="Armor" v={item.armor_level} />}
{item.max_damage != null && item.max_damage > 0 && <Row k="Max damage" v={item.max_damage} />}
{item.od_rating != null && <Row k="Weapon OD" v={String(item.od_rating)} />}
{item.condition_percent != null && <Row k="Condition" v={`${item.condition_percent}%`} />}
{item.item_set_name && <Row k="Set" v={item.item_set_name} />}
{(item.is_bonded || item.is_attuned) && <Row k="Binding" v={[item.is_bonded && 'Bonded', item.is_attuned && 'Attuned'].filter(Boolean).join(', ')} />}
<hr />
<div className="inv-sphead">Spells ({item.spell_names?.length ?? 0})</div>
{(item.spell_names ?? []).map((s, i) => (
<div className={`inv-sp${/legendary/i.test(s) ? ' inv-leg' : ''}`} key={i}>{s}</div>
))}
<div className="inv-hint">/ next item · Esc close</div>
</div>
);
}

View file

@ -1,145 +0,0 @@
import React, { useEffect, useState } from 'react';
import type { InventorySearch } from './useInventorySearch';
import { CharacterFilter } from './CharacterFilter';
import { CantripFilter } from './CantripFilter';
import { ARMOR_SLOTS, JEWELRY_SLOTS, RATING_DEFS, WEAPON_TYPES } from './constants';
import type { ItemType } from './types';
import { apiFetch } from '../../api/client';
export function Group(props: {
title: string; badge?: string | number; defaultOpen?: boolean; children: React.ReactNode;
}) {
const [open, setOpen] = useState(props.defaultOpen ?? false);
return (
<div className={`inv-grp${open ? ' inv-open' : ''}`}>
<div className="inv-grp-head" onClick={() => setOpen(o => !o)}>
<span className="inv-arrow"></span> {props.title}
{props.badge ? <span className="inv-badge">{props.badge}</span> : null}
</div>
{open && <div className="inv-grp-body">{props.children}</div>}
</div>
);
}
export function FilterSidebar({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [showArmorSlots, setShowArmorSlots] = useState(false);
const [allRatings, setAllRatings] = useState(false);
const [sets, setSets] = useState<Array<{ id: string; item_count: number }>>([]);
const [setQuery, setSetQuery] = useState('');
useEffect(() => {
apiFetch<{ sets: Array<{ id: string; item_count: number }> }>('/inv/sets/list')
.then(r => setSets(r.sets)).catch(() => {});
}, []);
const toggleSlot = (s: string) => update({
slots: f.slots.includes(s) ? f.slots.filter(x => x !== s) : [...f.slots, s],
});
const setRating = (param: string, v: string) => update({
ratings: { ...f.ratings, [param]: v === '' ? '' : Number(v) },
});
const ratingCount = Object.values(f.ratings).filter(v => v !== '').length;
const stateCount = [f.equipStatus !== '', f.bonded, f.attuned, f.rare].filter(Boolean).length;
const reqCount = [f.maxLevel, f.minValue, f.minWorkmanship, f.maxBurden].filter(v => v !== '').length;
const types: Array<[ItemType, string]> = [['all', 'All items'], ['armor', 'Armor'],
['jewelry', 'Jewelry'], ['weapon', 'Weapons'], ['clothing', 'Clothing'],
['shirt', 'Shirts'], ['pants', 'Pants']];
return (
<div className="inv-sidebar">
<CharacterFilter search={search} />
<Group title="Item state" defaultOpen badge={stateCount || undefined}>
{([['', 'Any'], ['equipped', 'Equipped'], ['unequipped', 'Not equipped']] as const).map(([v, label]) => (
<label className="inv-fitem" key={label}>
<input type="radio" name="inv-eq" checked={f.equipStatus === v}
onChange={() => update({ equipStatus: v })} /> {label}
</label>
))}
<label className="inv-fitem"><input type="checkbox" checked={f.bonded}
onChange={e => update({ bonded: e.target.checked })} /> Bonded</label>
<label className="inv-fitem"><input type="checkbox" checked={f.attuned}
onChange={e => update({ attuned: e.target.checked })} /> Attuned</label>
<label className="inv-fitem"><input type="checkbox" checked={f.rare}
onChange={e => update({ rare: e.target.checked })} /> Rare</label>
</Group>
<Group title="Item type" defaultOpen badge={f.itemType !== 'all' ? 1 : undefined}>
{types.map(([v, label]) => (
<label className="inv-fitem" key={v}>
<input type="radio" name="inv-t" checked={f.itemType === v}
onChange={() => update({ itemType: v, weaponTypes: [] })} /> {label}
</label>
))}
{f.itemType === 'weapon' && (
<div className="inv-subgroup">
{WEAPON_TYPES.map(w => (
<label className="inv-fitem" key={w.value}>
<input type="checkbox" checked={f.weaponTypes.includes(w.value)}
onChange={() => update({
weaponTypes: f.weaponTypes.includes(w.value)
? f.weaponTypes.filter(x => x !== w.value)
: [...f.weaponTypes, w.value],
})} /> {w.label}
</label>
))}
</div>
)}
</Group>
<Group title="Slots" defaultOpen badge={f.slots.length || undefined}>
{JEWELRY_SLOTS.map(s => (
<label className="inv-fitem" key={s}>
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
</label>
))}
<span className="inv-linky" onClick={() => setShowArmorSlots(v => !v)}>
{showArmorSlots ? 'hide' : 'show'} armor slots {showArmorSlots ? '▴' : '▾'}
</span>
{showArmorSlots && ARMOR_SLOTS.map(s => (
<label className="inv-fitem" key={s}>
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
</label>
))}
</Group>
<CantripFilter search={search} />
<Group title="Ratings" badge={ratingCount || undefined}>
{RATING_DEFS.filter(r => allRatings || r.common).map(r => (
<div className="inv-range" key={r.param}>
<label>{r.label} </label>
<input type="number" value={f.ratings[r.param] ?? ''} placeholder="min"
onChange={e => setRating(r.param, e.target.value)} />
</div>
))}
<span className="inv-linky" onClick={() => setAllRatings(v => !v)}>
{allRatings ? 'common ratings ▴' : `all ${RATING_DEFS.length} ratings ▾`}
</span>
</Group>
<Group title="Equipment sets" badge={f.itemSet ? 1 : undefined}>
<input className="inv-minisearch" placeholder="find set…" value={setQuery}
onChange={e => setSetQuery(e.target.value)} />
<label className="inv-fitem">
<input type="radio" name="inv-set" checked={f.itemSet === ''}
onChange={() => update({ itemSet: '' })} /> Any set
</label>
{sets.filter(s => s.id.toLowerCase().includes(setQuery.toLowerCase())).map(s => (
<label className="inv-fitem" key={s.id}>
<input type="radio" name="inv-set" checked={f.itemSet === s.id}
onChange={() => update({ itemSet: s.id })} /> {s.id} <span className="inv-dim">({s.item_count})</span>
</label>
))}
</Group>
<Group title="Reqs & value" badge={reqCount || undefined}>
<div className="inv-range"><label>Wield lvl </label>
<input type="number" value={f.maxLevel} placeholder="max"
onChange={e => update({ maxLevel: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Value </label>
<input type="number" value={f.minValue} placeholder="min"
onChange={e => update({ minValue: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Workmanship </label>
<input type="number" value={f.minWorkmanship} placeholder="min"
onChange={e => update({ minWorkmanship: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Burden </label>
<input type="number" value={f.maxBurden} placeholder="max"
onChange={e => update({ maxBurden: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
</Group>
</div>
);
}

View file

@ -1,92 +0,0 @@
import { Component, useEffect, useState, type ErrorInfo, type ReactNode } from 'react';
import { useInventorySearch } from './useInventorySearch';
import { FilterSidebar } from './FilterSidebar';
import { ActiveChips } from './ActiveChips';
import { ResultsTable } from './ResultsTable';
import { DetailPanel } from './DetailPanel';
import type { InvItem } from './types';
/** Composite key used to re-match a selected item across refetches (objects are recreated each fetch). */
function itemKey(i: InvItem): string {
return `${i.name} ${i.character_name} ${i.last_updated ?? ''}`;
}
class InventorySearchErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('Inventory search crashed:', error, info);
}
render(): ReactNode {
if (this.state.hasError) {
return (
<div className="inv-page">
<div className="inv-error" style={{ margin: 'auto', textAlign: 'center', padding: 24 }}>
<p style={{ marginBottom: 12 }}>Something went wrong loading the inventory search page.</p>
<button className="inv-btn" onClick={() => { window.location.href = '/?view=inventory'; }}>
Reset filters
</button>
</div>
</div>
);
}
return this.props.children;
}
}
function InventorySearchPageInner() {
const search = useInventorySearch();
const [selected, setSelected] = useState<InvItem | null>(null);
// Selection is by object identity; every refetch produces new item objects, so re-match the
// previously selected item by composite key in the new result set (or clear it if it's gone).
useEffect(() => {
if (!selected) return;
const items = search.result?.items ?? [];
const key = itemKey(selected);
const match = items.find(i => itemKey(i) === key);
if (match && match !== selected) setSelected(match);
else if (!match) setSelected(null);
}, [search.result]);
return (
<div className="inv-page">
<div className="inv-topbar">
<span className="inv-title"> Inventory Search</span>
<input
className="inv-searchbox"
placeholder="Search name or material…"
value={search.filters.text}
onChange={e => search.update({ text: e.target.value })}
/>
<button className="inv-btn" onClick={() => { search.reset(); setSelected(null); }}>Reset</button>
<span className="inv-count">
{search.error ? <span className="inv-error">{search.error}</span>
: search.result ? <><b>{search.result.total_count.toLocaleString()}</b> items
{search.queryMs != null && <> · {search.queryMs} ms</>}
{search.loading && ' · …'}</>
: 'loading…'}
</span>
</div>
<ActiveChips search={search} />
<div className="inv-main">
<FilterSidebar search={search} />
<ResultsTable search={search} selected={selected} onSelect={setSelected} />
{selected && <DetailPanel item={selected} onClose={() => setSelected(null)} />}
</div>
</div>
);
}
export function InventorySearchPage() {
return (
<InventorySearchErrorBoundary>
<InventorySearchPageInner />
</InventorySearchErrorBoundary>
);
}

View file

@ -1,147 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { COLUMNS, COLUMNS_LS_KEY, RENDER_CHUNK } from './constants';
import type { InventorySearch } from './useInventorySearch';
import type { InvItem } from './types';
function loadVisible(): Set<string> {
try {
const raw = localStorage.getItem(COLUMNS_LS_KEY);
if (raw) return new Set(JSON.parse(raw));
} catch { /* fall through */ }
return new Set(COLUMNS.filter(c => c.defaultVisible).map(c => c.key));
}
function cell(item: InvItem, key: string): React.ReactNode {
switch (key) {
case 'name':
return <>{item.name}{item.is_equipped && <span className="inv-equipped"> </span>}</>;
case 'spell_names':
return <span className="inv-spells">{(item.spell_names ?? []).map((s, i) => (
<span key={i}>{i > 0 && ', '}<span className={/legendary/i.test(s) ? 'inv-leg' : ''}>{s}</span></span>
))}</span>;
case 'value':
return item.value != null ? item.value.toLocaleString() : '—';
case 'last_updated':
return item.last_updated ? item.last_updated.slice(0, 16).replace('T', ' ') : '—';
case 'od_rating': {
const v = item.od_rating;
return v == null ? '—' : String(v);
}
default: {
const v = (item as any)[key];
return v == null || v === -1 ? '—' : String(v);
}
}
}
export function ResultsTable({ search, selected, onSelect }: {
search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void;
}) {
const { filters: f, update, result } = search;
const [visible, setVisible] = useState<Set<string>>(loadVisible);
const [pickerOpen, setPickerOpen] = useState(false);
// Incremental rendering: the full result set is loaded in one request, but
// only renderCount rows are in the DOM; scrolling near the bottom grows it.
const [renderCount, setRenderCount] = useState(RENDER_CHUNK);
const scrollRef = useRef<HTMLDivElement>(null);
const cols = useMemo(() => {
const eff = new Set(visible);
if (f.itemType === 'weapon') eff.add('od_rating');
return COLUMNS.filter(c => eff.has(c.key));
}, [visible, f.itemType]);
const items = result?.items ?? [];
const shown = items.slice(0, renderCount);
// New result set (search/sort/filter change) → jump back to the top.
useEffect(() => {
setRenderCount(RENDER_CHUNK);
scrollRef.current?.scrollTo(0, 0);
}, [result]);
const onScroll = () => {
const el = scrollRef.current;
if (!el || renderCount >= items.length) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 600) {
setRenderCount(c => Math.min(c + RENDER_CHUNK, items.length));
}
};
useEffect(() => {
try {
localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible]));
} catch {
/* Safari private mode throws on quota — non-fatal, column prefs just won't persist. */
}
}, [visible]);
// Keyboard: ↑/↓ moves selection, Esc clears — ignore while typing in inputs.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement)?.tagName === 'INPUT' || (e.target as HTMLElement)?.tagName === 'SELECT') return;
if (e.key === 'Escape') { onSelect(null); return; }
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const idx = selected ? items.indexOf(selected) : -1;
const next = e.key === 'ArrowDown' ? Math.min(idx + 1, items.length - 1) : Math.max(idx - 1, 0);
if (items[next]) {
onSelect(items[next]);
// Keep keyboard navigation working past the rendered window.
if (next >= renderCount) setRenderCount(c => Math.min(c + RENDER_CHUNK, items.length));
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [items, selected, onSelect, renderCount]);
const sortOn = (sortKey?: string) => {
if (!sortKey) return;
if (f.sortBy === sortKey) update({ sortDir: f.sortDir === 'asc' ? 'desc' : 'asc' });
else update({ sortBy: sortKey, sortDir: 'asc' });
};
return (
<div className="inv-results" ref={scrollRef} onScroll={onScroll}>
<div className="inv-colpicker-anchor">
<button className="inv-btn inv-colpicker-btn" onClick={() => setPickerOpen(o => !o)}> Columns</button>
{pickerOpen && (
<div className="inv-colpicker">
{COLUMNS.map(c => (
<label className="inv-fitem" key={c.key}>
<input type="checkbox" checked={visible.has(c.key)} onChange={() => {
const next = new Set(visible);
if (next.has(c.key)) next.delete(c.key); else next.add(c.key);
setVisible(next);
}} /> {c.label}
</label>
))}
</div>
)}
</div>
<table>
<thead><tr>
{cols.map(c => (
<th key={c.key} className={f.sortBy === c.sortKey ? `inv-sorted-${f.sortDir}` : ''}
onClick={() => sortOn(c.sortKey)}>{c.label}</th>
))}
</tr></thead>
<tbody>
{shown.map((it, i) => (
<tr key={i} className={it === selected ? 'inv-sel' : ''}
onClick={() => onSelect(it === selected ? null : it)}>
{cols.map(c => <td key={c.key}>{cell(it, c.key)}</td>)}
</tr>
))}
{!items.length && <tr><td colSpan={cols.length} className="inv-dim">No items match.</td></tr>}
</tbody>
</table>
{renderCount < items.length && (
<div className="inv-pager"><span className="inv-dim">Scroll for more ({shown.length.toLocaleString()} of {items.length.toLocaleString()} shown)</span></div>
)}
{result?.has_next && renderCount >= items.length && (
<div className="inv-pager"><span className="inv-dim">
Showing the first {items.length.toLocaleString()} of {result.total_count.toLocaleString()} matches narrow your filters to see the rest.
</span></div>
)}
</div>
);
}

View file

@ -1,110 +0,0 @@
export interface CantripDef { value: string; label: string; }
export interface CantripGroup { group: string; items: CantripDef[]; }
const c = (value: string): CantripDef => ({ value, label: value.replace(/^Legendary /, '') });
export const CANTRIP_GROUPS: CantripGroup[] = [
{ group: 'Attributes', items: [
c('Legendary Strength'), c('Legendary Endurance'), c('Legendary Quickness'),
c('Legendary Coordination'), c('Legendary Willpower'), c('Legendary Focus'),
]},
{ group: 'Weapon skills', items: [
c('Legendary Heavy Weapon Aptitude'), c('Legendary Light Weapon Aptitude'),
c('Legendary Finesse Weapon Aptitude'), c('Legendary Missile Weapon Aptitude'),
c('Legendary Two Handed Combat Aptitude'), c('Legendary Dual Wield Aptitude'),
c('Legendary Shield Aptitude'), c('Legendary Sneak Attack Prowess'),
c('Legendary Dirty Fighting Prowess'), c('Legendary Recklessness Prowess'),
c('Legendary Defender'), c('Legendary Blood Thirst'),
]},
{ group: 'Magic', items: [
c('Legendary War Magic Aptitude'), c('Legendary Void Magic Aptitude'),
c('Legendary Creature Enchantment Aptitude'), c('Legendary Item Enchantment Aptitude'),
c('Legendary Life Magic Aptitude'), c('Legendary Mana Conversion Prowess'),
c('Legendary Arcane Prowess'), c('Legendary Hermetic Link'),
c('Legendary Spirit Thirst'), c('Legendary Magic Resistance'),
]},
{ group: 'Utility', items: [
c('Legendary Summoning Prowess'), c('Legendary Healing Prowess'),
c('Legendary Leadership'), c('Legendary Deception Prowess'),
c('Legendary Person Attunement'), c('Legendary Magic Item Tinkering Expertise'),
]},
{ group: 'Defense', items: [
c('Legendary Invulnerability'), c('Legendary Impenetrability'),
c('Legendary Impregnability'), c('Legendary Armor'),
]},
{ group: 'Wards & Banes', items: [
c('Legendary Flame Ward'), c('Legendary Frost Ward'), c('Legendary Acid Ward'),
c('Legendary Storm Ward'), c('Legendary Slashing Ward'), c('Legendary Piercing Ward'),
c('Legendary Bludgeoning Ward'), c('Legendary Piercing Bane'), c('Legendary Storm Bane'),
]},
];
export const JEWELRY_SLOTS = ['Ring', 'Bracelet', 'Neck', 'Trinket', 'Cloak'];
export const ARMOR_SLOTS = ['Head', 'Chest', 'Abdomen', 'Upper Arms', 'Lower Arms',
'Hands', 'Upper Legs', 'Lower Legs', 'Feet', 'Shield'];
export const WEAPON_TYPES: Array<{ value: string; label: string }> = [
{ value: 'heavy', label: 'Heavy' },
{ value: 'light', label: 'Light' },
{ value: 'finesse', label: 'Finesse' },
{ value: 'two_handed', label: 'Two-Handed' },
{ value: 'missile', label: 'Missile' },
{ value: 'war', label: 'War Magic' },
{ value: 'void', label: 'Void Magic' },
];
export interface RatingDef { param: string; label: string; common?: boolean; }
export const RATING_DEFS: RatingDef[] = [
{ param: 'min_od', label: 'Weapon OD', common: true },
{ param: 'min_damage_rating', label: 'Damage rating', common: true },
{ param: 'min_crit_damage_rating', label: 'Crit damage', common: true },
{ param: 'min_heal_boost_rating', label: 'Heal boost', common: true },
{ param: 'min_vitality_rating', label: 'Vitality', common: true },
{ param: 'min_armor', label: 'Armor level' },
{ param: 'min_damage_resist_rating', label: 'Damage resist' },
{ param: 'min_crit_resist_rating', label: 'Crit resist' },
{ param: 'min_crit_damage_resist_rating', label: 'Crit dmg resist' },
{ param: 'min_healing_resist_rating', label: 'Healing resist' },
{ param: 'min_nether_resist_rating', label: 'Nether resist' },
{ param: 'min_healing_rating', label: 'Healing rating' },
{ param: 'min_dot_resist_rating', label: 'DoT resist' },
{ param: 'min_life_resist_rating', label: 'Life resist' },
{ param: 'min_sneak_attack_rating', label: 'Sneak attack' },
{ param: 'min_recklessness_rating', label: 'Recklessness' },
{ param: 'min_deception_rating', label: 'Deception' },
{ param: 'min_pk_damage_rating', label: 'PK damage' },
{ param: 'min_pk_damage_resist_rating', label: 'PK dmg resist' },
{ param: 'min_gear_pk_damage_rating', label: 'Gear PK dmg' },
{ param: 'min_gear_pk_damage_resist_rating', label: 'Gear PK resist' },
{ param: 'min_tinks', label: 'Tinks' },
];
export interface ColumnDef {
key: string; label: string; sortKey?: string; defaultVisible: boolean;
}
// sortKey values must exist in the backend's sortMapping (search.go).
export const COLUMNS: ColumnDef[] = [
{ key: 'name', label: 'Item', sortKey: 'name', defaultVisible: true },
{ key: 'character_name', label: 'Character', sortKey: 'character_name', defaultVisible: true },
{ key: 'slot_name', label: 'Slot', defaultVisible: true },
{ key: 'value', label: 'Value', sortKey: 'value', defaultVisible: true },
{ key: 'wield_level', label: 'Wield', sortKey: 'level', defaultVisible: true },
{ key: 'spell_names', label: 'Spells / Cantrips', sortKey: 'spell_names', defaultVisible: true },
{ key: 'armor_level', label: 'Armor', sortKey: 'armor', defaultVisible: false },
{ key: 'max_damage', label: 'Max Dmg', sortKey: 'damage', defaultVisible: false },
{ key: 'od_rating', label: 'OD', sortKey: 'od', defaultVisible: false },
{ key: 'workmanship', label: 'Work', sortKey: 'workmanship', defaultVisible: false },
{ key: 'item_set_name', label: 'Set', sortKey: 'item_set', defaultVisible: false },
{ key: 'object_class_name', label: 'Type', sortKey: 'item_type_name', defaultVisible: false },
{ key: 'burden', label: 'Burden', defaultVisible: false },
{ key: 'condition_percent', label: 'Cond %', defaultVisible: false },
{ key: 'last_updated', label: 'Updated', sortKey: 'last_updated', defaultVisible: false },
];
// One-shot fetch: every search loads the full result set in a single request
// (no pagination); rows are rendered incrementally on scroll. Capped at 10k —
// a filterless all-characters browse is ~38k items / 65 MB JSON, which no
// browser should be asked to swallow; real searches are a few thousand rows.
export const PAGE_SIZE = 10000;
export const RENDER_CHUNK = 300;
export const COLUMNS_LS_KEY = 'inv.visibleColumns';

View file

@ -1,69 +0,0 @@
// Item shape returned by /api/inv/search/items (subset we render).
export interface InvItem {
name: string;
character_name: string;
slot_name: string | null;
value: number | null;
burden: number | null;
wield_level: number | null;
workmanship: number | null;
spell_names?: string[];
is_equipped: boolean;
is_bonded: boolean;
is_attuned: boolean;
is_rare: boolean;
item_set_name?: string;
armor_level: number | null;
max_damage: number | null;
od_rating?: number | null;
condition_percent: number | null;
object_class_name?: string;
material_name?: string;
last_updated?: string;
}
export interface SearchResponse {
items: InvItem[];
total_count: number;
page: number;
limit: number;
has_next: boolean;
has_previous: boolean;
error?: string;
}
export type ItemType = 'all' | 'armor' | 'jewelry' | 'weapon' | 'clothing' | 'shirt' | 'pants';
export interface SearchFilters {
text: string;
/** 'all' → include_all_characters=true; otherwise explicit list. */
characters: 'all' | string[];
itemType: ItemType;
weaponTypes: string[]; // [] = all weapon types
slots: string[];
cantrips: string[]; // full "Legendary X" value strings
spellContains: string;
/** min-rating param name -> value ('' = unset). */
ratings: Record<string, number | ''>;
itemSet: string; // set id, '' = none
/** '' = any, or the backend's equipment_status values. */
equipStatus: '' | 'equipped' | 'unequipped';
bonded: boolean;
attuned: boolean;
rare: boolean;
maxLevel: number | '';
minValue: number | '';
minWorkmanship: number | '';
maxBurden: number | '';
sortBy: string;
sortDir: 'asc' | 'desc';
page: number;
}
export const DEFAULT_FILTERS: SearchFilters = {
text: '', characters: 'all', itemType: 'all', weaponTypes: [], slots: [],
cantrips: [], spellContains: '', ratings: {}, itemSet: '',
equipStatus: '', bonded: false, attuned: false, rare: false,
maxLevel: '', minValue: '', minWorkmanship: '', maxBurden: '',
sortBy: 'name', sortDir: 'asc', page: 1,
};

View file

@ -1,182 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { DEFAULT_FILTERS, type SearchFilters, type SearchResponse } from './types';
import { PAGE_SIZE } from './constants';
/** Serialize only the keys that differ from defaults, as a compact q= JSON param. */
function filtersToUrl(f: SearchFilters): void {
const diff: Partial<SearchFilters> = {};
for (const k of Object.keys(DEFAULT_FILTERS) as Array<keyof SearchFilters>) {
if (JSON.stringify(f[k]) !== JSON.stringify(DEFAULT_FILTERS[k])) (diff as any)[k] = f[k];
}
const url = new URL(window.location.href);
url.searchParams.set('view', 'inventory');
if (Object.keys(diff).length) url.searchParams.set('q', JSON.stringify(diff));
else url.searchParams.delete('q');
try {
window.history.replaceState(null, '', url);
} catch {
/* Safari throttles history.replaceState and throws when exceeded — non-fatal. */
}
}
const ITEM_TYPES = new Set(['all', 'armor', 'jewelry', 'weapon', 'clothing', 'shirt', 'pants']);
const SORT_DIRS = new Set(['asc', 'desc']);
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every(x => typeof x === 'string');
}
/** Validate a single filter key's parsed value; return the valid value or undefined to drop it. */
function validateFilterValue(key: keyof SearchFilters, value: unknown): unknown {
switch (key) {
case 'characters':
if (value === 'all' || isStringArray(value)) return value;
return undefined;
case 'slots':
case 'cantrips':
case 'weaponTypes':
return isStringArray(value) ? value : undefined;
case 'itemType':
return typeof value === 'string' && ITEM_TYPES.has(value) ? value : undefined;
case 'sortDir':
return typeof value === 'string' && SORT_DIRS.has(value) ? value : undefined;
case 'ratings': {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
const out: Record<string, number | ''> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (v === '' || typeof v === 'number') out[k] = v;
}
return out;
}
case 'text':
case 'spellContains':
case 'itemSet':
case 'sortBy':
return typeof value === 'string' ? value : undefined;
case 'equipStatus':
return value === '' || value === 'equipped' || value === 'unequipped' ? value : undefined;
case 'bonded':
case 'attuned':
case 'rare':
return typeof value === 'boolean' ? value : undefined;
case 'maxLevel':
case 'minValue':
case 'minWorkmanship':
case 'maxBurden':
return value === '' || typeof value === 'number' ? value : undefined;
case 'page':
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined;
default:
return undefined;
}
}
function filtersFromUrl(): SearchFilters {
try {
const q = new URLSearchParams(window.location.search).get('q');
if (!q) return DEFAULT_FILTERS;
const parsed = JSON.parse(q);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return DEFAULT_FILTERS;
const merged: SearchFilters = { ...DEFAULT_FILTERS };
for (const k of Object.keys(DEFAULT_FILTERS) as Array<keyof SearchFilters>) {
if (!(k in parsed)) continue;
const validated = validateFilterValue(k, (parsed as any)[k]);
if (validated !== undefined) (merged as any)[k] = validated;
}
return merged;
} catch {
return DEFAULT_FILTERS;
}
}
export function buildParams(f: SearchFilters): URLSearchParams {
const p = new URLSearchParams();
if (f.characters === 'all') p.set('include_all_characters', 'true');
else if (f.characters.length === 1) p.set('character', f.characters[0]);
else p.set('characters', f.characters.join(','));
if (f.text) p.set('text', f.text);
switch (f.itemType) {
case 'armor': p.set('armor_only', 'true'); break;
case 'jewelry': p.set('jewelry_only', 'true'); break;
case 'clothing': p.set('clothing_only', 'true'); break;
case 'shirt': p.set('shirt_only', 'true'); break;
case 'pants': p.set('pants_only', 'true'); break;
case 'weapon':
p.set('weapon_only', 'true');
if (f.weaponTypes.length) p.set('weapon_types', f.weaponTypes.join(','));
break;
}
if (f.slots.length) p.set('slot_names', f.slots.join(','));
if (f.cantrips.length) p.set('legendary_cantrips', f.cantrips.join(','));
if (f.spellContains) p.set('spell_contains', f.spellContains);
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') p.set(param, String(v));
if (f.itemSet) p.set('item_set', f.itemSet);
if (f.equipStatus) p.set('equipment_status', f.equipStatus);
if (f.bonded) p.set('bonded', 'true');
if (f.attuned) p.set('attuned', 'true');
if (f.rare) p.set('is_rare', 'true');
if (f.maxLevel !== '') p.set('max_level', String(f.maxLevel));
if (f.minValue !== '') p.set('min_value', String(f.minValue));
if (f.minWorkmanship !== '') p.set('min_workmanship', String(f.minWorkmanship));
if (f.maxBurden !== '') p.set('max_burden', String(f.maxBurden));
p.set('sort_by', f.sortBy);
p.set('sort_dir', f.sortDir);
p.set('page', String(f.page));
p.set('limit', String(PAGE_SIZE));
return p;
}
export interface InventorySearch {
filters: SearchFilters;
/** Patch filters; resets page to 1 unless the patch itself sets page. */
update: (patch: Partial<SearchFilters>) => void;
reset: () => void;
result: SearchResponse | null;
loading: boolean;
error: string | null;
queryMs: number | null;
}
export function useInventorySearch(): InventorySearch {
const [filters, setFilters] = useState<SearchFilters>(filtersFromUrl);
const [result, setResult] = useState<SearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [queryMs, setQueryMs] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const update = useCallback((patch: Partial<SearchFilters>) => {
setFilters(prev => ({ ...prev, page: 'page' in patch ? prev.page : 1, ...patch }));
}, []);
const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []);
useEffect(() => {
const timer = setTimeout(async () => {
filtersToUrl(filters);
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
const t0 = performance.now();
try {
const res = await fetch(`/api/inv/search/items?${buildParams(filters)}`, {
credentials: 'include', signal: ctrl.signal,
});
if (!res.ok) throw new Error(`search: HTTP ${res.status}`);
const data: SearchResponse = await res.json();
if (data.error) throw new Error(data.error);
setResult(data);
setError(null);
setQueryMs(Math.round(performance.now() - t0));
} catch (e: any) {
if (e?.name !== 'AbortError') setError(String(e?.message ?? e));
} finally {
if (abortRef.current === ctrl) setLoading(false);
}
}, 400);
return () => clearTimeout(timer);
}, [filters]);
return { filters, update, reset, result, loading, error, queryMs };
}

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,81 +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 { MidsummerBanner } from '../midsummer/MidsummerBanner';
import { MidsummerRain } from '../midsummer/MidsummerRain';
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">
<MidsummerBanner />
<MidsummerRain />
<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,163 +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 { Maypole } from '../midsummer/Maypole';
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} />
<Maypole imgW={imgSize.w} imgH={imgSize.h} />
</>
)}
</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="/?view=inventory" 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,17 +0,0 @@
import React from 'react';
import { useMidsummer } from '../../hooks/useMidsummer';
/** 🐸 midsummer theme toggle, rendered among the sidebar tool links. */
export const FrogToggle: React.FC = () => {
const { enabled, toggle } = useMidsummer();
return (
<span
className="ml-tool-link"
style={{ cursor: 'pointer' }}
title={enabled ? 'Turn off the midsummer theme' : 'Turn on the midsummer theme'}
onClick={toggle}
>
🐸 Midsommar {enabled ? 'on' : 'off'}
</span>
);
};

View file

@ -1,41 +0,0 @@
import React from 'react';
import { useMidsummer } from '../../hooks/useMidsummer';
interface Props {
imgW: number;
imgH: number;
}
// Kept small for perf — these orbit the pole via one CSS animation.
const FROG_COUNT = 6;
// Default: dead centre of the Dereth map image. To plant at a landmark,
// import { worldToPx } from '../../utils/coordinates' and compute from
// world coords instead.
const center = (imgW: number, imgH: number) => ({ x: imgW / 2, y: imgH / 2 });
/**
* Midsommarstång planted inside the map's pan/zoom group, so it scales and
* pans with the world automatically. Carries its own ring of dancing frogs
* (one CSS rotation) so the spectacle is independent of who is online.
*/
export const Maypole: React.FC<Props> = ({ imgW, imgH }) => {
const { enabled } = useMidsummer();
if (!enabled || imgW === 0) return null;
const { x, y } = center(imgW, imgH);
return (
<div className="ms-maypole" style={{ left: x, top: y }} aria-hidden="true">
<div className="ms-maypole-pole" />
<div className="ms-maypole-ring">
{Array.from({ length: FROG_COUNT }).map((_, i) => (
<span
key={i}
className="ms-frog"
style={{ transform: `rotate(${(360 / FROG_COUNT) * i}deg) translateY(-40px)` }}
>
🐸
</span>
))}
</div>
</div>
);
};

View file

@ -1,13 +0,0 @@
import React from 'react';
import { useMidsummer } from '../../hooks/useMidsummer';
/** Festive top strip shown while the theme is on. */
export const MidsummerBanner: React.FC = () => {
const { enabled } = useMidsummer();
if (!enabled) return null;
return (
<div className="ms-banner" role="status">
🐸 Glad midsommar! 🌼 Små grodorna, små grodorna 🥂
</div>
);
};

View file

@ -1,44 +0,0 @@
import React, { useEffect } from 'react';
import { useMidsummer } from '../../hooks/useMidsummer';
// Flowers, frogs and Swedish flags drifting down over the screen.
const PIECES = ['🐸', '🌼', '🌸', '🇸🇪', '🌿'];
/**
* Continuous gentle rain of flowers, frogs and Swedish flags while the theme
* is on. Pure DOM + CSS (no React re-renders) so it doesn't fight the map's
* high-frequency telemetry updates. Cleans up its layer, interval and any
* in-flight pieces when the theme is toggled off or the component unmounts.
* Skipped entirely under prefers-reduced-motion.
*/
export const MidsummerRain: React.FC = () => {
const { enabled } = useMidsummer();
useEffect(() => {
if (!enabled) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const layer = document.createElement('div');
layer.className = 'ms-rain';
document.body.appendChild(layer);
const spawn = () => {
const piece = document.createElement('span');
piece.textContent = PIECES[Math.floor(Math.random() * PIECES.length)];
piece.style.left = Math.floor(Math.random() * 100) + 'vw';
const dur = 6 + Math.random() * 5; // 611s to fall
piece.style.animationDuration = dur.toFixed(2) + 's';
piece.style.fontSize = 14 + Math.floor(Math.random() * 16) + 'px';
layer.appendChild(piece);
window.setTimeout(() => piece.remove(), dur * 1000 + 250);
};
const id = window.setInterval(spawn, 450);
return () => {
window.clearInterval(id);
layer.remove();
};
}, [enabled]);
return null;
};

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,50 +0,0 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
const KEY = 'mo-midsummer';
// Seasonal master switch. The Små grodorna theme is only active around
// Midsummer week. Out of season this is false → the theme is fully dormant
// (no rain/frogs/maypole/banner/palette) regardless of any stored preference,
// and the 🐸 toggle is removed from the sidebar. To bring it back next year:
// flip this to true and re-add <FrogToggle /> in SidebarWindowButtons.tsx.
const SEASON_ACTIVE = false;
interface MidsummerCtx {
enabled: boolean;
toggle: () => void;
}
const Ctx = createContext<MidsummerCtx | null>(null);
export const MidsummerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// In season, default ON (only the literal "off" disables). Out of season,
// always off — the stored preference is ignored.
const [pref, setPref] = useState<boolean>(() => localStorage.getItem(KEY) !== 'off');
const enabled = SEASON_ACTIVE && pref;
useEffect(() => {
const el = document.documentElement;
if (enabled) el.setAttribute('data-midsummer', '');
else el.removeAttribute('data-midsummer');
}, [enabled]);
const toggle = useCallback(() => {
setPref(p => {
const next = !p;
localStorage.setItem(KEY, next ? 'on' : 'off');
return next;
});
}, []);
return (
<Ctx.Provider value={{ enabled, toggle }}>
{children}
</Ctx.Provider>
);
};
export function useMidsummer(): MidsummerCtx {
const c = useContext(Ctx);
if (!c) throw new Error('useMidsummer must be used within MidsummerProvider');
return c;
}

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,102 +0,0 @@
/* Inventory Search (/?view=inventory) dark theme ported from
docs/superpowers/specs/2026-07-15-inventory-search-redesign-mockup.html
All rules are scoped under .inv-* classes (this SPA also renders the
map/dashboard views, so no bare element selectors at file scope). */
.inv-page, .inv-page * { box-sizing: border-box; margin: 0; padding: 0; }
.inv-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #111;
color: #eee;
font-family: "Segoe UI", sans-serif;
font-size: 13px;
}
.inv-main { display: flex; flex: 1; overflow: hidden; }
.inv-error { color: #c66; }
/* ── Top bar ── */
.inv-topbar { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: #1a1a1a; border-bottom: 2px solid #333; }
.inv-title { color: #88f; font-weight: 600; font-size: 15px; white-space: nowrap; }
.inv-searchbox { flex: 1; max-width: 520px; background: #222; border: 1px solid #444; border-radius: 4px; padding: 7px 12px; color: #eee; font-size: 13px; outline: none; }
.inv-searchbox:focus { border-color: #88f; }
.inv-searchbox::placeholder { color: #666; }
.inv-btn { background: #333; border: 1px solid #555; color: #ccc; border-radius: 4px; padding: 6px 14px; font-size: 12px; cursor: pointer; }
.inv-btn:hover { background: #444; color: #fff; }
.inv-count { margin-left: auto; color: #888; font-size: 12px; white-space: nowrap; }
.inv-count b { color: #eee; }
/* ── Chips ── */
.inv-chipsrow { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 8px 14px; background: #151515; border-bottom: 1px solid #2a2a2a; min-height: 37px; }
.inv-chips-lbl { color: #666; font-size: 10px; letter-spacing: 1px; }
.inv-chip { display: inline-flex; align-items: center; gap: 6px; background: #23233a; border: 1px solid #55f; border-radius: 12px; padding: 2px 10px; font-size: 11px; color: #bbd; cursor: default; }
.inv-chip-x { color: #c66; cursor: pointer; font-weight: bold; }
.inv-chip-x:hover { color: #f88; }
.inv-chip-gold { border-color: #a80; color: #fc6; background: #2a2418; }
/* ── Sidebar ── */
.inv-sidebar { width: 210px; min-width: 210px; background: #1a1a1a; border-right: 2px solid #333; overflow-y: auto; padding: 8px 10px; }
.inv-grp { border-bottom: 1px solid #262626; padding: 6px 0; }
.inv-grp-head { display: flex; align-items: center; gap: 6px; cursor: pointer; color: #999; text-transform: uppercase; font-size: 10px; letter-spacing: 1px; padding: 3px 0; user-select: none; }
.inv-grp-head:hover { color: #ccc; }
.inv-arrow { font-size: 9px; transition: transform .15s; }
.inv-grp.inv-open .inv-arrow { transform: rotate(90deg); }
.inv-badge { margin-left: auto; background: #3a3a6e; color: #cce; border-radius: 8px; padding: 0 7px; font-size: 9px; }
.inv-grp-body { padding: 5px 0 3px; }
.inv-minisearch { width: 100%; background: #222; border: 1px solid #3a3a3a; border-radius: 3px; padding: 4px 8px; color: #ccc; font-size: 11px; outline: none; margin-bottom: 5px; }
.inv-minisearch::placeholder { color: #555; }
.inv-fitem { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 12px; color: #bbb; cursor: pointer; }
.inv-fitem:hover { color: #fff; }
.inv-fitem input { accent-color: #88f; }
.inv-subgroup { margin: 2px 0 2px 10px; }
.inv-subhead { color: #666; font-size: 9px; text-transform: uppercase; letter-spacing: 1px; margin: 6px 0 2px; }
.inv-linky { color: #88f; font-size: 10px; cursor: pointer; }
.inv-linky:hover { text-decoration: underline; }
.inv-range { display: flex; gap: 4px; align-items: center; margin: 2px 0; }
.inv-range label { flex: 1; color: #999; font-size: 11px; }
.inv-range input { width: 52px; background: #222; border: 1px solid #3a3a3a; border-radius: 3px; color: #ccc; padding: 2px 5px; font-size: 11px; }
/* ── Results ── */
.inv-results { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
.inv-results table { width: 100%; border-collapse: collapse; }
.inv-results thead { position: sticky; top: 0; background: #191919; z-index: 1; }
.inv-results th { text-align: left; color: #88f; font-size: 11px; font-weight: 600; padding: 8px 10px; border-bottom: 2px solid #333; cursor: pointer; white-space: nowrap; user-select: none; }
.inv-results th:hover { color: #aaf; }
.inv-results td { padding: 6px 10px; border-bottom: 1px solid #1e1e1e; font-size: 12px; color: #bbb; vertical-align: top; }
.inv-results tbody tr { cursor: pointer; }
.inv-results tbody tr:hover td { background: #191922; }
.inv-results tbody tr.inv-sel td { background: #20203a; }
.inv-results td.inv-num { text-align: right; font-variant-numeric: tabular-nums; }
.inv-leg { color: #fc6; }
.inv-spells { color: #889; font-size: 11px; }
.inv-equipped { color: #4c4; font-size: 10px; }
.inv-pager { display: flex; align-items: center; gap: 8px; padding: 8px 14px; color: #888; font-size: 12px; border-top: 1px solid #262626; background: #151515; margin-top: auto; }
.inv-pg { padding: 2px 8px; border: 1px solid #333; border-radius: 3px; cursor: pointer; }
.inv-pg.inv-cur { background: #3a3a6e; border-color: #88f; color: #fff; }
/* ── Detail panel ── */
.inv-detail { width: 250px; min-width: 250px; background: #161620; border-left: 2px solid #333; overflow-y: auto; padding: 12px; }
.inv-detail h3 { color: #88f; font-size: 14px; margin-bottom: 2px; }
.inv-detail-sub { color: #777; font-size: 11px; margin-bottom: 10px; }
.inv-kv { display: flex; justify-content: space-between; padding: 2px 0; font-size: 12px; color: #999; }
.inv-kv b { color: #ddd; font-weight: normal; text-align: right; }
.inv-detail hr { border: none; border-top: 1px solid #2a2a35; margin: 9px 0; }
.inv-sphead { color: #666; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
.inv-sp { padding: 2px 0; font-size: 12px; color: #bbb; }
.inv-hint { color: #555; font-size: 10px; margin-top: 12px; }
.inv-closex { float: right; color: #666; cursor: pointer; font-size: 14px; }
.inv-closex:hover { color: #f88; }
/* ── FilterSidebar additions (Task 4) ── */
.inv-dim { color: #666; font-size: 10px; }
.inv-online { width: 6px; height: 6px; border-radius: 50%; background: #4c4; display: inline-block; margin-left: 4px; }
.inv-links { margin-bottom: 4px; font-size: 10px; }
.inv-gold { color: #fc6; }
/* ── ResultsTable additions (Task 5) ── */
.inv-colpicker-anchor { position: relative; align-self: flex-end; padding: 4px 10px; }
.inv-colpicker { position: absolute; right: 10px; top: 30px; background: #1a1a1a; border: 1px solid #444; border-radius: 4px; padding: 8px; z-index: 5; max-height: 300px; overflow-y: auto; }
.inv-sorted-asc::after { content: " ▲"; font-size: 9px; }
.inv-sorted-desc::after { content: " ▼"; font-size: 9px; }
.inv-pager-right { margin-left: auto; }

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,176 +0,0 @@
/* Midsummer "Små grodorna" theme overlay. All rules scoped under
:root[data-midsummer] so they only apply when the theme is on. */
:root[data-midsummer] .ml-sidebar {
background: #0a1f16;
border-right: 2px solid #1c5a2c;
}
:root[data-midsummer] .ml-map-container {
background: #0e2a1e;
}
:root[data-midsummer] .ml-sidebar-title {
color: #7ed957;
text-shadow: 0 0 6px rgba(126, 217, 87, 0.35);
}
:root[data-midsummer] .ml-tool-link {
color: #bfe9a8;
}
:root[data-midsummer] .ml-tool-link:hover {
color: #eafbe0;
}
:root[data-midsummer] .ml-server-status,
:root[data-midsummer] .ml-counters,
:root[data-midsummer] .ml-player-row {
border-color: #1c5a2c;
}
:root[data-midsummer] .ml-player-row.ml-player-selected {
background: rgba(126, 217, 87, 0.14);
outline: 1px solid rgba(126, 217, 87, 0.5);
}
:root[data-midsummer] .ml-sort-btn,
:root[data-midsummer] .ml-btn {
border-color: #2c6e36;
}
.ms-maypole {
position: absolute;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 6;
}
.ms-maypole-pole {
position: absolute;
left: -2px;
top: -64px;
width: 4px;
height: 70px;
background: #6b4f2a;
border-radius: 2px;
}
.ms-maypole-pole::before {
content: '';
position: absolute;
top: 4px;
left: -16px;
width: 36px;
height: 4px;
background: #3b6d11;
border-radius: 2px;
}
.ms-maypole-pole::after {
content: '🌼';
position: absolute;
top: -16px;
left: -8px;
font-size: 16px;
line-height: 1;
}
.ms-maypole-ring {
position: absolute;
left: 0;
top: -30px;
width: 0;
height: 0;
animation: ms-spin 12s linear infinite;
}
.ms-frog {
position: absolute;
font-size: 13px;
line-height: 1;
}
@keyframes ms-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.ms-maypole-ring { animation: none; }
}
/* Replace each player marker with a frog (instead of the coloured dot). The
inline backgroundColor + base border on .ml-dot are overridden so only the
frog shows; the base blink animation on the selected dot still applies. */
:root[data-midsummer] .ml-dot {
background: transparent !important;
border: none !important;
overflow: visible;
}
:root[data-midsummer] .ml-dot::before {
content: '🐸';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 15px;
line-height: 1;
pointer-events: none;
}
:root[data-midsummer] .ml-dot.ml-dot-selected::before {
font-size: 21px;
filter: drop-shadow(0 0 3px #7ed957);
}
.ms-banner {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
z-index: 50;
margin-top: 6px;
padding: 4px 16px;
border-radius: 14px;
background: rgba(20, 64, 31, 0.92);
border: 1px solid #7ed957;
color: #eafbe0;
font-size: 0.8rem;
white-space: nowrap;
pointer-events: none;
}
/* Continuous rain of flowers / frogs / Swedish flags (MidsummerRain). Each
piece sets its own animation-duration inline; pointer-events:none so it
never blocks the UI underneath. */
.ms-rain {
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 9000;
}
.ms-rain span {
position: absolute;
top: -40px;
line-height: 1;
animation-name: ms-fall;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
@keyframes ms-fall {
to { transform: translateY(112vh) rotate(360deg); opacity: 0.3; }
}
.ml-layout.ms-hop {
animation: ms-bounce 0.6s ease-in-out 3;
}
@keyframes ms-bounce {
0%, 100% { transform: translateY(0); }
20% { transform: translateY(-16px); }
40% { transform: translateY(0); }
60% { transform: translateY(-9px); }
80% { transform: translateY(0); }
}
.ms-hop-frogs {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 999999;
}
.ms-hop-frogs span {
position: absolute;
bottom: -40px;
font-size: 34px;
line-height: 1;
animation: ms-hop-up 2.4s ease-in forwards;
}
@keyframes ms-hop-up {
to { bottom: 114vh; transform: rotate(18deg); }
}
@media (prefers-reduced-motion: reduce) {
.ml-layout.ms-hop { animation: none; }
.ms-hop-frogs span { animation-duration: 0.01s; }
}

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