diff --git a/.gitignore b/.gitignore index d9c8ee36..033df5fb 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 31ab8cf5..00000000 --- a/.mcp.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "mcpServers": { - "overlord": { - "command": "/home/erik/MosswartOverlord/agent/.venv/bin/python", - "args": ["-m", "agent.mcp_overlord"], - "env": { - "PYTHONPATH": "/home/erik/MosswartOverlord" - } - } - } -} diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 46ea6fa7..00000000 --- a/AGENTS.md +++ /dev/null @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index e825bcb9..f6144321 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` (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) \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 1fc2530f..6c491936 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 87e1922f..ecc22f92 100644 --- a/README.md +++ b/README.md @@ -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 "; + # 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:///api/` if behind a prefix) + - Grafana UI: `http://localhost:3000/grafana/` (or `http:///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] : ` for plugin messages on `/ws/position` + - `[WS-LIVE RX] : ` 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://:/ws/position?secret= +``` +or +``` +X-Plugin-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 diff --git a/agent/README.md b/agent/README.md deleted file mode 100644 index aa875178..00000000 --- a/agent/README.md +++ /dev/null @@ -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= -AGENT_DB_DSN=postgresql://overlord_agent_ro:@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 '';" - ``` -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=' \ - -H 'Content-Type: application/json' \ - -d '{"session_id":"","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. diff --git a/agent/__init__.py b/agent/__init__.py deleted file mode 100644 index 2cbfa0cf..00000000 --- a/agent/__init__.py +++ /dev/null @@ -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). -""" diff --git a/agent/auth.py b/agent/auth.py deleted file mode 100644 index 1974ee1d..00000000 --- a/agent/auth.py +++ /dev/null @@ -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 diff --git a/agent/claude_wrapper.py b/agent/claude_wrapper.py deleted file mode 100644 index bbe48712..00000000 --- a/agent/claude_wrapper.py +++ /dev/null @@ -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/.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//.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 ` to create it. - On subsequent messages uses `--resume ` 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____. - # 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, - ) diff --git a/agent/install.sh b/agent/install.sh deleted file mode 100644 index f92a2c7a..00000000 --- a/agent/install.sh +++ /dev/null @@ -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" diff --git a/agent/mcp_overlord.py b/agent/mcp_overlord.py deleted file mode 100644 index 320f3131..00000000 --- a/agent/mcp_overlord.py +++ /dev/null @@ -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() diff --git a/agent/overlord-agent.service b/agent/overlord-agent.service deleted file mode 100644 index 20861a1b..00000000 --- a/agent/overlord-agent.service +++ /dev/null @@ -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 diff --git a/agent/requirements.txt b/agent/requirements.txt deleted file mode 100644 index c14e3454..00000000 --- a/agent/requirements.txt +++ /dev/null @@ -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 diff --git a/agent/service.py b/agent/service.py deleted file mode 100644 index d2b7d6a0..00000000 --- a/agent/service.py +++ /dev/null @@ -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//.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/.../.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() diff --git a/agent/sql/0001_overlord_agent_ro.sql b/agent/sql/0001_overlord_agent_ro.sql deleted file mode 100644 index b87f9cce..00000000 --- a/agent/sql/0001_overlord_agent_ro.sql +++ /dev/null @@ -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; diff --git a/agent/tools.py b/agent/tools.py deleted file mode 100644 index c7b3b8fe..00000000 --- a/agent/tools.py +++ /dev/null @@ -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/ - "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 diff --git a/db_async.py b/db_async.py index c7adf1dc..998d6707 100644 --- a/db_async.py +++ b/db_async.py @@ -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 \ No newline at end of file diff --git a/deploy-frontend.sh b/deploy-frontend.sh deleted file mode 100644 index 144f81ad..00000000 --- a/deploy-frontend.sh +++ /dev/null @@ -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." diff --git a/discord-rare-monitor/discord_rare_monitor.py b/discord-rare-monitor/discord_rare_monitor.py index 5c58e1fb..7063691c 100644 --- a/discord-rare-monitor/discord_rare_monitor.py +++ b/discord-rare-monitor/discord_rare_monitor.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 8c7212b8..1f04d940 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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"] diff --git a/docs/backups.md b/docs/backups.md deleted file mode 100644 index a7010211..00000000 --- a/docs/backups.md +++ /dev/null @@ -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 7–30 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-.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-.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-.dump | head # table of contents -pg_restore --list dereth-.dump | grep -c 'TABLE DATA' -``` - -A dump that suddenly shrinks dramatically (check `backup.log` sizes) is the -canary for silent failure. diff --git a/docs/go-rewrite-prompt.md b/docs/go-rewrite-prompt.md deleted file mode 100644 index 212b77ab..00000000 --- a/docs/go-rewrite-prompt.md +++ /dev/null @@ -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 ~10–50× 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 2–5 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. diff --git a/docs/plans/2026-06-25-suitbuilder-cd-tier-filter-design.md b/docs/plans/2026-06-25-suitbuilder-cd-tier-filter-design.md deleted file mode 100644 index a17585c7..00000000 --- a/docs/plans/2026-06-25-suitbuilder-cd-tier-filter-design.md +++ /dev/null @@ -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. diff --git a/docs/plans/2026-06-25-suitbuilder-cd-tier-filter-plan.md b/docs/plans/2026-06-25-suitbuilder-cd-tier-filter-plan.md deleted file mode 100644 index c9336f7d..00000000 --- a/docs/plans/2026-06-25-suitbuilder-cd-tier-filter-plan.md +++ /dev/null @@ -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 -
- - - - - -
-``` - -with: - -```html -
- - - - -
-``` - -- [ ] **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.) diff --git a/docs/suitbuilder.md b/docs/suitbuilder.md deleted file mode 100644 index 9a831346..00000000 --- a/docs/suitbuilder.md +++ /dev/null @@ -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 diff --git a/docs/superpowers/plans/2026-06-19-midsummer-theme.md b/docs/superpowers/plans/2026-06-19-midsummer-theme.md deleted file mode 100644 index f431e976..00000000 --- a/docs/superpowers/plans/2026-06-19-midsummer-theme.md +++ /dev/null @@ -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 `` 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 `` inside `.ml-map-group`. -- `frontend/src/components/map/MapLayout.tsx` — mount ``, call `useMidsummerSound()`. -- `frontend/src/components/PlayerDashboardFullPage.tsx` — same banner + sound for the new-tab dashboard. -- `frontend/src/components/sidebar/SidebarWindowButtons.tsx` — add ``. -- `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(null); - -export const MidsummerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - // Default ON: only the literal "off" disables it. - const [enabled, setEnabled] = useState(() => localStorage.getItem(KEY) !== 'off'); - const [soundOn, setSoundOn] = useState(() => 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 ( - - {children} - - ); -}; - -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 ( - - {view === 'dashboard' ? : } - - ); -} - -/** 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 ; -} -``` - -- [ ] **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 ( - <> - - 🐸 Midsommar {enabled ? 'on' : 'off'} - - {enabled && ( - { - const turningOn = !soundOn; - toggleSound(); - if (turningOn) playSmaGrodorna(); // this click is a user gesture - }} - > - {soundOn ? '🔊' : '🔇'} Jingle - - )} - - ); -}; -``` - -- [ ] **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 `` as the first child inside the `
` (immediately before the `🤖 Assistant` span): - -```tsx -
- - openWindow('agent', 'Overlord Assistant')}>🤖 Assistant -``` - -- [ ] **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 = ({ imgW, imgH }) => { - const { enabled } = useMidsummer(); - if (!enabled || imgW === 0) return null; - const { x, y } = center(imgW, imgH); - return ( -