Compare commits
No commits in common. "master" and "python-legacy" have entirely different histories.
master
...
python-leg
112 changed files with 7533 additions and 14989 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -3,16 +3,9 @@ __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/
|
||||
|
|
|
|||
43
CLAUDE.md
43
CLAUDE.md
|
|
@ -5,41 +5,22 @@ Cross-repo workflows (plugin coupling, deploy commands, nginx) live in the works
|
|||
|
||||
## 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.
|
||||
|
||||
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.**
|
||||
Dereth Tracker is a real-time telemetry platform for Asheron's Call world tracking. A FastAPI WebSocket/HTTP service (`main.py`, single file ~4200 lines) ingests player data from the MosswartMassacre DECAL plugin and serves a live React dashboard, with TimescaleDB persistence, a separate inventory microservice, Grafana dashboards, a Discord rare bot, and a host-side Claude-powered assistant.
|
||||
|
||||
## Components
|
||||
|
||||
| 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` |
|
||||
| Tracker API (`main.py`) | repo root | Docker `dereth-tracker`, 127.0.0.1:8765 |
|
||||
| Telemetry DB (TimescaleDB) | `db_async.py` schema | Docker `dereth-db`, port 5432 |
|
||||
| Inventory service + DB | `inventory-service/` | Docker `inventory-service` (127.0.0.1:8766) + `inventory-db` (5433) |
|
||||
| React frontend | `frontend/` → built into `static/` | served by tracker (FastAPI StaticFiles) |
|
||||
| Classic v1 frontend | `static/classic/` | served at `/classic` |
|
||||
| Legacy vanilla pages | `static/inventory.html`, `static/suitbuilder.html` | still live |
|
||||
| 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` |
|
||||
| Discord rare bot | `discord-rare-monitor/` | Docker, connects to `/ws/live` internally |
|
||||
| Overlord Agent (assistant) | `agent/` | **host-side systemd service** `overlord-agent`, 127.0.0.1:8767 |
|
||||
|
||||
### Go services — build, deploy, gotchas
|
||||
|
||||
- **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.
|
||||
|
||||
## WebSocket endpoints
|
||||
|
||||
- `/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.
|
||||
|
|
@ -82,14 +63,12 @@ The original Python/FastAPI implementation (`main.py` ~4200 lines, `inventory-se
|
|||
|
||||
## Suitbuilder
|
||||
|
||||
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.
|
||||
Production equipment-optimization engine (`inventory-service/suitbuilder.py`): multi-character search, armor set constraints, cantrip overlap detection, SSE streaming. UI at `/suitbuilder.html`. Architecture doc: `docs/plans/2026-02-09-suitbuilder-architecture.md`.
|
||||
Known limitations: no slot-aware spell filtering, equal spell weighting. The legacy `/optimize/*` solver in inventory-service/main.py is a near-duplicate — `suitbuilder.py` is the production path.
|
||||
|
||||
## Deploying
|
||||
|
||||
- **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.
|
||||
See workspace `../CLAUDE.md` "Build & Deploy Instructions" — quick deploy (git pull + `docker compose restart dereth-tracker` for Python; nothing for static), `deploy-frontend.sh` for React, full `--no-cache` rebuild only for Dockerfile/pip/version-stamp changes. Bind mounts: `main.py`, `db_async.py`, `static/`, `alembic/` only.
|
||||
|
||||
## Operational notes
|
||||
|
||||
|
|
|
|||
537
README.md
537
README.md
|
|
@ -1,155 +1,424 @@
|
|||
# Mosswart Overlord (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.
|
||||
|
||||
**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.
|
||||
Real-time telemetry, inventory, and analytics platform for Asheron's Call.
|
||||
FastAPI backend + React frontend + PostgreSQL (TimescaleDB) + Discord integrations,
|
||||
all driven by WebSocket events from the companion [MosswartMassacre](https://github.com/SawatoMosswartsEnjoyersClub/MosswartMassacre) DECAL plugin.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Overview](#overview)
|
||||
- [Architecture](#architecture)
|
||||
- [Features](#features)
|
||||
- [Requirements](#requirements)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Deploying Changes](#deploying-changes)
|
||||
- [WebSocket Contract](#websocket-contract)
|
||||
- [HTTP API Reference](#http-api-reference)
|
||||
- [Frontend](#frontend)
|
||||
- [AI Assistant (Overlord Agent)](#ai-assistant-overlord-agent)
|
||||
- [Database Schema](#database-schema)
|
||||
- [Operations & Health](#operations--health)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Mosswart Overlord is the backend that consumes a firehose of telemetry, vitals, inventory, combat, and chat events from 60+ characters running the `MosswartMassacre` plugin. It stores selected data in TimescaleDB, runs analytics (combat stats, idle/death detection), and broadcasts live updates to connected browser clients.
|
||||
|
||||
The frontend is a React + Vite app served at `/` with a live map, draggable windows (inventory, chat, combat, radar, etc.), and a server uptime sidebar. The previous vanilla JS frontend is preserved at `/classic`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
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
|
||||
┌─────────────────────────┐
|
||||
│ MosswartMassacre (C#) │ ← plugin per game client
|
||||
└────────────┬────────────┘
|
||||
│ WebSocket /ws/position (authenticated)
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ dereth-tracker (FastAPI, Docker) │
|
||||
│ • main.py — WS routing, analytics, broadcasts │
|
||||
│ • idle/death detection → Discord webhook │
|
||||
│ • combat stats delta/lifetime accumulation │
|
||||
│ • vital sharing relay (cross-machine) │
|
||||
└──┬──────────────────┬────────────────────┬────────────┘
|
||||
│ │ │
|
||||
│ WS /ws/live │ HTTP │ HTTP
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ Browsers │ │ inventory-svc │ │ Discord bot │
|
||||
│ (React) │ │ (FastAPI, Docker)│ │ (rare monitor) │
|
||||
└────┬─────┘ └────────┬─────────┘ └──────────────────┘
|
||||
│ ▼
|
||||
│ ┌──────────────┐
|
||||
│ │ inventory-db │
|
||||
│ └──────────────┘
|
||||
│
|
||||
│ /api/agent/* (host-side, OUTSIDE Docker)
|
||||
▼
|
||||
┌────────────────────────────────────────┐
|
||||
│ overlord-agent (FastAPI, systemd) │ ← runs as dedicated unprivileged user
|
||||
│ • shells out to `claude -p ...` │ /var/lib/overlord-agent home,
|
||||
│ • MCP server: live-state Q&A tools │ strict settings, no /home/erik
|
||||
└────────────────────────────────────────┘
|
||||
|
||||
┌──────────────┐
|
||||
│ dereth-db │ ← TimescaleDB (telemetry, spawns, rares, portals)
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
| Component | Path | Runs as | Notes |
|
||||
Most services run via Docker Compose. **`overlord-agent` is host-side**
|
||||
(systemd) because it shells out to the `claude` CLI which depends on
|
||||
host-side credentials — see [AI Assistant](#ai-assistant-overlord-agent).
|
||||
|
||||
## Features
|
||||
|
||||
### Live Data
|
||||
- **Live Map** — real-time player positions, dots, trails, portals, heatmap
|
||||
- **WebSocket firehose** (`/ws/live`) — broadcasts every incoming event to browsers
|
||||
- **Per-client subscriptions** — clients can send `{"type":"subscribe","message_types":[...]}` to receive only specific event types (the Discord rare monitor bot uses this to filter the 82GB/day firehose down to just `rare` and `chat`)
|
||||
|
||||
### Inventory
|
||||
- Full inventory snapshot on login + incremental `inventory_delta` updates (add/update/remove)
|
||||
- Per-character live refresh in the browser (debounced 2s)
|
||||
- Advanced search with filters: material, set, armor level, spells, tinks, workmanship, etc.
|
||||
- **Suitbuilder** at `/suitbuilder.html` — constraint-based armor optimization across multiple mule inventories with primary/secondary set support, cantrip overlap detection, and real-time SSE streaming
|
||||
|
||||
### Combat Stats (Mag-Tools style)
|
||||
- Plugin parses combat chat into session deltas
|
||||
- Backend accumulates lifetime totals from per-session snapshots
|
||||
- Offense/defense broken out per damage element
|
||||
- Browser combat window shows monster-by-monster damage
|
||||
|
||||
### Cross-Machine Vital Sharing
|
||||
- WebSocket relay replaces UtilityBelt's localhost-only `VTankFellowHeals`
|
||||
- Plugin broadcasts its own vitals and consumes peer vitals
|
||||
- In-game `DxHud` overlay shows peer health/stamina/mana bars with direction arrows
|
||||
|
||||
### AI Assistant
|
||||
- 🤖 chat window in the dashboard backed by `claude -p` running headless on the server
|
||||
- Read-only access to live game state via 12 MCP tools (live players, inventory cross-search, combat stats, quests, suitbuilder, read-only SQL, etc.)
|
||||
- Per-browser persistent session, "New Chat" button, history rehydration on reload
|
||||
- Hardened: dedicated unprivileged Linux user, systemd lockdown, strict tool whitelist, audit log, rate limit. See [AI Assistant section](#ai-assistant-overlord-agent) for the full security stack.
|
||||
|
||||
### Discord Integration
|
||||
- **Rare Monitor Bot** — posts rares (split by common/great) to configured channels
|
||||
- **Death Alerts** — webhook to `#alerts` when a character's vitae goes from 0 → >0 (rate-limited to one per character per 5 min)
|
||||
- **Idle Alerts** — webhook after 5 minutes of continuous idle state (caught portals, stuck nav, etc.). The grace period prevents false positives on brief idle blips.
|
||||
- **Vortex Warning** — bot watches for "whirlwind of vortexes" chat and posts a warning embed
|
||||
|
||||
### Portals
|
||||
- Automatic discovery + 1-hour retention
|
||||
- Coordinate-deduplicated (rounded to 0.1 precision)
|
||||
|
||||
### Stats
|
||||
- Per-character lifetime kills, deaths, rares, taper counts
|
||||
- Grafana dashboards (2x2 iframe grid in the stats window)
|
||||
|
||||
### Health & Monitoring
|
||||
- Server uptime + latency + player count from TreeStats.net (checked every 30s)
|
||||
- Only current state is kept — no historical `server_health_checks` table (removed April 2026 as write-only bloat)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker & Docker Compose (recommended)
|
||||
- OR: Python 3.11+, Node.js 20+, and a PostgreSQL 14+ with TimescaleDB
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone git@git.snakedesert.se:SawatoMosswartsEnjoyersClub/MosswartOverlord.git
|
||||
cd MosswartOverlord
|
||||
cp .env.example .env # fill in secrets (see Configuration below)
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Frontend development loop
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # local Vite server
|
||||
# ...edit files, hot reload...
|
||||
cd ..
|
||||
bash deploy-frontend.sh # builds + copies to static/ for production serving
|
||||
```
|
||||
|
||||
⚠️ **`npm run build` writes to `static/_build/` but the web server serves from `static/`.** You must run `deploy-frontend.sh` to copy `_build/ → static/`. Otherwise the browser keeps loading the previous bundle.
|
||||
|
||||
## Configuration
|
||||
|
||||
All secrets go in `.env`:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `POSTGRES_PASSWORD` | Telemetry DB password |
|
||||
| `INVENTORY_DB_PASSWORD` | Inventory DB password |
|
||||
| `SHARED_SECRET` | Plugin auth for `/ws/position` |
|
||||
| `SECRET_KEY` | Session cookie signing |
|
||||
| `DISCORD_RARE_BOT_TOKEN` | Bot token for rare monitor |
|
||||
| `DISCORD_ACLOG_WEBHOOK` | Webhook URL for death/idle alerts |
|
||||
| `GF_SECURITY_ADMIN_PASSWORD` | Grafana admin |
|
||||
| `COMMON_RARE_CHANNEL_ID` | Discord channel ID for common rares |
|
||||
| `GREAT_RARE_CHANNEL_ID` | Discord channel ID for great rares |
|
||||
| `ACLOG_CHANNEL_ID` | Discord channel ID for the rare bot's status/vortex messages |
|
||||
| `MONITOR_CHARACTER` | Which character's chat the bot monitors |
|
||||
|
||||
The Overlord Agent has its own env file at `/etc/overlord/agent.env` (root:overlord-agent 0640) so it doesn't share the tracker's secrets:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `SECRET_KEY` | Same value as the tracker — validates browser session cookies |
|
||||
| `AGENT_DB_DSN` | Read-only connection string `postgresql://overlord_agent_ro:<pw>@127.0.0.1:5432/dereth` |
|
||||
| `TRACKER_URL` | Loopback to the tracker container (default `http://127.0.0.1:8765`) |
|
||||
| `AGENT_RATE_MAX` | Per-user rate limit (default 60/hour) |
|
||||
| `AGENT_RATE_WINDOW_S` | Rate-limit window in seconds (default 3600) |
|
||||
| `AGENT_AUDIT_LOG` | Path to audit JSONL (default `/var/log/overlord-agent/audit.jsonl`) |
|
||||
| `CLAUDE_TIMEOUT_S` | Max seconds per `claude -p` invocation (default 240) |
|
||||
|
||||
## Deploying Changes
|
||||
|
||||
Live backend host: `overlord.snakedesert.se` (SSH user `erik`, key-based auth).
|
||||
|
||||
### Quick deploy — Python / static file changes
|
||||
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se \
|
||||
"cd /home/erik/MosswartOverlord && git pull --ff-only origin master"
|
||||
# Python changes require a restart:
|
||||
ssh erik@overlord.snakedesert.se "docker compose restart dereth-tracker"
|
||||
# Static files (JS/CSS/HTML) are served from the bind-mounted static/ — no restart.
|
||||
```
|
||||
|
||||
⚠️ Uvicorn runs **without** `--reload` in production. Do not add it back — without the `watchfiles` package it falls back to a polling reloader that busy-loops at 100% CPU and eats a whole core.
|
||||
|
||||
### React frontend deploy
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build && cd ..
|
||||
bash deploy-frontend.sh
|
||||
git add static/ && git commit -m "deploy frontend" && git push
|
||||
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull"
|
||||
# No container restart needed.
|
||||
```
|
||||
|
||||
### Full rebuild — Dockerfile / pip package / version stamp changes
|
||||
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && \
|
||||
git pull --ff-only origin master && \
|
||||
export BUILD_VERSION=\"\$(date -u +%Y.%-m.%-d.%H%M)-\$(git rev-parse --short HEAD)\" && \
|
||||
docker compose build --no-cache --build-arg BUILD_VERSION=\$BUILD_VERSION dereth-tracker && \
|
||||
docker compose up -d dereth-tracker"
|
||||
```
|
||||
|
||||
`BUILD_VERSION` is displayed in the sidebar of the live frontend. Format is CalVer: `YYYY.M.D.HHMM-gitshorthash`.
|
||||
|
||||
### Overlord Agent deploy
|
||||
|
||||
Code changes to `agent/` only:
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && \
|
||||
git pull --ff-only origin master && \
|
||||
sudo systemctl restart overlord-agent"
|
||||
journalctl -u overlord-agent -f # tail logs to verify
|
||||
```
|
||||
|
||||
`agent/requirements.txt` changed (new pip deps):
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && \
|
||||
git pull --ff-only origin master && \
|
||||
agent/.venv/bin/pip install -r agent/requirements.txt && \
|
||||
sudo systemctl restart overlord-agent"
|
||||
```
|
||||
|
||||
systemd unit changed:
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && \
|
||||
git pull --ff-only origin master && \
|
||||
sudo cp agent/overlord-agent.service /etc/systemd/system/ && \
|
||||
sudo systemctl daemon-reload && sudo systemctl restart overlord-agent"
|
||||
```
|
||||
|
||||
First-time install: `bash agent/install.sh` — see `agent/README.md` for the full bootstrap procedure (creating the `overlord-agent` user, copying claude auth, granting filesystem access, populating `/etc/overlord/agent.env`).
|
||||
|
||||
## WebSocket Contract
|
||||
|
||||
### `/ws/position` (plugin → backend)
|
||||
|
||||
Authenticated via `?secret=<SHARED_SECRET>` or `X-Plugin-Secret` header. Accepts JSON frames with a `type` discriminator:
|
||||
|
||||
| `type` | Purpose |
|
||||
|---|---|
|
||||
| `telemetry` | Position, kills, session metrics (every 2s per character) |
|
||||
| `vitals` | Health/stamina/mana/vitae percentages |
|
||||
| `character_stats` | Full attributes/skills/allegiance (every 10 min) |
|
||||
| `inventory` / `full_inventory` | Complete inventory dump on login |
|
||||
| `inventory_delta` | Incremental add/update/remove of a single item |
|
||||
| `equipment_cantrip_state` | Equipped spell effects |
|
||||
| `portal` | Discovered portal with coordinates |
|
||||
| `spawn` | Monster spawn observation |
|
||||
| `chat` | In-game chat line (any channel) |
|
||||
| `quest` | Quest timer / progress |
|
||||
| `rare` | Rare item find notification |
|
||||
| `nearby_objects` | On-demand radar data (nearby entities) |
|
||||
| `combat_stats` | Session combat snapshot (Mag-Tools parser output) |
|
||||
| `share_*` | Cross-machine vital/debuff sharing envelopes |
|
||||
| `dungeon_map` | Dungeon floor tile data for radar overlay |
|
||||
|
||||
See `EVENT_FORMATS.json` for exact per-type schemas.
|
||||
|
||||
### `/ws/live` (browser → backend)
|
||||
|
||||
Session-cookie authenticated (except for internal Docker network clients, which are trusted by IP). Clients can:
|
||||
|
||||
- Send `{"type":"subscribe","message_types":["rare","chat"]}` to filter which events they receive. Without subscribing, all types are forwarded (browser default).
|
||||
- Send `{"player_name":"Larsson","command":"/radar start"}` to route a command to that character's plugin client.
|
||||
- Send `{"type":"request_dungeon_map","landblock":"..."}` to pull cached dungeon tile data.
|
||||
|
||||
Backend pushes the same firehose (subject to subscription filter) to every browser client.
|
||||
|
||||
## HTTP API Reference
|
||||
|
||||
See `EVENT_FORMATS.json` for event schemas. Major HTTP endpoints:
|
||||
|
||||
- `GET /live` — active players seen in the last 30s
|
||||
- `GET /history?from=…&to=…` — historical telemetry snapshots
|
||||
- `GET /trails` — recent player trails for the map
|
||||
- `GET /spawns/heatmap?hours=N` — aggregated spawn density
|
||||
- `GET /portals` — discovered portals within retention window
|
||||
- `GET /inventory/{character}` — current inventory (proxied to inventory-service)
|
||||
- `GET /character-stats/{character}` — full character attributes/skills
|
||||
- `GET /combat-stats/{character}` — session + lifetime combat stats
|
||||
- `GET /vital-sharing/peers` — currently-registered vital sharing peers
|
||||
- `GET /api-version` — build version stamp
|
||||
- `GET /server-health` — current Coldeve server status + player count
|
||||
|
||||
## Frontend
|
||||
|
||||
### React v2 (primary, at `/`)
|
||||
- Map-first layout with draggable/resizable windows
|
||||
- Code-split bundles: one chunk per window type, lazy-loaded on open
|
||||
- Window types: Chat, Stats, Inventory, Character, Radar, CombatStats, CombatPicker, Issues, VitalSharing, QuestStatus, PlayerDashboard
|
||||
- Per-character inventory version counter — an open inventory window refreshes 2s after its own character's last `inventory_delta`, ignoring unrelated traffic
|
||||
- Direct DOM pan/zoom on the map (no React state per frame)
|
||||
- Service worker caches a small whitelist of static assets
|
||||
- Version badge in the sidebar confirms which build is loaded
|
||||
|
||||
### Classic v1 (preserved at `/classic`)
|
||||
The original vanilla JS frontend with element-pooling optimization is kept for fallback and reference.
|
||||
|
||||
## AI Assistant (Overlord Agent)
|
||||
|
||||
A draggable chat window in the dashboard (🤖 Assistant button). Powered by `claude -p` running headless on the server, with read-only access to live game state via an MCP server.
|
||||
|
||||
### Architecture
|
||||
- **Host-side service** (`agent/`, systemd unit `overlord-agent`) runs OUTSIDE Docker because the `claude` CLI binary lives on the host (`/home/erik/.local/bin/claude`) and depends on host-side authentication credentials.
|
||||
- **Dedicated UNIX user** (`overlord-agent`, system account, `/var/lib/overlord-agent` home, no shell) — kernel-level isolation from the operator's `erik` account. Cannot read `/home/erik/.claude`, `~/.ssh`, `.bash_history`, `.env`, etc.
|
||||
- **MCP stdio server** (`agent/mcp_overlord.py`) exposes 12 tools that wrap the tracker's HTTP endpoints + read-only DB queries. Claude only sees these tools; no `Bash`, `Read`, `Write`, etc.
|
||||
- **Frontend** (`AgentWindow.tsx`) — per-browser session UUID in localStorage, "New Chat" button, on-mount rehydration from `/agent/sessions/{id}/history`.
|
||||
|
||||
### MCP tools available to the assistant
|
||||
`get_live_players`, `get_player_state`, `get_combat_stats`, `get_equipment_cantrips`, `get_inventory`, `get_inventory_search`, `search_items` (cross-character), `get_recent_rares`, `get_quest_status`, `get_server_health`, `query_telemetry_db` (read-only SQL via sqlglot parser + GRANT-SELECT-only PG role), `suitbuilder_search`. Plus `WebFetch(domain:acpedia.org)` for AC info lookups.
|
||||
|
||||
### Security stack (defense-in-depth)
|
||||
1. **Cookie auth** on `/agent/ask` (same session cookie the tracker issues)
|
||||
2. **Per-user rate limit** (60 req/h default) and **concurrency cap** (1 in-flight)
|
||||
3. **JSONL audit log** at `/var/log/overlord-agent/audit.jsonl` (every prompt + result)
|
||||
4. **CLI flags** — `--allowed-tools` (just our 12 MCP tools), `--disallowed-tools` (Bash, Write, Read, Edit, Agent, ToolSearch, Monitor, scheduling, Gmail/Drive/Calendar, etc.), `--permission-mode dontAsk`
|
||||
5. **`/var/lib/overlord-agent/.claude/settings.json`** — strict deny rules (server-side only, NOT in repo)
|
||||
6. **System-prompt scope rules** in `CLAUDE.md` — instruct the model not to probe, not to suggest workarounds
|
||||
7. **SQL parser** (`sqlglot`) rejects any non-SELECT statement on `query_telemetry_db`
|
||||
8. **Read-only PG role** `overlord_agent_ro` (GRANT SELECT only) — even a parser bypass can't mutate
|
||||
9. **systemd hardening** — `ProtectSystem=strict`, `ProtectHome=read-only`, `InaccessiblePaths=/etc/shadow,/root,~/.ssh,…`, `NoNewPrivileges=true`, `CapabilityBoundingSet=` (empty), `PrivateTmp=true`, `PrivateDevices=true`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`, `SystemCallFilter=@system-service ~@privileged ~@reboot ~@mount`, `MemoryMax=512M`, `TasksMax=128`
|
||||
10. **Secrets out of /home** — `/etc/overlord/agent.env` (root:overlord-agent 0640) for SECRET_KEY + AGENT_DB_DSN
|
||||
|
||||
### Files
|
||||
|
||||
| Path | What |
|
||||
|------|------|
|
||||
| `agent/service.py` | FastAPI app: `/agent/health`, `/agent/sessions/new`, `/agent/ask`, `/agent/sessions/{id}/history` |
|
||||
| `agent/auth.py` | Session cookie validation (mirrors `main.py:1013-1019`) |
|
||||
| `agent/claude_wrapper.py` | `asyncio.create_subprocess_exec("claude", "-p", …)` with allowed/disallowed-tools |
|
||||
| `agent/tools.py` | Pure tool implementations |
|
||||
| `agent/mcp_overlord.py` | MCP stdio server registering tools |
|
||||
| `agent/sql/0001_overlord_agent_ro.sql` | Read-only PG role |
|
||||
| `agent/overlord-agent.service` | systemd unit (the hardening directives) |
|
||||
| `agent/install.sh` | venv + systemd setup |
|
||||
| `agent/README.md` | Operator's deeper reference |
|
||||
| `.mcp.json` (repo root) | Project-level MCP config Claude Code auto-loads |
|
||||
| `CLAUDE.md` "Overlord Assistant Mode" section | System-prompt briefing |
|
||||
|
||||
### Routing
|
||||
nginx forwards `/api/agent/*` to `127.0.0.1:8767` (the host-side service) with a 300s read/send timeout (suitbuilder runs can be slow). Other `/api/*` continues to the dereth-tracker container at `127.0.0.1:8765`.
|
||||
|
||||
### Cost / quota
|
||||
Subscription auth (no API key); per-call cost is informational only. Each `/agent/ask` invocation = one `claude -p` subprocess with shared session cache. Reactive only — no background polling, no scheduled tasks.
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Telemetry DB (`dereth`, TimescaleDB)
|
||||
|
||||
| Table | Type | Retention | Purpose |
|
||||
|---|---|---|---|
|
||||
| **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 |
|
||||
| `telemetry_events` | hypertable | 30 days | Position/stats snapshots |
|
||||
| `spawn_events` | hypertable | 7 days | Monster spawn observations (heatmap source) |
|
||||
| `rare_events` | regular | forever | Rare find history |
|
||||
| `portals` | regular | 1 hour | Discovered portals, dedup by rounded coords |
|
||||
| `char_stats` | regular | forever | Per-character lifetime kill total |
|
||||
| `rare_stats` | regular | forever | Per-character lifetime rare total |
|
||||
| `rare_stats_sessions` | regular | forever | Per-session rare count |
|
||||
| `combat_stats` | regular | forever | Lifetime combat accumulator |
|
||||
| `combat_stats_sessions` | regular | forever | Per-session combat snapshots |
|
||||
| `character_stats` | regular | forever | Latest full stats JSON per character |
|
||||
| `server_status` | regular | forever | Current Coldeve server state (single row) |
|
||||
|
||||
**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.
|
||||
### Inventory DB (`inventory_db`, PostgreSQL)
|
||||
|
||||
---
|
||||
Normalized schema: `items`, `item_combat_stats`, `item_requirements`, `item_enhancements`, `item_ratings`, `item_spells`, `item_raw_data`.
|
||||
|
||||
## Build & run
|
||||
`items.container_id` stores the in-game ID of the container holding the item (0 = character body). The frontend groups items into packs by this ID.
|
||||
|
||||
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.
|
||||
## Operations & Health
|
||||
|
||||
### PostgreSQL tuning
|
||||
`dereth-db` runs with explicit memory overrides in `docker-compose.yml`:
|
||||
- `shared_buffers=8GB` (was 96GB via auto-tune on a 32GB host — caused thrashing)
|
||||
- `effective_cache_size=16GB`
|
||||
- `work_mem=16MB`, `maintenance_work_mem=1GB`
|
||||
- `max_wal_size=4GB`
|
||||
|
||||
### Retention policies
|
||||
- `telemetry_events`: 30-day drop, daily
|
||||
- `spawn_events`: 7-day drop, daily
|
||||
- `portals`: 1-hour cleanup (background task in `main.py`)
|
||||
- `server_health_checks`: **removed** — was write-only, 850K rows of nothing
|
||||
|
||||
### Log levels
|
||||
Both `dereth-tracker` and `inventory-service` run at `LOG_LEVEL=INFO`. Do not set to `DEBUG` in production — it dumps full inventory_delta payloads for every item update (hundreds of KB/sec).
|
||||
|
||||
### Host (Proxmox VM)
|
||||
- 6 vCPU, 32 GiB RAM (of which ~30 GiB is normally free under current load)
|
||||
- Live host: `overlord.snakedesert.se`
|
||||
- Reverse proxy: Nginx on the host terminates TLS and strips the `/api/` prefix before forwarding to port 8765
|
||||
|
||||
### Debug commands
|
||||
```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
|
||||
|
||||
# --- 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
|
||||
docker ps
|
||||
docker logs mosswartoverlord-dereth-tracker-1 --tail 100
|
||||
docker logs mosswartoverlord-inventory-service-1 --tail 100
|
||||
docker logs mosswartoverlord-discord-rare-monitor-1 --tail 100
|
||||
docker exec dereth-db psql -U postgres -d dereth
|
||||
```
|
||||
|
||||
- `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`.
|
||||
## Contributing
|
||||
|
||||
### Frontend (unchanged by the migration)
|
||||
Contributions welcome. Please:
|
||||
- Keep cross-repo protocol changes additive (new optional fields > renames/removes)
|
||||
- Update both this README and `CLAUDE.md` when workflows change
|
||||
- Test end-to-end: plugin → backend → browser for any new event type
|
||||
|
||||
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.
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
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/`.
|
||||
|
||||
### nginx
|
||||
|
||||
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.
|
||||
|
||||
### Overlord Agent
|
||||
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket contract
|
||||
|
||||
- **`/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.
|
||||
|
||||
### Payload note
|
||||
|
||||
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`).
|
||||
|
||||
## Auth & users
|
||||
|
||||
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`.
|
||||
|
||||
## Databases
|
||||
|
||||
Two separate Postgres databases, both schema-from-code:
|
||||
|
||||
- **`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).
|
||||
|
||||
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`.
|
||||
|
||||
## Route conventions
|
||||
|
||||
- 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.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- 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.
|
||||
|
||||
## Branches
|
||||
|
||||
- **`master`** — the Go production backend (this).
|
||||
- **`python-legacy`** — the original Python/FastAPI implementation, preserved for reference and rollback.
|
||||
|
||||
See [`CLAUDE.md`](CLAUDE.md) for contributor/agent guidance and deeper internals.
|
||||
For detailed architecture notes and ongoing investigations, see `CLAUDE.md` and `docs/plans/`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -1,522 +0,0 @@
|
|||
# Suitbuilder CD-tier filter — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let a suitbuilder search restrict which crit-damage tiers (CD0/CD1/CD2) are allowed on armor pieces, so the user can build e.g. all-CD1 suits — while the default (all allowed) stays byte-identical to today.
|
||||
|
||||
**Architecture:** Add an `allowed_crit_damage` constraint. In the live Go solver (`inventory-go`), drop armor items whose CD tier isn't allowed during item loading, before the domination pre-filter. "Prefer highest allowed tier" needs no new code — it falls out of the existing scoring and CD-descending armor sort. Frontend swaps the dead Crit-Damage min/max inputs for three CD checkboxes.
|
||||
|
||||
**Tech Stack:** Go 1.25 (`go-services/inventory-go`), vanilla JS/HTML (`static/suitbuilder.*`), Docker on the server (no local Go toolchain).
|
||||
|
||||
**Spec:** `docs/plans/2026-06-25-suitbuilder-cd-tier-filter-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Conventions for this plan
|
||||
|
||||
- **Source-of-truth edits** happen in the local repo at `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`, on branch `suitbuilder-cd-tier-filter`. Commit there.
|
||||
- **No local Go toolchain.** Build & test run on the server (`overlord.snakedesert.se`) inside Docker.
|
||||
- **Fast unit-test loop** (run from the local MosswartOverlord dir after copying changed files to the host — see Task 6 for the copy command):
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "docker run --rm \
|
||||
-v /home/erik/MosswartOverlord/go-services/inventory-go:/src -w /src \
|
||||
golang:1.25-bookworm sh -c 'go mod tidy >/dev/null 2>&1 && go test ./... -v'"
|
||||
```
|
||||
(Mounts the host's inventory-go source into a throwaway golang container. `go mod tidy` writes go.sum into that untracked dir — harmless.)
|
||||
- The live container is `inventory-go` (image `inventory-go:local`, `127.0.0.1:8772`).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
- `go-services/inventory-go/suit_model.go` — **modify**: constraint field.
|
||||
- `go-services/inventory-go/suit_cd.go` — **create**: pure CD-tier helpers (one responsibility, DB-free, unit-testable).
|
||||
- `go-services/inventory-go/suit_cd_test.go` — **create**: unit tests for the helpers.
|
||||
- `go-services/inventory-go/suit_solver.go` — **modify**: solver field + wire filter into `loadItems`.
|
||||
- `go-services/inventory-go/Dockerfile` — **modify**: add a `go test` build gate (mirrors tracker-go).
|
||||
- `static/suitbuilder.html` — **modify**: CD checkboxes replace min/max inputs.
|
||||
- `static/suitbuilder.js` — **modify**: gather/validate/send `allowed_crit_damage`.
|
||||
- `static/suitbuilder.css` — **modify**: minor styling for the toggles.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add the `allowed_crit_damage` constraint field
|
||||
|
||||
**Files:** Modify `go-services/inventory-go/suit_model.go`
|
||||
|
||||
- [ ] **Step 1: Replace the dead crit min/max fields**
|
||||
|
||||
In `SearchConstraints`, replace these two lines:
|
||||
|
||||
```go
|
||||
MinCritDamage *int `json:"min_crit_damage"`
|
||||
MaxCritDamage *int `json:"max_crit_damage"`
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```go
|
||||
AllowedCritDamage []int `json:"allowed_crit_damage"`
|
||||
```
|
||||
|
||||
(The `Min/MaxCritDamage` fields were never referenced by the solver — confirmed by grep. The other `Min/Max*` fields stay untouched.)
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
cd /c/Users/erikn/source/repos/dereth-workspace/MosswartOverlord
|
||||
git add go-services/inventory-go/suit_model.go
|
||||
git commit -m "feat(suitbuilder): add allowed_crit_damage constraint field"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: CD-tier helpers + unit tests (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `go-services/inventory-go/suit_cd.go`
|
||||
- Create: `go-services/inventory-go/suit_cd_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `go-services/inventory-go/suit_cd_test.go`:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCritTier(t *testing.T) {
|
||||
cases := []struct {
|
||||
rating, want int
|
||||
}{{-1, 0}, {0, 0}, {1, 1}, {2, 2}, {3, 2}, {5, 2}}
|
||||
for _, c := range cases {
|
||||
if got := critTier(c.rating); got != c.want {
|
||||
t.Errorf("critTier(%d) = %d, want %d", c.rating, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedCritSet(t *testing.T) {
|
||||
for _, vals := range [][]int{nil, {}, {0, 1, 2}, {0, 1, 3}} {
|
||||
if allowedCritSet(vals) != nil {
|
||||
t.Errorf("allowedCritSet(%v) should be nil (inactive)", vals)
|
||||
}
|
||||
}
|
||||
if s := allowedCritSet([]int{1}); s == nil || !s[1] || s[0] || s[2] {
|
||||
t.Errorf("allowedCritSet({1}) = %v, want only tier 1", s)
|
||||
}
|
||||
if s := allowedCritSet([]int{0, 1}); s == nil || !s[0] || !s[1] || s[2] {
|
||||
t.Errorf("allowedCritSet({0,1}) = %v, want tiers 0,1", s)
|
||||
}
|
||||
if s := allowedCritSet([]int{3}); s == nil || !s[2] || s[0] || s[1] {
|
||||
t.Errorf("allowedCritSet({3}) = %v, want only tier 2 (normalized)", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsArmorSlot(t *testing.T) {
|
||||
for _, s := range []string{"Chest", "Head", "Feet", "Chest, Abdomen", "Upper Legs, Lower Legs"} {
|
||||
if !isArmorSlot(s) {
|
||||
t.Errorf("isArmorSlot(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"Neck", "Left Ring", "Left Wrist", "Trinket", "Shirt", "Pants", "Unknown", ""} {
|
||||
if isArmorSlot(s) {
|
||||
t.Errorf("isArmorSlot(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cdItem(slot string, cd int) *SuitItem {
|
||||
return &SuitItem{Slot: slot, Ratings: map[string]int{"crit_damage_rating": cd}}
|
||||
}
|
||||
|
||||
func TestFilterArmorByCD(t *testing.T) {
|
||||
items := []*SuitItem{
|
||||
cdItem("Chest", 0), cdItem("Head", 1), cdItem("Feet", 2),
|
||||
cdItem("Chest, Abdomen", 2), // multi-coverage armor, CD2
|
||||
cdItem("Neck", 0), // jewelry — never filtered
|
||||
cdItem("Shirt", 0), // clothing — never filtered
|
||||
}
|
||||
|
||||
if got := filterArmorByCD(items, nil); len(got) != len(items) {
|
||||
t.Errorf("nil filter dropped items: got %d, want %d", len(got), len(items))
|
||||
}
|
||||
|
||||
got := filterArmorByCD(items, map[int]bool{1: true})
|
||||
keep := map[string]bool{"Head": true, "Neck": true, "Shirt": true}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("allowed{1}: got %d items, want 3", len(got))
|
||||
}
|
||||
for _, it := range got {
|
||||
if !keep[it.Slot] {
|
||||
t.Errorf("allowed{1}: unexpected slot %q survived", it.Slot)
|
||||
}
|
||||
}
|
||||
|
||||
got = filterArmorByCD(items, map[int]bool{0: true, 1: true})
|
||||
if len(got) != 4 { // Chest(0), Head(1), Neck, Shirt
|
||||
t.Errorf("allowed{0,1}: got %d items, want 4", len(got))
|
||||
}
|
||||
for _, it := range got {
|
||||
if isArmorSlot(it.Slot) && it.Ratings["crit_damage_rating"] >= 2 {
|
||||
t.Errorf("allowed{0,1}: CD2 armor %q should have been dropped", it.Slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to confirm they fail to build**
|
||||
|
||||
Copy only the test file to the host (the implementation doesn't exist yet):
|
||||
|
||||
```bash
|
||||
cd /c/Users/erikn/source/repos/dereth-workspace/MosswartOverlord
|
||||
scp go-services/inventory-go/suit_cd_test.go \
|
||||
erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/go-services/inventory-go/
|
||||
```
|
||||
|
||||
Then run the fast test loop (see Conventions).
|
||||
Expected: FAIL — `undefined: critTier`, `allowedCritSet`, `isArmorSlot`, `filterArmorByCD`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Create `go-services/inventory-go/suit_cd.go`:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// CD-tier filtering for the suitbuilder. The allowed_crit_damage constraint
|
||||
// restricts which crit-damage tiers are permitted on ARMOR pieces; jewelry and
|
||||
// clothing are never affected. "Prefer the highest allowed tier" is NOT done
|
||||
// here — it falls out of the existing scoring (CritDamage2 > CritDamage1) and
|
||||
// the CD-descending armor sort once disallowed tiers are removed.
|
||||
|
||||
// critTier normalizes a raw crit_damage_rating into a tier in {0,1,2}. Rare
|
||||
// high-crit gear (rating >= 2, including 3+) collapses to tier 2 so it counts
|
||||
// as "CD2" rather than being silently excluded.
|
||||
func critTier(rating int) int {
|
||||
switch {
|
||||
case rating <= 0:
|
||||
return 0
|
||||
case rating == 1:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// isArmorSlot reports whether a slot name denotes an armor coverage slot,
|
||||
// including comma-joined multi-coverage slots like "Chest, Abdomen".
|
||||
func isArmorSlot(slot string) bool {
|
||||
if armorSlotSet[slot] {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(slot, ", ") {
|
||||
for _, p := range strings.Split(slot, ", ") {
|
||||
if armorSlotSet[strings.TrimSpace(p)] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// allowedCritSet normalizes the constraint's allowed crit-damage tiers into a
|
||||
// set, or returns nil when the filter is INACTIVE: no values, or all three
|
||||
// tiers {0,1,2} present (== default). A nil result means "no filter" and keeps
|
||||
// the default search path byte-identical to the unfiltered solver.
|
||||
func allowedCritSet(vals []int) map[int]bool {
|
||||
if len(vals) == 0 {
|
||||
return nil
|
||||
}
|
||||
set := map[int]bool{}
|
||||
for _, v := range vals {
|
||||
set[critTier(v)] = true
|
||||
}
|
||||
if set[0] && set[1] && set[2] {
|
||||
return nil
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// filterArmorByCD drops armor items whose crit-damage tier is not in allowed.
|
||||
// Non-armor items (jewelry, clothing, unknown) always pass through. When
|
||||
// allowed is nil the input is returned unchanged.
|
||||
func filterArmorByCD(items []*SuitItem, allowed map[int]bool) []*SuitItem {
|
||||
if allowed == nil {
|
||||
return items
|
||||
}
|
||||
out := make([]*SuitItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
if isArmorSlot(it.Slot) && !allowed[critTier(it.Ratings["crit_damage_rating"])] {
|
||||
continue
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to confirm they pass**
|
||||
|
||||
```bash
|
||||
scp go-services/inventory-go/suit_cd.go \
|
||||
erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/go-services/inventory-go/
|
||||
```
|
||||
|
||||
Run the fast test loop. Expected: PASS (`ok` — 4 tests).
|
||||
|
||||
- [ ] **Step 5: Add the `go test` build gate to the Dockerfile**
|
||||
|
||||
In `go-services/inventory-go/Dockerfile`, after `RUN go mod tidy` add:
|
||||
|
||||
```dockerfile
|
||||
RUN go test ./...
|
||||
```
|
||||
|
||||
(Mirrors `tracker-go/Dockerfile`; from now on every image build runs the tests.)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add go-services/inventory-go/suit_cd.go go-services/inventory-go/suit_cd_test.go go-services/inventory-go/Dockerfile
|
||||
git commit -m "feat(suitbuilder): CD-tier filter helpers + tests; gate inventory-go build on go test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire the filter into the solver
|
||||
|
||||
**Files:** Modify `go-services/inventory-go/suit_solver.go`
|
||||
|
||||
- [ ] **Step 1: Add the precomputed set to the Solver struct**
|
||||
|
||||
In the `Solver` struct, after `armorBucketsItems int`, add:
|
||||
|
||||
```go
|
||||
allowedCD map[int]bool // nil == no CD filter (default / all tiers)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Populate it in `newSolver`**
|
||||
|
||||
In `newSolver`, after the line `sv.neededSpellBitmap = sv.spellIndex.getBitmap(c.RequiredSpells)`, add:
|
||||
|
||||
```go
|
||||
sv.allowedCD = allowedCritSet(c.AllowedCritDamage)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Apply the filter in `loadItems` before domination**
|
||||
|
||||
In `loadItems`, find:
|
||||
|
||||
```go
|
||||
filtered := removeSurpassedItems(items)
|
||||
```
|
||||
|
||||
and immediately ABOVE it insert:
|
||||
|
||||
```go
|
||||
// Drop armor whose CD tier is disallowed BEFORE domination, so a CD2 piece
|
||||
// can't surpass-and-remove an allowed CD1 piece we'd then exclude.
|
||||
items = filterArmorByCD(items, sv.allowedCD)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify it still builds and all tests pass**
|
||||
|
||||
Copy the changed solver file and run the test loop:
|
||||
|
||||
```bash
|
||||
scp go-services/inventory-go/suit_solver.go \
|
||||
erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/go-services/inventory-go/
|
||||
```
|
||||
|
||||
Run the fast test loop. Expected: PASS, and the package compiles (the wiring type-checks; `go test` builds the whole `main` package).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add go-services/inventory-go/suit_solver.go
|
||||
git commit -m "feat(suitbuilder): apply CD-tier filter in loadItems (before domination)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Frontend — CD checkboxes
|
||||
|
||||
**Files:** Modify `static/suitbuilder.html`, `static/suitbuilder.js`, `static/suitbuilder.css`
|
||||
|
||||
- [ ] **Step 1: Replace the Crit Damage inputs with checkboxes**
|
||||
|
||||
In `static/suitbuilder.html`, replace this block:
|
||||
|
||||
```html
|
||||
<div class="filter-group">
|
||||
<label>Crit Damage:</label>
|
||||
<input type="number" id="minCritDmg" placeholder="Min" min="0" max="999">
|
||||
<span>-</span>
|
||||
<input type="number" id="maxCritDmg" placeholder="Max" min="0" max="999">
|
||||
</div>
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```html
|
||||
<div class="filter-group">
|
||||
<label>Allowed Crit Damage:</label>
|
||||
<label class="cd-toggle"><input type="checkbox" id="allowCD0" checked> CD0</label>
|
||||
<label class="cd-toggle"><input type="checkbox" id="allowCD1" checked> CD1</label>
|
||||
<label class="cd-toggle"><input type="checkbox" id="allowCD2" checked> CD2</label>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build `allowed_crit_damage` in `gatherConstraints()`**
|
||||
|
||||
In `static/suitbuilder.js`, replace these two lines:
|
||||
|
||||
```js
|
||||
min_crit_damage: document.getElementById('minCritDmg').value || null,
|
||||
max_crit_damage: document.getElementById('maxCritDmg').value || null,
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
allowed_crit_damage: [
|
||||
document.getElementById('allowCD0').checked ? 0 : null,
|
||||
document.getElementById('allowCD1').checked ? 1 : null,
|
||||
document.getElementById('allowCD2').checked ? 2 : null,
|
||||
].filter(v => v !== null),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Drop the deleted field from validation**
|
||||
|
||||
In `validateConstraints()`, change:
|
||||
|
||||
```js
|
||||
!constraints.min_armor && !constraints.min_crit_damage && !constraints.min_damage_rating) {
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```js
|
||||
!constraints.min_armor && !constraints.min_damage_rating) {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Send `allowed_crit_damage` in the request body**
|
||||
|
||||
In `streamOptimalSuits()`, replace these two lines:
|
||||
|
||||
```js
|
||||
min_crit_damage: constraints.min_crit_damage ? parseInt(constraints.min_crit_damage) : null,
|
||||
max_crit_damage: constraints.max_crit_damage ? parseInt(constraints.max_crit_damage) : null,
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
allowed_crit_damage: constraints.allowed_crit_damage,
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Style the toggles**
|
||||
|
||||
Append to `static/suitbuilder.css`:
|
||||
|
||||
```css
|
||||
.cd-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: 10px;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cd-toggle input { margin: 0; }
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add static/suitbuilder.html static/suitbuilder.js static/suitbuilder.css
|
||||
git commit -m "feat(suitbuilder): CD0/CD1/CD2 allowed-tier checkboxes (replace dead crit min/max)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Deploy to the server & verify end-to-end
|
||||
|
||||
- [ ] **Step 1: Copy changed backend files to the host build context**
|
||||
|
||||
```bash
|
||||
cd /c/Users/erikn/source/repos/dereth-workspace/MosswartOverlord
|
||||
scp go-services/inventory-go/suit_model.go go-services/inventory-go/suit_cd.go \
|
||||
go-services/inventory-go/suit_cd_test.go go-services/inventory-go/suit_solver.go \
|
||||
go-services/inventory-go/Dockerfile \
|
||||
erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/go-services/inventory-go/
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the image (runs `go test` as part of the build)**
|
||||
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \
|
||||
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml \
|
||||
build inventory-go'
|
||||
```
|
||||
Expected: build succeeds; the `RUN go test ./...` layer passes.
|
||||
|
||||
- [ ] **Step 3: Recreate the container with the cutover override**
|
||||
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \
|
||||
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml \
|
||||
-f go-services/docker-compose.cutover.yml up -d --no-deps inventory-go'
|
||||
```
|
||||
Expected: `inventory-go` recreated; `docker ps` shows it healthy on :8772.
|
||||
|
||||
- [ ] **Step 4: Copy the changed static files (bind-mounted; live immediately)**
|
||||
|
||||
```bash
|
||||
scp static/suitbuilder.html static/suitbuilder.js static/suitbuilder.css \
|
||||
erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/static/
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify default search is unchanged + CD1-only works**
|
||||
|
||||
Manual, in the browser at the suitbuilder page (hard-refresh to bust cache):
|
||||
- With **all three CD boxes checked**, run a search (a primary set + a character with armor). Confirm results look like before.
|
||||
- Check **only CD1**, run the same search. Confirm in the Network tab the request body has `"allowed_crit_damage":[1]`, and every armor piece in the returned suits shows **CD1** (jewelry/clothing unaffected; slots with no CD1 piece may be empty).
|
||||
- Check **CD1 + CD0**, confirm no CD2 armor appears and CD1 is preferred where available.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Finalize the local feature commit
|
||||
|
||||
- [ ] **Step 1: Confirm the branch state**
|
||||
|
||||
```bash
|
||||
cd /c/Users/erikn/source/repos/dereth-workspace/MosswartOverlord
|
||||
git log --oneline -6
|
||||
git status
|
||||
```
|
||||
Expected: clean tree; the spec + plan + Tasks 1-4 feature commits on `suitbuilder-cd-tier-filter`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Reconcile host git + push to Gitea (separate, after the feature is verified live)
|
||||
|
||||
> ⚠ Pushing to the **public** Gitea is outward-facing and partly irreversible. Investigate state and decide a strategy BEFORE any push; surface the chosen strategy to the user first. Never `git add` the host's `.env` (secrets).
|
||||
|
||||
- [ ] **Step 1: Establish the true state of all three gits**
|
||||
- Local `MosswartOverlord` HEAD (`9911edbf`, has go-services committed).
|
||||
- Host `/home/erik/MosswartOverlord` HEAD (`6a0bb9fe`, go-services untracked, has server-only commits like rickroll/midsummer).
|
||||
- Gitea `origin/master` — fetch and inspect; determine whether local's go-services history and/or the host's server-only commits are already on the remote.
|
||||
|
||||
- [ ] **Step 2: Decide a reconciliation strategy** (depends on Step 1 findings):
|
||||
- Get the host's server-only commits into the canonical local history (cherry-pick or merge), and get the local go-services history onto the host — so a single `master` contains both, with this feature on top.
|
||||
- Plan must avoid clobbering the host's untracked `.env`/backups and avoid a destructive force-push unless explicitly chosen.
|
||||
|
||||
- [ ] **Step 3: Execute the chosen reconciliation, then `git pull` on the host** so the host runs tracked code, and push the unified `master` to Gitea. Confirm `docker compose build` still uses the now-tracked go-services.
|
||||
|
||||
(Phase 2 steps are deliberately high-level — the exact git commands depend on Step 1's findings and a strategy choice. Do not pre-bake destructive commands.)
|
||||
|
|
@ -1,433 +0,0 @@
|
|||
# Inventory Search Spell Filters Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement the `legendary_cantrips`, `spell_contains`, and `has_spell` query params in the Go inventory service so the existing (currently dead) UI spell filters work, with AND semantics across checked cantrips.
|
||||
|
||||
**Architecture:** Match filter names against the in-memory spell enum map (`Server.spells`, loaded at boot from `comprehensive_enum_database_v2.json`) to obtain spell IDs, then emit `EXISTS (... spell_id IN (...))` conditions against the existing `item_spells` table — one per checked cantrip, ANDed — as ordinary WHERE conditions in `runSearch` so they compose with all other filters, sorting, pagination, and the count query. Spec: `docs/superpowers/specs/2026-07-14-inventory-spell-jewelry-filters-design.md`.
|
||||
|
||||
**Tech Stack:** Go 1.25, pgx/v5, plain `net/http`. No local Go toolchain exists — tests run in a throwaway `golang:1.25-bookworm` container on the deploy server (`overlord.snakedesert.se`); the Dockerfile's `RUN go test ./...` gates every image build.
|
||||
|
||||
**Working directory for all git commands:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`
|
||||
|
||||
**How to run tests (no local Go):** sync the source to a scratch dir on the server, run tests in Docker:
|
||||
|
||||
```bash
|
||||
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
|
||||
tar czf - go-services | ssh erik@overlord.snakedesert.se \
|
||||
"rm -rf /tmp/tdd-verify && mkdir -p /tmp/tdd-verify && tar xzf - -C /tmp/tdd-verify" && \
|
||||
ssh erik@overlord.snakedesert.se \
|
||||
"docker run --rm -v /tmp/tdd-verify/go-services/inventory-go:/src -w /src golang:1.25-bookworm \
|
||||
sh -c 'go mod tidy >/dev/null 2>&1; go test ./... -v -run <PATTERN> 2>&1'"
|
||||
```
|
||||
|
||||
Replace `<PATTERN>` per step (use `.` for all tests). First run pulls modules (~30 s).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Spell name → ID matchers
|
||||
|
||||
The three lookup functions that translate UI names into spell IDs, mirroring the legacy Python matching rules (`inventory-service/main.py:3355-3427`).
|
||||
|
||||
**Files:**
|
||||
- Create: `go-services/inventory-go/spell_filter.go`
|
||||
- Test: `go-services/inventory-go/spell_filter_test.go` (create)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `go-services/inventory-go/spell_filter_test.go`:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Minimal stand-in for the enum-DB spell map (Server.spells).
|
||||
var testSpells = map[int]map[string]any{
|
||||
1: {"name": "Legendary Invulnerability"},
|
||||
2: {"name": "Epic Invulnerability"},
|
||||
3: {"name": "Legendary Summoning Prowess"},
|
||||
4: {"name": "Strength Other VI"},
|
||||
5: {"name": "Summoning"}, // shorter name CONTAINED IN a cantrip label
|
||||
6: {"name": ""}, // malformed entry must never match
|
||||
}
|
||||
|
||||
func TestSpellIDsContaining(t *testing.T) {
|
||||
if got := spellIDsContaining(testSpells, "invulnerability"); !reflect.DeepEqual(got, []int{1, 2}) {
|
||||
t.Errorf("substring match = %v, want [1 2]", got)
|
||||
}
|
||||
if got := spellIDsContaining(testSpells, "INVULN"); !reflect.DeepEqual(got, []int{1, 2}) {
|
||||
t.Errorf("case-insensitive match = %v, want [1 2]", got)
|
||||
}
|
||||
if got := spellIDsContaining(testSpells, "frostbite"); got != nil {
|
||||
t.Errorf("no-match = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpellIDsForCantrip(t *testing.T) {
|
||||
// Forward direction: spell name contains the cantrip label.
|
||||
if got := spellIDsForCantrip(testSpells, "Invulnerability"); !reflect.DeepEqual(got, []int{1, 2}) {
|
||||
t.Errorf("forward contains = %v, want [1 2]", got)
|
||||
}
|
||||
// Both directions (legacy flexible rule): "Legendary Summoning Prowess"
|
||||
// matches spell 3 (equal) AND spell 5 ("Summoning" is contained in the label).
|
||||
if got := spellIDsForCantrip(testSpells, "Legendary Summoning Prowess"); !reflect.DeepEqual(got, []int{3, 5}) {
|
||||
t.Errorf("both-direction = %v, want [3 5]", got)
|
||||
}
|
||||
if got := spellIDsForCantrip(testSpells, "Piercing Bane"); got != nil {
|
||||
t.Errorf("no-match = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExactSpellID(t *testing.T) {
|
||||
if id, ok := exactSpellID(testSpells, "legendary invulnerability"); !ok || id != 1 {
|
||||
t.Errorf("exact case-insensitive = (%d,%v), want (1,true)", id, ok)
|
||||
}
|
||||
if _, ok := exactSpellID(testSpells, "Invulnerability"); ok {
|
||||
t.Error("partial name must not exact-match")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run the SSH+Docker test command above with `-run 'TestSpellIDsContaining|TestSpellIDsForCantrip|TestExactSpellID'`.
|
||||
|
||||
Expected: `FAIL ... [build failed]` with `undefined: spellIDsContaining` (and the other two names).
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Create `go-services/inventory-go/spell_filter.go`:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Spell-filter support for /search/items: implements the has_spell /
|
||||
// spell_contains / legendary_cantrips params the Go port had ignored
|
||||
// (design doc 2026-07-14; legacy inventory-service/main.py:3345-3452).
|
||||
// Names are matched against the in-memory enum-DB spell map; the resulting
|
||||
// IDs are filtered in SQL against the item_spells table.
|
||||
|
||||
// spellIDsContaining returns the IDs of all spells whose name contains q,
|
||||
// case-insensitively, in ascending order (deterministic SQL args).
|
||||
func spellIDsContaining(spells map[int]map[string]any, q string) []int {
|
||||
q = strings.ToLower(strings.TrimSpace(q))
|
||||
var ids []int
|
||||
for id, sp := range spells {
|
||||
name, _ := sp["name"].(string)
|
||||
if name != "" && strings.Contains(strings.ToLower(name), q) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Ints(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
// spellIDsForCantrip matches one cantrip display name using the legacy
|
||||
// flexible rule: spell name contains the label OR the label contains the
|
||||
// spell name (both case-insensitive).
|
||||
func spellIDsForCantrip(spells map[int]map[string]any, cantrip string) []int {
|
||||
c := strings.ToLower(strings.TrimSpace(cantrip))
|
||||
var ids []int
|
||||
for id, sp := range spells {
|
||||
name, _ := sp["name"].(string)
|
||||
n := strings.ToLower(name)
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(n, c) || strings.Contains(c, n) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Ints(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
// exactSpellID returns the lowest ID whose name equals q case-insensitively.
|
||||
func exactSpellID(spells map[int]map[string]any, q string) (int, bool) {
|
||||
q = strings.ToLower(strings.TrimSpace(q))
|
||||
best := -1
|
||||
for id, sp := range spells {
|
||||
name, _ := sp["name"].(string)
|
||||
if strings.ToLower(name) == q && (best == -1 || id < best) {
|
||||
best = id
|
||||
}
|
||||
}
|
||||
return best, best != -1
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Same command as Step 2. Expected: all three tests `PASS`, plus the pre-existing suite still `ok`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add go-services/inventory-go/spell_filter.go go-services/inventory-go/spell_filter_test.go
|
||||
git commit -m "feat(inventory-go): spell name -> ID matchers for search spell filters"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: SQL condition builder
|
||||
|
||||
Turns the three query params into WHERE conditions using the Task 1 matchers and the existing `argBuilder` (`search.go:139`) for positional args.
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-services/inventory-go/spell_filter.go`
|
||||
- Test: `go-services/inventory-go/spell_filter_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `go-services/inventory-go/spell_filter_test.go`:
|
||||
|
||||
```go
|
||||
func TestSpellFilterConds_Cantrips(t *testing.T) {
|
||||
ab := &argBuilder{}
|
||||
q := map[string][]string{"legendary_cantrips": {"Legendary Invulnerability, Legendary Summoning Prowess"}}
|
||||
conds := spellFilterConds(q, testSpells, ab)
|
||||
want := []string{
|
||||
"EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($1))",
|
||||
"EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($2, $3))",
|
||||
}
|
||||
if !reflect.DeepEqual(conds, want) {
|
||||
t.Errorf("conds = %v, want %v", conds, want)
|
||||
}
|
||||
// "Legendary Invulnerability" exact-contains only id 1; the Summoning
|
||||
// label matches 3 and 5. AND semantics = one EXISTS per cantrip.
|
||||
if wantArgs := []any{1, 3, 5}; !reflect.DeepEqual(ab.args, wantArgs) {
|
||||
t.Errorf("args = %v, want %v", ab.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpellFilterConds_NoMatchIsImpossible(t *testing.T) {
|
||||
ab := &argBuilder{}
|
||||
q := map[string][]string{"legendary_cantrips": {"Legendary Frostbite"}}
|
||||
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, []string{"1 = 0"}) {
|
||||
t.Errorf("unknown cantrip conds = %v, want [1 = 0]", conds)
|
||||
}
|
||||
ab = &argBuilder{}
|
||||
q = map[string][]string{"spell_contains": {"frostbite"}}
|
||||
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, []string{"1 = 0"}) {
|
||||
t.Errorf("unknown spell_contains conds = %v, want [1 = 0]", conds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpellFilterConds_ContainsAndHasSpell(t *testing.T) {
|
||||
ab := &argBuilder{}
|
||||
q := map[string][]string{"spell_contains": {"invulnerability"}}
|
||||
want := []string{"EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($1, $2))"}
|
||||
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, want) {
|
||||
t.Errorf("spell_contains conds = %v, want %v", conds, want)
|
||||
}
|
||||
|
||||
ab = &argBuilder{}
|
||||
q = map[string][]string{"has_spell": {"Epic Invulnerability"}}
|
||||
want = []string{"EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($1))"}
|
||||
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, want) {
|
||||
t.Errorf("has_spell conds = %v, want %v", conds, want)
|
||||
}
|
||||
if _, ok := exactSpellID(testSpells, "No Such Spell"); ok {
|
||||
t.Fatal("precondition")
|
||||
}
|
||||
ab = &argBuilder{}
|
||||
q = map[string][]string{"has_spell": {"No Such Spell"}}
|
||||
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, []string{"1 = 0"}) {
|
||||
t.Errorf("unknown has_spell conds = %v, want [1 = 0]", conds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpellFilterConds_Empty(t *testing.T) {
|
||||
ab := &argBuilder{}
|
||||
if conds := spellFilterConds(map[string][]string{}, testSpells, ab); conds != nil {
|
||||
t.Errorf("no params => nil conds, got %v", conds)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `spellFilterConds` takes `url.Values` (which IS `map[string][]string`), so the literal maps above compile directly.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run with `-run 'TestSpellFilterConds'`. Expected: `FAIL ... [build failed]` with `undefined: spellFilterConds`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Append to `go-services/inventory-go/spell_filter.go` (add `"net/url"` to its imports):
|
||||
|
||||
```go
|
||||
// spellExists renders the EXISTS clause for a set of spell IDs, binding each
|
||||
// ID as a positional arg. ids must be non-empty.
|
||||
func spellExists(ids []int, ab *argBuilder) string {
|
||||
ph := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
ph[i] = ab.add(id)
|
||||
}
|
||||
return "EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN (" + strings.Join(ph, ", ") + "))"
|
||||
}
|
||||
|
||||
// spellFilterConds builds the WHERE conditions for has_spell, spell_contains,
|
||||
// and legendary_cantrips. Cantrips use AND semantics — one EXISTS per checked
|
||||
// cantrip (NOT the legacy COUNT>=N trick, which can false-positive when one
|
||||
// label matches two spells on an item while another matches none). Any
|
||||
// no-match filter yields the impossible condition, matching legacy intent.
|
||||
func spellFilterConds(q url.Values, spells map[int]map[string]any, ab *argBuilder) []string {
|
||||
var conds []string
|
||||
impossible := func() { conds = append(conds, "1 = 0") }
|
||||
|
||||
if v := q.Get("has_spell"); v != "" {
|
||||
if id, ok := exactSpellID(spells, v); ok {
|
||||
conds = append(conds, spellExists([]int{id}, ab))
|
||||
} else {
|
||||
impossible()
|
||||
}
|
||||
}
|
||||
if v := q.Get("spell_contains"); v != "" {
|
||||
if ids := spellIDsContaining(spells, v); len(ids) > 0 {
|
||||
conds = append(conds, spellExists(ids, ab))
|
||||
} else {
|
||||
impossible()
|
||||
}
|
||||
}
|
||||
if v := q.Get("legendary_cantrips"); v != "" {
|
||||
for _, name := range splitNonEmpty(v) {
|
||||
if ids := spellIDsForCantrip(spells, name); len(ids) > 0 {
|
||||
conds = append(conds, spellExists(ids, ab))
|
||||
} else {
|
||||
impossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
return conds
|
||||
}
|
||||
```
|
||||
|
||||
(`splitNonEmpty` and `argBuilder` already exist in `search.go`.)
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run with `-run 'TestSpellFilter|TestSpellIDs|TestExactSpell'`. Expected: all `PASS`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add go-services/inventory-go/spell_filter.go go-services/inventory-go/spell_filter_test.go
|
||||
git commit -m "feat(inventory-go): build EXISTS conditions for spell filter params"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire into runSearch
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-services/inventory-go/search.go` (inside `runSearch`, after the `slot_names` block at ~line 295-303, before `where := ""`)
|
||||
|
||||
- [ ] **Step 1: Add the wiring**
|
||||
|
||||
In `search.go`, directly after the `slot_names` block (the `if v := q.Get("slot_names"); v != "" { ... }` closing brace) and before `where := ""`, insert:
|
||||
|
||||
```go
|
||||
// --- spell filters: has_spell / spell_contains / legendary_cantrips ---
|
||||
conds = append(conds, spellFilterConds(q, s.spells, ab)...)
|
||||
```
|
||||
|
||||
This runs before the LIMIT/OFFSET args are appended, so the count query's `ab.args[:len(ab.args)-2]` slicing stays correct, and the conditions apply identically to the items query and the count query.
|
||||
|
||||
- [ ] **Step 2: Run the full suite**
|
||||
|
||||
Run the SSH+Docker test command with `-run .` (everything). Expected: all tests `PASS`, `ok` for the package.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add go-services/inventory-go/search.go
|
||||
git commit -m "feat(inventory-go): wire spell filters into /search/items"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Deploy and live verification
|
||||
|
||||
**Files:** none (operational).
|
||||
|
||||
- [ ] **Step 1: Sync source to the server's production checkout**
|
||||
|
||||
```bash
|
||||
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
|
||||
tar czf - go-services | ssh erik@overlord.snakedesert.se "tar xzf - -C /home/erik/MosswartOverlord/"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build (test-gated) and recreate the container**
|
||||
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \
|
||||
export BUILD_VERSION="$(date -u +%Y.%-m.%-d.%H%M)-$(git rev-parse --short HEAD)" && \
|
||||
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml build --build-arg BUILD_VERSION=$BUILD_VERSION inventory-go && \
|
||||
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml -f go-services/docker-compose.cutover.yml \
|
||||
up -d --no-deps inventory-go'
|
||||
```
|
||||
|
||||
Expected: `RUN go test ./...` passes inside the build; container recreated and `Started`.
|
||||
|
||||
- [ ] **Step 3: Live verification (direct against inventory-go on 127.0.0.1:8772)**
|
||||
|
||||
AND semantics — every returned item must carry BOTH cantrips:
|
||||
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8772/search/items?include_all_characters=true&jewelry_only=true&legendary_cantrips=Legendary%20Invulnerability,Legendary%20Summoning%20Prowess&limit=10'" \
|
||||
| python -c "import json,sys; d=json.load(sys.stdin); print('total:', d['total_count']); [print(i['name'], '|', i.get('spell_names')) for i in d['items']]"
|
||||
```
|
||||
|
||||
Expected: every listed item's `spell_names` includes both `Legendary Invulnerability` and `Legendary Summoning Prowess`. (If total is 0, relax to a single common cantrip to confirm the mechanism, e.g. only `Legendary Invulnerability`.)
|
||||
|
||||
Jewelry-type composition — same query plus `&slot_names=Ring`:
|
||||
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8772/search/items?include_all_characters=true&jewelry_only=true&slot_names=Ring&legendary_cantrips=Legendary%20Invulnerability&limit=10'" \
|
||||
| python -c "import json,sys; d=json.load(sys.stdin); print('total:', d['total_count']); [print(i['name'], '|', i.get('slot_name'), '|', i.get('spell_names')) for i in d['items']]"
|
||||
```
|
||||
|
||||
Expected: only ring-slot items, all carrying Legendary Invulnerability.
|
||||
|
||||
`spell_contains`:
|
||||
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8772/search/items?include_all_characters=true&spell_contains=Epic%20Invulnerability&limit=5'" \
|
||||
| python -c "import json,sys; d=json.load(sys.stdin); print('total:', d['total_count']); [print(i['name'], '|', i.get('spell_names')) for i in d['items']]"
|
||||
```
|
||||
|
||||
Expected: items whose `spell_names` include `Epic Invulnerability`; non-zero total if any exist in inventories.
|
||||
|
||||
Regression — a filterless jewelry search still works and `total_count` is consistent:
|
||||
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8772/search/items?include_all_characters=true&jewelry_only=true&limit=1'" \
|
||||
| python -c "import json,sys; d=json.load(sys.stdin); print('total:', d['total_count'], 'items:', len(d['items']))"
|
||||
```
|
||||
|
||||
Expected: same total as before the deploy (spot-check it's a plausible large number, not 0).
|
||||
|
||||
- [ ] **Step 4: Check container logs for errors**
|
||||
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "docker logs inventory-go --tail 20 2>&1"
|
||||
```
|
||||
|
||||
Expected: normal request logs, no SQL errors.
|
||||
|
||||
- [ ] **Step 5: Push**
|
||||
|
||||
```bash
|
||||
git push origin master
|
||||
```
|
||||
|
||||
(Server checkout already matches via the tar sync; the push keeps git as source of truth.)
|
||||
|
|
@ -1,986 +0,0 @@
|
|||
# Inventory Search Redesign Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace `static/inventory.html`/`inventory.js` with a dark-themed React full-page inventory search at `/?view=inventory` (facet sidebar, chips, instant search, detail panel), per the approved spec + mockup.
|
||||
|
||||
**Architecture:** New self-contained component tree under `frontend/src/components/inventory/` with a single `useInventorySearch` hook owning filter state, debounce, aborting fetch, and URL serialization. Zero backend changes — everything talks to the existing `GET /api/inv/search/items` (plus `/api/inv/characters/list`, `/api/inv/sets/list`, `/api/live`). Spec: `docs/superpowers/specs/2026-07-15-inventory-search-redesign-design.md`; visual ground truth: `docs/superpowers/specs/2026-07-15-inventory-search-redesign-mockup.html` (styling/spacing/colors MUST follow it).
|
||||
|
||||
**Tech Stack:** React 19 + TypeScript + Vite (existing app, NO new npm deps). No test framework exists — per-task verification is `npm run build` (runs `tsc -b`) green; live verification is the final task. npm exists on this Windows machine; run builds via the Bash tool.
|
||||
|
||||
**Working directory for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`
|
||||
**Build command (every task):**
|
||||
```bash
|
||||
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord/frontend" && npm run build
|
||||
```
|
||||
(If `node_modules` is missing, run `npm install` once first.)
|
||||
|
||||
**API cheat-sheet (verified live):**
|
||||
- `GET /api/inv/search/items?...` → `{items:[...], total_count, page, limit, has_next, has_previous}`. Item fields used here: `name, character_name, slot_name, value, burden, wield_level, workmanship, spell_names (string[]), is_equipped, is_bonded, is_attuned, is_rare, item_set_name, armor_level, max_damage, condition_percent, object_class_name, last_updated`.
|
||||
- Params: `text, character, characters, include_all_characters, armor_only, jewelry_only, weapon_only, weapon_type, clothing_only, shirt_only, pants_only, slot_names, legendary_cantrips (CSV, AND), spell_contains, equipment_status (equipped|unequipped), bonded, attuned, is_rare, item_set, min_* rating params, max_level, min_value, min_workmanship, max_burden, sort_by, sort_dir, page, limit`.
|
||||
- `GET /api/inv/characters/list` → `{characters:[{character_name, item_count, last_updated}]}`
|
||||
- `GET /api/inv/sets/list` → `{sets:[{id, item_count, name}]}` — display `id` (e.g. "Defender's Set"); the `name` field has a broken "Unknown Set " prefix, ignore it. Send `item_set=<id>`.
|
||||
- `GET /api/live` → `{players:[{character_name, ...}]}` — online characters.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Types + constants module
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/inventory/types.ts`
|
||||
- Create: `frontend/src/components/inventory/constants.ts`
|
||||
|
||||
- [ ] **Step 1: Create `types.ts`:**
|
||||
|
||||
```ts
|
||||
// Item shape returned by /api/inv/search/items (subset we render).
|
||||
export interface InvItem {
|
||||
name: string;
|
||||
character_name: string;
|
||||
slot_name: string | null;
|
||||
value: number | null;
|
||||
burden: number | null;
|
||||
wield_level: number | null;
|
||||
workmanship: number | null;
|
||||
spell_names?: string[];
|
||||
is_equipped: boolean;
|
||||
is_bonded: boolean;
|
||||
is_attuned: boolean;
|
||||
is_rare: boolean;
|
||||
item_set_name?: string;
|
||||
armor_level: number | null;
|
||||
max_damage: number | null;
|
||||
condition_percent: number | null;
|
||||
object_class_name?: string;
|
||||
material_name?: string;
|
||||
last_updated?: string;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
items: InvItem[];
|
||||
total_count: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
has_next: boolean;
|
||||
has_previous: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ItemType = 'all' | 'armor' | 'jewelry' | 'weapon' | 'clothing' | 'shirt' | 'pants';
|
||||
|
||||
export interface SearchFilters {
|
||||
text: string;
|
||||
/** 'all' → include_all_characters=true; otherwise explicit list. */
|
||||
characters: 'all' | string[];
|
||||
itemType: ItemType;
|
||||
weaponType: string; // '' = all weapon types
|
||||
slots: string[];
|
||||
cantrips: string[]; // full "Legendary X" value strings
|
||||
spellContains: string;
|
||||
/** min-rating param name -> value ('' = unset). */
|
||||
ratings: Record<string, number | ''>;
|
||||
itemSet: string; // set id, '' = none
|
||||
equippedOnly: boolean;
|
||||
bonded: boolean;
|
||||
attuned: boolean;
|
||||
rare: boolean;
|
||||
maxLevel: number | '';
|
||||
minValue: number | '';
|
||||
minWorkmanship: number | '';
|
||||
maxBurden: number | '';
|
||||
sortBy: string;
|
||||
sortDir: 'asc' | 'desc';
|
||||
page: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_FILTERS: SearchFilters = {
|
||||
text: '', characters: 'all', itemType: 'all', weaponType: '', slots: [],
|
||||
cantrips: [], spellContains: '', ratings: {}, itemSet: '',
|
||||
equippedOnly: false, bonded: false, attuned: false, rare: false,
|
||||
maxLevel: '', minValue: '', minWorkmanship: '', maxBurden: '',
|
||||
sortBy: 'name', sortDir: 'asc', page: 1,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `constants.ts`** — the 47 cantrip values are lifted verbatim from the old page's checkboxes (they are what the backend's `legendary_cantrips` matcher expects); labels drop the "Legendary " prefix:
|
||||
|
||||
```ts
|
||||
export interface CantripDef { value: string; label: string; }
|
||||
export interface CantripGroup { group: string; items: CantripDef[]; }
|
||||
|
||||
const c = (value: string): CantripDef => ({ value, label: value.replace(/^Legendary /, '') });
|
||||
|
||||
export const CANTRIP_GROUPS: CantripGroup[] = [
|
||||
{ group: 'Attributes', items: [
|
||||
c('Legendary Strength'), c('Legendary Endurance'), c('Legendary Quickness'),
|
||||
c('Legendary Coordination'), c('Legendary Willpower'), c('Legendary Focus'),
|
||||
]},
|
||||
{ group: 'Weapon skills', items: [
|
||||
c('Legendary Heavy Weapon Aptitude'), c('Legendary Light Weapon Aptitude'),
|
||||
c('Legendary Finesse Weapon Aptitude'), c('Legendary Missile Weapon Aptitude'),
|
||||
c('Legendary Two Handed Combat Aptitude'), c('Legendary Dual Wield Aptitude'),
|
||||
c('Legendary Shield Aptitude'), c('Legendary Sneak Attack Prowess'),
|
||||
c('Legendary Dirty Fighting Prowess'), c('Legendary Recklessness Prowess'),
|
||||
c('Legendary Defender'), c('Legendary Blood Thirst'),
|
||||
]},
|
||||
{ group: 'Magic', items: [
|
||||
c('Legendary War Magic Aptitude'), c('Legendary Void Magic Aptitude'),
|
||||
c('Legendary Creature Enchantment Aptitude'), c('Legendary Item Enchantment Aptitude'),
|
||||
c('Legendary Life Magic Aptitude'), c('Legendary Mana Conversion Prowess'),
|
||||
c('Legendary Arcane Prowess'), c('Legendary Hermetic Link'),
|
||||
c('Legendary Spirit Thirst'), c('Legendary Magic Resistance'),
|
||||
]},
|
||||
{ group: 'Utility', items: [
|
||||
c('Legendary Summoning Prowess'), c('Legendary Healing Prowess'),
|
||||
c('Legendary Leadership'), c('Legendary Deception Prowess'),
|
||||
c('Legendary Person Attunement'), c('Legendary Magic Item Tinkering Expertise'),
|
||||
]},
|
||||
{ group: 'Defense', items: [
|
||||
c('Legendary Invulnerability'), c('Legendary Impenetrability'),
|
||||
c('Legendary Impregnability'), c('Legendary Armor'),
|
||||
]},
|
||||
{ group: 'Wards & Banes', items: [
|
||||
c('Legendary Flame Ward'), c('Legendary Frost Ward'), c('Legendary Acid Ward'),
|
||||
c('Legendary Storm Ward'), c('Legendary Slashing Ward'), c('Legendary Piercing Ward'),
|
||||
c('Legendary Bludgeoning Ward'), c('Legendary Piercing Bane'), c('Legendary Storm Bane'),
|
||||
]},
|
||||
];
|
||||
|
||||
export const JEWELRY_SLOTS = ['Ring', 'Bracelet', 'Neck', 'Trinket', 'Cloak'];
|
||||
export const ARMOR_SLOTS = ['Head', 'Chest', 'Abdomen', 'Upper Arms', 'Lower Arms',
|
||||
'Hands', 'Upper Legs', 'Lower Legs', 'Feet', 'Shield'];
|
||||
|
||||
export const WEAPON_TYPES: Array<{ value: string; label: string }> = [
|
||||
{ value: '', label: 'All weapons' }, { value: 'heavy', label: 'Heavy' },
|
||||
{ value: 'light', label: 'Light' }, { value: 'finesse', label: 'Finesse' },
|
||||
{ value: 'two_handed', label: 'Two-handed' }, { value: 'bow', label: 'Bow' },
|
||||
{ value: 'crossbow', label: 'Crossbow' }, { value: 'thrown', label: 'Thrown' },
|
||||
{ value: 'caster', label: 'Wand/Staff/Orb' },
|
||||
];
|
||||
|
||||
export interface RatingDef { param: string; label: string; common?: boolean; }
|
||||
export const RATING_DEFS: RatingDef[] = [
|
||||
{ param: 'min_damage_rating', label: 'Damage rating', common: true },
|
||||
{ param: 'min_crit_damage_rating', label: 'Crit damage', common: true },
|
||||
{ param: 'min_heal_boost_rating', label: 'Heal boost', common: true },
|
||||
{ param: 'min_vitality_rating', label: 'Vitality', common: true },
|
||||
{ param: 'min_armor', label: 'Armor level' },
|
||||
{ param: 'min_damage_resist_rating', label: 'Damage resist' },
|
||||
{ param: 'min_crit_resist_rating', label: 'Crit resist' },
|
||||
{ param: 'min_crit_damage_resist_rating', label: 'Crit dmg resist' },
|
||||
{ param: 'min_healing_resist_rating', label: 'Healing resist' },
|
||||
{ param: 'min_nether_resist_rating', label: 'Nether resist' },
|
||||
{ param: 'min_healing_rating', label: 'Healing rating' },
|
||||
{ param: 'min_dot_resist_rating', label: 'DoT resist' },
|
||||
{ param: 'min_life_resist_rating', label: 'Life resist' },
|
||||
{ param: 'min_sneak_attack_rating', label: 'Sneak attack' },
|
||||
{ param: 'min_recklessness_rating', label: 'Recklessness' },
|
||||
{ param: 'min_deception_rating', label: 'Deception' },
|
||||
{ param: 'min_pk_damage_rating', label: 'PK damage' },
|
||||
{ param: 'min_pk_damage_resist_rating', label: 'PK dmg resist' },
|
||||
{ param: 'min_gear_pk_damage_rating', label: 'Gear PK dmg' },
|
||||
{ param: 'min_gear_pk_damage_resist_rating', label: 'Gear PK resist' },
|
||||
{ param: 'min_tinks', label: 'Tinks' },
|
||||
];
|
||||
|
||||
export interface ColumnDef {
|
||||
key: string; label: string; sortKey?: string; defaultVisible: boolean;
|
||||
}
|
||||
// sortKey values must exist in the backend's sortMapping (search.go).
|
||||
export const COLUMNS: ColumnDef[] = [
|
||||
{ key: 'name', label: 'Item', sortKey: 'name', defaultVisible: true },
|
||||
{ key: 'character_name', label: 'Character', sortKey: 'character_name', defaultVisible: true },
|
||||
{ key: 'slot_name', label: 'Slot', defaultVisible: true },
|
||||
{ key: 'value', label: 'Value', sortKey: 'value', defaultVisible: true },
|
||||
{ key: 'wield_level', label: 'Wield', sortKey: 'level', defaultVisible: true },
|
||||
{ key: 'spell_names', label: 'Spells / Cantrips', sortKey: 'spell_names', defaultVisible: true },
|
||||
{ key: 'armor_level', label: 'Armor', sortKey: 'armor', defaultVisible: false },
|
||||
{ key: 'max_damage', label: 'Max Dmg', sortKey: 'damage', defaultVisible: false },
|
||||
{ key: 'workmanship', label: 'Work', sortKey: 'workmanship', defaultVisible: false },
|
||||
{ key: 'item_set_name', label: 'Set', sortKey: 'item_set', defaultVisible: false },
|
||||
{ key: 'object_class_name', label: 'Type', sortKey: 'item_type_name', defaultVisible: false },
|
||||
{ key: 'burden', label: 'Burden', defaultVisible: false },
|
||||
{ key: 'condition_percent', label: 'Cond %', defaultVisible: false },
|
||||
{ key: 'last_updated', label: 'Updated', sortKey: 'last_updated', defaultVisible: false },
|
||||
];
|
||||
|
||||
export const PAGE_SIZE = 200;
|
||||
export const COLUMNS_LS_KEY = 'inv.visibleColumns';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build** — expected green (files compile standalone).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/inventory/
|
||||
git commit -m "feat(frontend): inventory search types + filter/column constants"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: useInventorySearch hook
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/inventory/useInventorySearch.ts`
|
||||
|
||||
- [ ] **Step 1: Create the hook** — owns filters, URL (de)serialization (single `q` JSON param alongside `view=inventory`), 400 ms debounce, AbortController fetch:
|
||||
|
||||
```ts
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { DEFAULT_FILTERS, type SearchFilters, type SearchResponse } from './types';
|
||||
import { PAGE_SIZE } from './constants';
|
||||
|
||||
/** Serialize only the keys that differ from defaults, as a compact q= JSON param. */
|
||||
function filtersToUrl(f: SearchFilters): void {
|
||||
const diff: Partial<SearchFilters> = {};
|
||||
for (const k of Object.keys(DEFAULT_FILTERS) as Array<keyof SearchFilters>) {
|
||||
if (JSON.stringify(f[k]) !== JSON.stringify(DEFAULT_FILTERS[k])) (diff as any)[k] = f[k];
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('view', 'inventory');
|
||||
if (Object.keys(diff).length) url.searchParams.set('q', JSON.stringify(diff));
|
||||
else url.searchParams.delete('q');
|
||||
window.history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
function filtersFromUrl(): SearchFilters {
|
||||
try {
|
||||
const q = new URLSearchParams(window.location.search).get('q');
|
||||
if (!q) return DEFAULT_FILTERS;
|
||||
return { ...DEFAULT_FILTERS, ...JSON.parse(q) };
|
||||
} catch {
|
||||
return DEFAULT_FILTERS;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildParams(f: SearchFilters): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
if (f.characters === 'all') p.set('include_all_characters', 'true');
|
||||
else if (f.characters.length === 1) p.set('character', f.characters[0]);
|
||||
else p.set('characters', f.characters.join(','));
|
||||
|
||||
if (f.text) p.set('text', f.text);
|
||||
switch (f.itemType) {
|
||||
case 'armor': p.set('armor_only', 'true'); break;
|
||||
case 'jewelry': p.set('jewelry_only', 'true'); break;
|
||||
case 'clothing': p.set('clothing_only', 'true'); break;
|
||||
case 'shirt': p.set('shirt_only', 'true'); break;
|
||||
case 'pants': p.set('pants_only', 'true'); break;
|
||||
case 'weapon':
|
||||
p.set('weapon_only', 'true');
|
||||
if (f.weaponType) p.set('weapon_type', f.weaponType);
|
||||
break;
|
||||
}
|
||||
if (f.slots.length) p.set('slot_names', f.slots.join(','));
|
||||
if (f.cantrips.length) p.set('legendary_cantrips', f.cantrips.join(','));
|
||||
if (f.spellContains) p.set('spell_contains', f.spellContains);
|
||||
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') p.set(param, String(v));
|
||||
if (f.itemSet) p.set('item_set', f.itemSet);
|
||||
if (f.equippedOnly) p.set('equipment_status', 'equipped');
|
||||
if (f.bonded) p.set('bonded', 'true');
|
||||
if (f.attuned) p.set('attuned', 'true');
|
||||
if (f.rare) p.set('is_rare', 'true');
|
||||
if (f.maxLevel !== '') p.set('max_level', String(f.maxLevel));
|
||||
if (f.minValue !== '') p.set('min_value', String(f.minValue));
|
||||
if (f.minWorkmanship !== '') p.set('min_workmanship', String(f.minWorkmanship));
|
||||
if (f.maxBurden !== '') p.set('max_burden', String(f.maxBurden));
|
||||
p.set('sort_by', f.sortBy);
|
||||
p.set('sort_dir', f.sortDir);
|
||||
p.set('page', String(f.page));
|
||||
p.set('limit', String(PAGE_SIZE));
|
||||
return p;
|
||||
}
|
||||
|
||||
export interface InventorySearch {
|
||||
filters: SearchFilters;
|
||||
/** Patch filters; resets page to 1 unless the patch itself sets page. */
|
||||
update: (patch: Partial<SearchFilters>) => void;
|
||||
reset: () => void;
|
||||
result: SearchResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
queryMs: number | null;
|
||||
}
|
||||
|
||||
export function useInventorySearch(): InventorySearch {
|
||||
const [filters, setFilters] = useState<SearchFilters>(filtersFromUrl);
|
||||
const [result, setResult] = useState<SearchResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [queryMs, setQueryMs] = useState<number | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const update = useCallback((patch: Partial<SearchFilters>) => {
|
||||
setFilters(prev => ({ ...prev, page: 'page' in patch ? prev.page : 1, ...patch }));
|
||||
}, []);
|
||||
const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []);
|
||||
|
||||
useEffect(() => {
|
||||
filtersToUrl(filters);
|
||||
const timer = setTimeout(async () => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const res = await fetch(`/api/inv/search/items?${buildParams(filters)}`, {
|
||||
credentials: 'include', signal: ctrl.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`search: HTTP ${res.status}`);
|
||||
const data: SearchResponse = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
setResult(data);
|
||||
setError(null);
|
||||
setQueryMs(Math.round(performance.now() - t0));
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
if (abortRef.current === ctrl) setLoading(false);
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [filters]);
|
||||
|
||||
return { filters, update, reset, result, loading, error, queryMs };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build** — green.
|
||||
- [ ] **Step 3: Commit** — `feat(frontend): useInventorySearch hook (debounced abortable search, URL state)`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Routing, page scaffold, CSS
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.tsx`
|
||||
- Create: `frontend/src/components/inventory/InventorySearchPage.tsx`
|
||||
- Create: `frontend/src/styles/inventory.css`
|
||||
|
||||
- [ ] **Step 1: Route.** In `App.tsx`, add the import and branch (current file branches only on `'dashboard'`):
|
||||
|
||||
```tsx
|
||||
import { InventorySearchPage } from './components/inventory/InventorySearchPage';
|
||||
import './styles/inventory.css';
|
||||
```
|
||||
and change the ternary to:
|
||||
```tsx
|
||||
{view === 'dashboard' ? <PlayerDashboardFullPage />
|
||||
: view === 'inventory' ? <InventorySearchPage />
|
||||
: <DefaultApp />}
|
||||
```
|
||||
Also update the header comment to document `/?view=inventory`.
|
||||
|
||||
- [ ] **Step 2: Page scaffold** — `InventorySearchPage.tsx`. Full layout with top bar wired to the hook; sidebar/table/detail rendered from components built in Tasks 4-6 (create with placeholder stubs now so this task builds; the stubs are replaced by the real components in their tasks):
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import { useInventorySearch } from './useInventorySearch';
|
||||
import { FilterSidebar } from './FilterSidebar';
|
||||
import { ActiveChips } from './ActiveChips';
|
||||
import { ResultsTable } from './ResultsTable';
|
||||
import { DetailPanel } from './DetailPanel';
|
||||
import type { InvItem } from './types';
|
||||
|
||||
export function InventorySearchPage() {
|
||||
const search = useInventorySearch();
|
||||
const [selected, setSelected] = useState<InvItem | null>(null);
|
||||
|
||||
return (
|
||||
<div className="inv-page">
|
||||
<div className="inv-topbar">
|
||||
<span className="inv-title">⚔ Inventory Search</span>
|
||||
<input
|
||||
className="inv-searchbox"
|
||||
placeholder="Search name or material…"
|
||||
value={search.filters.text}
|
||||
onChange={e => search.update({ text: e.target.value })}
|
||||
/>
|
||||
<button className="inv-btn" onClick={() => { search.reset(); setSelected(null); }}>Reset</button>
|
||||
<span className="inv-count">
|
||||
{search.error ? <span className="inv-error">{search.error}</span>
|
||||
: search.result ? <><b>{search.result.total_count.toLocaleString()}</b> items
|
||||
{search.queryMs != null && <> · {search.queryMs} ms</>}
|
||||
{search.loading && ' · …'}</>
|
||||
: 'loading…'}
|
||||
</span>
|
||||
</div>
|
||||
<ActiveChips search={search} />
|
||||
<div className="inv-main">
|
||||
<FilterSidebar search={search} />
|
||||
<ResultsTable search={search} selected={selected} onSelect={setSelected} />
|
||||
{selected && <DetailPanel item={selected} onClose={() => setSelected(null)} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For this task only, create minimal stub files so the build passes (each replaced by its real task):
|
||||
```tsx
|
||||
// FilterSidebar.tsx (stub — replaced in Task 4)
|
||||
import type { InventorySearch } from './useInventorySearch';
|
||||
export function FilterSidebar(_: { search: InventorySearch }) { return <div className="inv-sidebar" />; }
|
||||
```
|
||||
```tsx
|
||||
// ActiveChips.tsx (stub — replaced in Task 5)
|
||||
import type { InventorySearch } from './useInventorySearch';
|
||||
export function ActiveChips(_: { search: InventorySearch }) { return null; }
|
||||
```
|
||||
```tsx
|
||||
// ResultsTable.tsx (stub — replaced in Task 5)
|
||||
import type { InventorySearch } from './useInventorySearch';
|
||||
import type { InvItem } from './types';
|
||||
export function ResultsTable(_: { search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void }) {
|
||||
return <div className="inv-results" />;
|
||||
}
|
||||
```
|
||||
```tsx
|
||||
// DetailPanel.tsx (stub — replaced in Task 6)
|
||||
import type { InvItem } from './types';
|
||||
export function DetailPanel(_: { item: InvItem; onClose: () => void }) { return null; }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: CSS** — `frontend/src/styles/inventory.css`. Port the approved mockup's `<style>` block (see `docs/superpowers/specs/2026-07-15-inventory-search-redesign-mockup.html`) with class names prefixed `inv-` and BEM-ish nesting flattened. Required tokens: page bg `#111`, panels `#1a1a1a`, borders `#333`, accent `#88f`, legendary gold `#fc6` (chip bg `#2a2418`, border `#a80`), selected row bg `#20203a`, detail bg `#161620`, font `"Segoe UI", sans-serif`, sidebar width 210px, detail width 250px, sticky table header on `#191919`. Copy the mockup's rules for: `.topbar→.inv-topbar`, `.searchbox→.inv-searchbox`, `.btn→.inv-btn`, `.chipsrow/.chip→.inv-chipsrow/.inv-chip(.gold)`, `.sidebar/.grp/.grp-head/.grp-body/.badge/.minisearch/.fitem/.subhead/.linky/.range→.inv-*`, table rules (`.inv-results table/th/td`, `tr.inv-sel`, `.inv-leg`, `.inv-spells`, `.inv-equipped`, `.inv-pager/.inv-pg(.cur)`), `.detail→.inv-detail` with `.inv-kv/.inv-sp(.leg)/.inv-sphead/.inv-hint/.inv-closex`. Add `.inv-page{display:flex;flex-direction:column;height:100vh;background:#111;color:#eee;font-family:"Segoe UI",sans-serif}` and `.inv-main{display:flex;flex:1;overflow:hidden}`, `.inv-error{color:#c66}`.
|
||||
|
||||
- [ ] **Step 4: Build** — green. Manually sanity-check nothing else broke: `npm run build` output lists the new css/js chunks.
|
||||
- [ ] **Step 5: Commit** — `feat(frontend): /?view=inventory route + inventory page scaffold + dark CSS`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: FilterSidebar (all filter groups)
|
||||
|
||||
**Files:**
|
||||
- Replace stub: `frontend/src/components/inventory/FilterSidebar.tsx`
|
||||
- Create: `frontend/src/components/inventory/CharacterFilter.tsx`
|
||||
- Create: `frontend/src/components/inventory/CantripFilter.tsx`
|
||||
|
||||
- [ ] **Step 1: Shared collapsible group.** At the top of `FilterSidebar.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { InventorySearch } from './useInventorySearch';
|
||||
import { CharacterFilter } from './CharacterFilter';
|
||||
import { CantripFilter } from './CantripFilter';
|
||||
import { ARMOR_SLOTS, JEWELRY_SLOTS, RATING_DEFS, WEAPON_TYPES } from './constants';
|
||||
import type { ItemType } from './types';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
export function Group(props: {
|
||||
title: string; badge?: string | number; defaultOpen?: boolean; children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(props.defaultOpen ?? false);
|
||||
return (
|
||||
<div className={`inv-grp${open ? ' inv-open' : ''}`}>
|
||||
<div className="inv-grp-head" onClick={() => setOpen(o => !o)}>
|
||||
<span className="inv-arrow">▶</span> {props.title}
|
||||
{props.badge ? <span className="inv-badge">{props.badge}</span> : null}
|
||||
</div>
|
||||
{open && <div className="inv-grp-body">{props.children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `FilterSidebar` body** — groups in mockup order. Item type radios (All/Armor/Jewelry/Weapons+subtype select/Clothing/Shirts/Pants), Slots (jewelry slots always visible, armor slots behind a "show armor slots" toggle), Ratings (common four + "all ratings" toggle), Sets (fetched once from `/inv/sets/list`, searchable, single-select radio list sending `id`), Item state, Reqs & value:
|
||||
|
||||
```tsx
|
||||
export function FilterSidebar({ search }: { search: InventorySearch }) {
|
||||
const { filters: f, update } = search;
|
||||
const [showArmorSlots, setShowArmorSlots] = useState(false);
|
||||
const [allRatings, setAllRatings] = useState(false);
|
||||
const [sets, setSets] = useState<Array<{ id: string; item_count: number }>>([]);
|
||||
const [setQuery, setSetQuery] = useState('');
|
||||
useEffect(() => {
|
||||
apiFetch<{ sets: Array<{ id: string; item_count: number }> }>('/inv/sets/list')
|
||||
.then(r => setSets(r.sets)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleSlot = (s: string) => update({
|
||||
slots: f.slots.includes(s) ? f.slots.filter(x => x !== s) : [...f.slots, s],
|
||||
});
|
||||
const setRating = (param: string, v: string) => update({
|
||||
ratings: { ...f.ratings, [param]: v === '' ? '' : Number(v) },
|
||||
});
|
||||
const ratingCount = Object.values(f.ratings).filter(v => v !== '').length;
|
||||
const stateCount = [f.equippedOnly, f.bonded, f.attuned, f.rare].filter(Boolean).length;
|
||||
const reqCount = [f.maxLevel, f.minValue, f.minWorkmanship, f.maxBurden].filter(v => v !== '').length;
|
||||
const types: Array<[ItemType, string]> = [['all', 'All items'], ['armor', 'Armor'],
|
||||
['jewelry', 'Jewelry'], ['weapon', 'Weapons'], ['clothing', 'Clothing'],
|
||||
['shirt', 'Shirts'], ['pants', 'Pants']];
|
||||
|
||||
return (
|
||||
<div className="inv-sidebar">
|
||||
<CharacterFilter search={search} />
|
||||
<Group title="Item type" defaultOpen badge={f.itemType !== 'all' ? 1 : undefined}>
|
||||
{types.map(([v, label]) => (
|
||||
<label className="inv-fitem" key={v}>
|
||||
<input type="radio" name="inv-t" checked={f.itemType === v}
|
||||
onChange={() => update({ itemType: v, weaponType: '' })} /> {label}
|
||||
</label>
|
||||
))}
|
||||
{f.itemType === 'weapon' && (
|
||||
<select className="inv-minisearch" value={f.weaponType}
|
||||
onChange={e => update({ weaponType: e.target.value })}>
|
||||
{WEAPON_TYPES.map(w => <option key={w.value} value={w.value}>{w.label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</Group>
|
||||
<Group title="Slots" defaultOpen badge={f.slots.length || undefined}>
|
||||
{JEWELRY_SLOTS.map(s => (
|
||||
<label className="inv-fitem" key={s}>
|
||||
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
|
||||
</label>
|
||||
))}
|
||||
<span className="inv-linky" onClick={() => setShowArmorSlots(v => !v)}>
|
||||
{showArmorSlots ? 'hide' : 'show'} armor slots {showArmorSlots ? '▴' : '▾'}
|
||||
</span>
|
||||
{showArmorSlots && ARMOR_SLOTS.map(s => (
|
||||
<label className="inv-fitem" key={s}>
|
||||
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
|
||||
</label>
|
||||
))}
|
||||
</Group>
|
||||
<CantripFilter search={search} />
|
||||
<Group title="Ratings" badge={ratingCount || undefined}>
|
||||
{RATING_DEFS.filter(r => allRatings || r.common).map(r => (
|
||||
<div className="inv-range" key={r.param}>
|
||||
<label>{r.label} ≥</label>
|
||||
<input type="number" value={f.ratings[r.param] ?? ''} placeholder="min"
|
||||
onChange={e => setRating(r.param, e.target.value)} />
|
||||
</div>
|
||||
))}
|
||||
<span className="inv-linky" onClick={() => setAllRatings(v => !v)}>
|
||||
{allRatings ? 'common ratings ▴' : `all ${RATING_DEFS.length} ratings ▾`}
|
||||
</span>
|
||||
</Group>
|
||||
<Group title="Equipment sets" badge={f.itemSet ? 1 : undefined}>
|
||||
<input className="inv-minisearch" placeholder="find set…" value={setQuery}
|
||||
onChange={e => setSetQuery(e.target.value)} />
|
||||
<label className="inv-fitem">
|
||||
<input type="radio" name="inv-set" checked={f.itemSet === ''}
|
||||
onChange={() => update({ itemSet: '' })} /> Any set
|
||||
</label>
|
||||
{sets.filter(s => s.id.toLowerCase().includes(setQuery.toLowerCase())).map(s => (
|
||||
<label className="inv-fitem" key={s.id}>
|
||||
<input type="radio" name="inv-set" checked={f.itemSet === s.id}
|
||||
onChange={() => update({ itemSet: s.id })} /> {s.id} <span className="inv-dim">({s.item_count})</span>
|
||||
</label>
|
||||
))}
|
||||
</Group>
|
||||
<Group title="Item state" badge={stateCount || undefined}>
|
||||
<label className="inv-fitem"><input type="checkbox" checked={f.equippedOnly}
|
||||
onChange={e => update({ equippedOnly: e.target.checked })} /> Equipped only</label>
|
||||
<label className="inv-fitem"><input type="checkbox" checked={f.bonded}
|
||||
onChange={e => update({ bonded: e.target.checked })} /> Bonded</label>
|
||||
<label className="inv-fitem"><input type="checkbox" checked={f.attuned}
|
||||
onChange={e => update({ attuned: e.target.checked })} /> Attuned</label>
|
||||
<label className="inv-fitem"><input type="checkbox" checked={f.rare}
|
||||
onChange={e => update({ rare: e.target.checked })} /> Rare</label>
|
||||
</Group>
|
||||
<Group title="Reqs & value" badge={reqCount || undefined}>
|
||||
<div className="inv-range"><label>Wield lvl ≤</label>
|
||||
<input type="number" value={f.maxLevel} placeholder="max"
|
||||
onChange={e => update({ maxLevel: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
|
||||
<div className="inv-range"><label>Value ≥</label>
|
||||
<input type="number" value={f.minValue} placeholder="min"
|
||||
onChange={e => update({ minValue: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
|
||||
<div className="inv-range"><label>Workmanship ≥</label>
|
||||
<input type="number" value={f.minWorkmanship} placeholder="min"
|
||||
onChange={e => update({ minWorkmanship: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
|
||||
<div className="inv-range"><label>Burden ≤</label>
|
||||
<input type="number" value={f.maxBurden} placeholder="max"
|
||||
onChange={e => update({ maxBurden: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
Add `.inv-dim{color:#666;font-size:10px}` to inventory.css.
|
||||
|
||||
- [ ] **Step 3: `CharacterFilter.tsx`** — full list from `/inv/characters/list`, online set from `/live` (one-shot fetches, no WebSocket), searchable, All/None/Online links:
|
||||
|
||||
```tsx
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { apiFetch } from '../../api/client';
|
||||
import type { InventorySearch } from './useInventorySearch';
|
||||
import { Group } from './FilterSidebar';
|
||||
|
||||
export function CharacterFilter({ search }: { search: InventorySearch }) {
|
||||
const { filters: f, update } = search;
|
||||
const [names, setNames] = useState<string[]>([]);
|
||||
const [online, setOnline] = useState<Set<string>>(new Set());
|
||||
const [q, setQ] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ characters: Array<{ character_name: string }> }>('/inv/characters/list')
|
||||
.then(r => setNames(r.characters.map(c => c.character_name).sort((a, b) => a.localeCompare(b))))
|
||||
.catch(() => {});
|
||||
apiFetch<{ players: Array<{ character_name: string }> }>('/live')
|
||||
.then(r => setOnline(new Set(r.players.map(p => p.character_name))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const checked = useMemo(() =>
|
||||
f.characters === 'all' ? new Set(names) : new Set(f.characters), [f.characters, names]);
|
||||
const toggle = (n: string) => {
|
||||
const next = new Set(checked);
|
||||
if (next.has(n)) next.delete(n); else next.add(n);
|
||||
update({ characters: next.size === names.length ? 'all' : [...next] });
|
||||
};
|
||||
const shown = names.filter(n => n.toLowerCase().includes(q.toLowerCase()));
|
||||
|
||||
return (
|
||||
<Group title="Characters" defaultOpen
|
||||
badge={f.characters === 'all' ? 'All' : f.characters.length}>
|
||||
<input className="inv-minisearch" placeholder="filter characters…"
|
||||
value={q} onChange={e => setQ(e.target.value)} />
|
||||
<div className="inv-links">
|
||||
<span className="inv-linky" onClick={() => update({ characters: 'all' })}>All</span>{' · '}
|
||||
<span className="inv-linky" onClick={() => update({ characters: [] })}>None</span>{' · '}
|
||||
<span className="inv-linky" onClick={() => update({ characters: names.filter(n => online.has(n)) })}>Online</span>
|
||||
</div>
|
||||
{shown.map(n => (
|
||||
<label className="inv-fitem" key={n}>
|
||||
<input type="checkbox" checked={checked.has(n)} onChange={() => toggle(n)} />
|
||||
{n} {online.has(n) && <span className="inv-online" />}
|
||||
</label>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
```
|
||||
Add to inventory.css: `.inv-online{width:6px;height:6px;border-radius:50%;background:#4c4;display:inline-block;margin-left:4px}` and `.inv-links{margin-bottom:4px;font-size:10px}`.
|
||||
Note: `characters: []` (None) sends `characters=` empty → backend returns the "Empty characters list" error object; the hook surfaces it as an error string. Acceptable — the count area shows the message and choosing any character recovers. (Matches old-page behavior of requiring a character selection.)
|
||||
|
||||
- [ ] **Step 4: `CantripFilter.tsx`** — find-as-you-type, grouped, checked pinned to a Selected section, plus the any-tier `spell_contains` input:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import { CANTRIP_GROUPS } from './constants';
|
||||
import type { InventorySearch } from './useInventorySearch';
|
||||
import { Group } from './FilterSidebar';
|
||||
|
||||
export function CantripFilter({ search }: { search: InventorySearch }) {
|
||||
const { filters: f, update } = search;
|
||||
const [q, setQ] = useState('');
|
||||
const toggle = (v: string) => update({
|
||||
cantrips: f.cantrips.includes(v) ? f.cantrips.filter(x => x !== v) : [...f.cantrips, v],
|
||||
});
|
||||
const match = (label: string) => label.toLowerCase().includes(q.toLowerCase());
|
||||
|
||||
return (
|
||||
<Group title="Cantrips" defaultOpen badge={f.cantrips.length || undefined}>
|
||||
<input className="inv-minisearch" placeholder="find cantrip… e.g. invuln"
|
||||
value={q} onChange={e => setQ(e.target.value)} />
|
||||
{f.cantrips.length > 0 && (<>
|
||||
<div className="inv-subhead">Selected</div>
|
||||
{f.cantrips.map(v => (
|
||||
<label className="inv-fitem inv-gold" key={v}>
|
||||
<input type="checkbox" checked onChange={() => toggle(v)} />
|
||||
{v.replace(/^Legendary /, '')}
|
||||
</label>
|
||||
))}
|
||||
</>)}
|
||||
{CANTRIP_GROUPS.map(g => {
|
||||
const items = g.items.filter(i => !f.cantrips.includes(i.value) && match(i.label));
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div key={g.group}>
|
||||
<div className="inv-subhead">{g.group}</div>
|
||||
{items.map(i => (
|
||||
<label className="inv-fitem" key={i.value}>
|
||||
<input type="checkbox" checked={false} onChange={() => toggle(i.value)} /> {i.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="inv-subhead">Any-tier spell search</div>
|
||||
<input className="inv-minisearch" placeholder="spell name contains… e.g. Epic Invuln"
|
||||
value={f.spellContains} onChange={e => update({ spellContains: e.target.value })} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
```
|
||||
Add `.inv-gold{color:#fc6}` to inventory.css.
|
||||
|
||||
- [ ] **Step 5: Build** — green.
|
||||
- [ ] **Step 6: Commit** — `feat(frontend): inventory filter sidebar (characters, type, slots, cantrips, ratings, sets, state, reqs)`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: ActiveChips + ResultsTable
|
||||
|
||||
**Files:**
|
||||
- Replace stubs: `frontend/src/components/inventory/ActiveChips.tsx`, `frontend/src/components/inventory/ResultsTable.tsx`
|
||||
|
||||
- [ ] **Step 1: `ActiveChips.tsx`** — derive chips from filters; × patches the filter away; legendary chips gold:
|
||||
|
||||
```tsx
|
||||
import type { InventorySearch } from './useInventorySearch';
|
||||
import { RATING_DEFS } from './constants';
|
||||
import { DEFAULT_FILTERS } from './types';
|
||||
|
||||
interface Chip { label: string; gold?: boolean; remove: () => void; }
|
||||
|
||||
export function ActiveChips({ search }: { search: InventorySearch }) {
|
||||
const { filters: f, update } = search;
|
||||
const chips: Chip[] = [];
|
||||
if (f.text) chips.push({ label: `"${f.text}"`, remove: () => update({ text: '' }) });
|
||||
if (f.characters !== 'all') chips.push({
|
||||
label: `${f.characters.length} character${f.characters.length === 1 ? '' : 's'}`,
|
||||
remove: () => update({ characters: 'all' }),
|
||||
});
|
||||
if (f.itemType !== 'all') chips.push({
|
||||
label: f.itemType[0].toUpperCase() + f.itemType.slice(1) + (f.weaponType ? `: ${f.weaponType}` : ''),
|
||||
remove: () => update({ itemType: 'all', weaponType: '' }),
|
||||
});
|
||||
for (const s of f.slots) chips.push({ label: `Slot: ${s}`, remove: () => update({ slots: f.slots.filter(x => x !== s) }) });
|
||||
for (const c of f.cantrips) chips.push({
|
||||
label: c.replace(/^Legendary /, 'Leg. '), gold: true,
|
||||
remove: () => update({ cantrips: f.cantrips.filter(x => x !== c) }),
|
||||
});
|
||||
if (f.spellContains) chips.push({ label: `Spell: ${f.spellContains}`, remove: () => update({ spellContains: '' }) });
|
||||
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') {
|
||||
const def = RATING_DEFS.find(r => r.param === param);
|
||||
chips.push({ label: `${def?.label ?? param} ≥ ${v}`, remove: () => update({ ratings: { ...f.ratings, [param]: '' } }) });
|
||||
}
|
||||
if (f.itemSet) chips.push({ label: `Set: ${f.itemSet}`, remove: () => update({ itemSet: '' }) });
|
||||
if (f.equippedOnly) chips.push({ label: 'Equipped', remove: () => update({ equippedOnly: false }) });
|
||||
if (f.bonded) chips.push({ label: 'Bonded', remove: () => update({ bonded: false }) });
|
||||
if (f.attuned) chips.push({ label: 'Attuned', remove: () => update({ attuned: false }) });
|
||||
if (f.rare) chips.push({ label: 'Rare', remove: () => update({ rare: false }) });
|
||||
if (f.maxLevel !== '') chips.push({ label: `Wield ≤ ${f.maxLevel}`, remove: () => update({ maxLevel: '' }) });
|
||||
if (f.minValue !== '') chips.push({ label: `Value ≥ ${f.minValue}`, remove: () => update({ minValue: '' }) });
|
||||
if (f.minWorkmanship !== '') chips.push({ label: `Work ≥ ${f.minWorkmanship}`, remove: () => update({ minWorkmanship: '' }) });
|
||||
if (f.maxBurden !== '') chips.push({ label: `Burden ≤ ${f.maxBurden}`, remove: () => update({ maxBurden: '' }) });
|
||||
|
||||
if (!chips.length) return null;
|
||||
return (
|
||||
<div className="inv-chipsrow">
|
||||
<span className="inv-chips-lbl">ACTIVE</span>
|
||||
{chips.map((c, i) => (
|
||||
<span className={`inv-chip${c.gold ? ' inv-chip-gold' : ''}`} key={i}>
|
||||
{c.label} <span className="inv-chip-x" onClick={c.remove}>×</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
(Unused import `DEFAULT_FILTERS` must not remain — do not include it; shown here as a reminder NOT to add it.) Remove that import line before committing.
|
||||
|
||||
- [ ] **Step 2: `ResultsTable.tsx`** — sticky sortable header, column picker (localStorage), spells with gold legendaries, equipped marker, pagination:
|
||||
|
||||
```tsx
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { COLUMNS, COLUMNS_LS_KEY } from './constants';
|
||||
import type { InventorySearch } from './useInventorySearch';
|
||||
import type { InvItem } from './types';
|
||||
|
||||
function loadVisible(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(COLUMNS_LS_KEY);
|
||||
if (raw) return new Set(JSON.parse(raw));
|
||||
} catch { /* fall through */ }
|
||||
return new Set(COLUMNS.filter(c => c.defaultVisible).map(c => c.key));
|
||||
}
|
||||
|
||||
function cell(item: InvItem, key: string): React.ReactNode {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return <>{item.name}{item.is_equipped && <span className="inv-equipped"> ⚔</span>}</>;
|
||||
case 'spell_names':
|
||||
return <span className="inv-spells">{(item.spell_names ?? []).map((s, i) => (
|
||||
<span key={i}>{i > 0 && ', '}<span className={/legendary/i.test(s) ? 'inv-leg' : ''}>{s}</span></span>
|
||||
))}</span>;
|
||||
case 'value':
|
||||
return item.value != null ? item.value.toLocaleString() : '—';
|
||||
case 'last_updated':
|
||||
return item.last_updated ? item.last_updated.slice(0, 16).replace('T', ' ') : '—';
|
||||
default: {
|
||||
const v = (item as any)[key];
|
||||
return v == null || v === -1 ? '—' : String(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ResultsTable({ search, selected, onSelect }: {
|
||||
search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void;
|
||||
}) {
|
||||
const { filters: f, update, result } = search;
|
||||
const [visible, setVisible] = useState<Set<string>>(loadVisible);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const cols = useMemo(() => COLUMNS.filter(c => visible.has(c.key)), [visible]);
|
||||
const items = result?.items ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible]));
|
||||
}, [visible]);
|
||||
|
||||
// Keyboard: ↑/↓ moves selection, Esc clears — ignore while typing in inputs.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.target as HTMLElement)?.tagName === 'INPUT' || (e.target as HTMLElement)?.tagName === 'SELECT') return;
|
||||
if (e.key === 'Escape') { onSelect(null); return; }
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
e.preventDefault();
|
||||
const idx = selected ? items.indexOf(selected) : -1;
|
||||
const next = e.key === 'ArrowDown' ? Math.min(idx + 1, items.length - 1) : Math.max(idx - 1, 0);
|
||||
if (items[next]) onSelect(items[next]);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [items, selected, onSelect]);
|
||||
|
||||
const sortOn = (sortKey?: string) => {
|
||||
if (!sortKey) return;
|
||||
if (f.sortBy === sortKey) update({ sortDir: f.sortDir === 'asc' ? 'desc' : 'asc' });
|
||||
else update({ sortBy: sortKey, sortDir: 'asc' });
|
||||
};
|
||||
const totalPages = result ? Math.max(1, Math.ceil(result.total_count / result.limit)) : 1;
|
||||
const goto = (p: number) => {
|
||||
update({ page: Math.min(Math.max(1, p), totalPages) });
|
||||
scrollRef.current?.scrollTo(0, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inv-results" ref={scrollRef}>
|
||||
<div className="inv-colpicker-anchor">
|
||||
<button className="inv-btn inv-colpicker-btn" onClick={() => setPickerOpen(o => !o)}>⚙ Columns</button>
|
||||
{pickerOpen && (
|
||||
<div className="inv-colpicker">
|
||||
{COLUMNS.map(c => (
|
||||
<label className="inv-fitem" key={c.key}>
|
||||
<input type="checkbox" checked={visible.has(c.key)} onChange={() => {
|
||||
const next = new Set(visible);
|
||||
if (next.has(c.key)) next.delete(c.key); else next.add(c.key);
|
||||
setVisible(next);
|
||||
}} /> {c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
{cols.map(c => (
|
||||
<th key={c.key} className={f.sortBy === c.sortKey ? `inv-sorted-${f.sortDir}` : ''}
|
||||
onClick={() => sortOn(c.sortKey)}>{c.label}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{items.map((it, i) => (
|
||||
<tr key={i} className={it === selected ? 'inv-sel' : ''}
|
||||
onClick={() => onSelect(it === selected ? null : it)}>
|
||||
{cols.map(c => <td key={c.key}>{cell(it, c.key)}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
{!items.length && <tr><td colSpan={cols.length} className="inv-dim">No items match.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="inv-pager">
|
||||
<span className="inv-pg" onClick={() => goto(f.page - 1)}>◀</span>
|
||||
<span>page {result?.page ?? 1} / {totalPages}</span>
|
||||
<span className="inv-pg" onClick={() => goto(f.page + 1)}>▶</span>
|
||||
<span className="inv-pager-right">{result?.limit ?? 200} / page</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
Move the "⚙ Columns" button here (remove it from the top bar if Task 3 put one there — Task 3's top bar has no Columns button, correct). CSS additions: `.inv-colpicker-anchor{position:relative;align-self:flex-end;padding:4px 10px}`, `.inv-colpicker{position:absolute;right:10px;top:30px;background:#1a1a1a;border:1px solid #444;border-radius:4px;padding:8px;z-index:5;max-height:300px;overflow-y:auto}`, `.inv-sorted-asc::after{content:" ▲";font-size:9px}`, `.inv-sorted-desc::after{content:" ▼";font-size:9px}`, `.inv-pager-right{margin-left:auto}`.
|
||||
|
||||
- [ ] **Step 3: Build** — green.
|
||||
- [ ] **Step 4: Commit** — `feat(frontend): inventory chips row + results table (sort, columns, paging, keyboard)`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: DetailPanel
|
||||
|
||||
**Files:**
|
||||
- Replace stub: `frontend/src/components/inventory/DetailPanel.tsx`
|
||||
|
||||
- [ ] **Step 1: Implement:**
|
||||
|
||||
```tsx
|
||||
import type { InvItem } from './types';
|
||||
|
||||
function Row({ k, v }: { k: string; v: React.ReactNode }) {
|
||||
return <div className="inv-kv"><span>{k}</span><b>{v ?? '—'}</b></div>;
|
||||
}
|
||||
|
||||
export function DetailPanel({ item, onClose }: { item: InvItem; onClose: () => void }) {
|
||||
return (
|
||||
<div className="inv-detail">
|
||||
<span className="inv-closex" onClick={onClose}>×</span>
|
||||
<h3>{item.name}</h3>
|
||||
<div className="inv-detail-sub">
|
||||
{item.slot_name ?? item.object_class_name ?? ''} · {item.is_equipped ? '⚔ Equipped' : '📦 Inventory'}
|
||||
{item.is_rare && ' · ★ Rare'}
|
||||
</div>
|
||||
<Row k="Character" v={item.character_name} />
|
||||
<Row k="Value" v={item.value?.toLocaleString()} />
|
||||
<Row k="Burden" v={item.burden} />
|
||||
<Row k="Wield req" v={item.wield_level ? `Level ${item.wield_level}` : '—'} />
|
||||
<Row k="Workmanship" v={item.workmanship ?? '—'} />
|
||||
{item.armor_level != null && item.armor_level > 0 && <Row k="Armor" v={item.armor_level} />}
|
||||
{item.max_damage != null && item.max_damage > 0 && <Row k="Max damage" v={item.max_damage} />}
|
||||
{item.condition_percent != null && <Row k="Condition" v={`${item.condition_percent}%`} />}
|
||||
{item.item_set_name && <Row k="Set" v={item.item_set_name} />}
|
||||
{(item.is_bonded || item.is_attuned) && <Row k="Binding" v={[item.is_bonded && 'Bonded', item.is_attuned && 'Attuned'].filter(Boolean).join(', ')} />}
|
||||
<hr />
|
||||
<div className="inv-sphead">Spells ({item.spell_names?.length ?? 0})</div>
|
||||
{(item.spell_names ?? []).map((s, i) => (
|
||||
<div className={`inv-sp${/legendary/i.test(s) ? ' inv-leg' : ''}`} key={i}>{s}</div>
|
||||
))}
|
||||
<div className="inv-hint">↑/↓ next item · Esc close</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build** — green.
|
||||
- [ ] **Step 3: Commit** — `feat(frontend): inventory item detail panel`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Cutover — link + delete old page
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/map/Sidebar.tsx:101`
|
||||
- Delete: `static/inventory.html`, `static/inventory.js`
|
||||
|
||||
- [ ] **Step 1:** In `Sidebar.tsx` line 101 change
|
||||
`<a href="/inventory.html" target="_blank" className="ml-tool-link">🔍 Inv Search</a>`
|
||||
to
|
||||
`<a href="/?view=inventory" target="_blank" className="ml-tool-link">🔍 Inv Search</a>`
|
||||
- [ ] **Step 2:** `git rm static/inventory.html static/inventory.js`
|
||||
- [ ] **Step 3:** Grep for stragglers: `grep -rn "inventory.html" frontend/src static/*.html static/classic docs/superpowers/specs/2026-07-15*.md` — only historical docs/specs may reference it (leave those; they're records). If `static/classic/` links to it, update that link the same way.
|
||||
- [ ] **Step 4: Build** — green.
|
||||
- [ ] **Step 5: Commit** — `feat(frontend)!: replace inventory.html with /?view=inventory (set analysis dropped)`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Deploy + live verification
|
||||
|
||||
- [ ] **Step 1:** `bash deploy-frontend.sh` from the repo root (runs the production build and copies `_build/` into `static/`).
|
||||
- [ ] **Step 2:** `git add static/ && git commit -m "build(frontend): deploy inventory search redesign" && git push origin master`
|
||||
- [ ] **Step 3:** `ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull --ff-only origin master"` (bind mount serves it; no restart).
|
||||
- [ ] **Step 4: Smoke checks** (unauthenticated, from local shell):
|
||||
- `curl -s https://overlord.snakedesert.se/ | grep -o 'assets/index-[^"]*\.js'` — new asset hash present.
|
||||
- `curl -s -o /dev/null -w '%{http_code}' https://overlord.snakedesert.se/inventory.html` — no longer the old page (SPA fallback 200 serving index.html is expected; verify body contains the React root div, not "Inventory Search - Dereth Tracker" title).
|
||||
- [ ] **Step 5: User verification** (requires login — ask the user): open `/?view=inventory`, confirm: jewelry + Invuln + Summon → the known 2 items; Ring slot + Invuln → 56; chips remove correctly; sort by Value; column picker persists after reload; detail panel + arrow keys; a bookmarked URL with filters restores them.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- Spec coverage: every spec bullet maps to a task (routing→3, sidebar groups→4, chips→5, table/sort/columns/paging→5, detail+keyboard→5/6, URL sharing→2, cutover→7, deploy/verify→8). Set analysis intentionally absent.
|
||||
- The `characters: []` edge intentionally surfaces the backend's error message (documented in Task 4 Step 3).
|
||||
- Keyboard handling lives in ResultsTable (needs items+selected); spec said "page" — acceptable placement, noted.
|
||||
- No new deps; no backend changes anywhere.
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
# Weapon OD from Loot Profile — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Replace the OD calculation with the user's VTank loot-profile ladder so `od_rating` is the 0–10 (casters 0–7) integer they see on ident. Spec: `docs/superpowers/specs/2026-07-15-weapon-od-loot-profile-design.md`.
|
||||
|
||||
**Architecture:** Rewrite `go-services/inventory-go/od.go` around an ordered bucket table (below, extracted from `Loot.utl`). `OD = clamp(stat − baseline, 1, cap)` for the first matching bucket. Backfill; the frontend renders integer OD.
|
||||
|
||||
**Tech Stack:** Go 1.25 (server-only tests in `golang:1.25-bookworm`), React (npm build).
|
||||
|
||||
**Working dir for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`
|
||||
|
||||
**Go test command:**
|
||||
```bash
|
||||
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
|
||||
tar czf - go-services | ssh erik@overlord.snakedesert.se \
|
||||
"rm -rf /tmp/tdd-verify && mkdir -p /tmp/tdd-verify && tar xzf - -C /tmp/tdd-verify" && \
|
||||
ssh erik@overlord.snakedesert.se \
|
||||
"docker run --rm -v /tmp/tdd-verify/go-services/inventory-go:/src -w /src golang:1.25-bookworm \
|
||||
sh -c 'go mod tidy >/dev/null 2>&1; go test ./... -run <PATTERN> -v 2>&1'"
|
||||
```
|
||||
|
||||
**Item keys:** object_class from `raw["ObjectClass"]`; skill `IntValues["218103840"]` (melee/missile) / `IntValues["159"]` (casters); mastery `IntValues["353"]`; MaxDamage `IntValues["218103842"]`; missile dmg `IntValues["218103839"]`; elemVsMon `DoubleValues["152"]`; name `raw["Name"]`. Helpers `bag/ivI/dvF/rawI/rawS` exist in process.go.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Rewrite od.go with the loot-profile table (TDD)
|
||||
|
||||
**Files:** rewrite `go-services/inventory-go/od.go`; rewrite `go-services/inventory-go/od_test.go`.
|
||||
|
||||
- [ ] **Step 1: Write tests first** (`od_test.go`, replacing the old content):
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOD_MeleeTetsubo(t *testing.T) {
|
||||
// 2H Cleaving baseline 45, name "Tetsubo", maxdmg 85 → clamp(85-45,1,10)=10.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Tetsubo",
|
||||
"IntValues": map[string]any{"218103840": 41.0, "353": 11.0, "218103842": 85.0}}
|
||||
if od, ok := computeOD(raw); !ok || od != 10 {
|
||||
t.Fatalf("Tetsubo OD=%v ok=%v want 10", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_MeleeMidTier(t *testing.T) {
|
||||
// Heavy Axe baseline 74, name "War Axe", maxdmg 79 → clamp(79-74,1,10)=5.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze War Axe",
|
||||
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 79.0}}
|
||||
if od, ok := computeOD(raw); !ok || od != 5 {
|
||||
t.Fatalf("War Axe OD=%v ok=%v want 5", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_MeleeBelowBaselineNull(t *testing.T) {
|
||||
// Heavy Axe baseline 74, maxdmg 74 → stat-baseline=0 (<1) → null.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze War Axe",
|
||||
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 74.0}}
|
||||
if _, ok := computeOD(raw); ok {
|
||||
t.Fatal("maxdmg == baseline must be null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_MeleeNameGateNull(t *testing.T) {
|
||||
// Heavy Axe bucket requires name in {Silifi,Lugian,War Axe,Battle}. A skill-44
|
||||
// mastery-3 item NOT matching any name and matching no other bucket → null.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze Frobnicator",
|
||||
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 84.0}}
|
||||
if _, ok := computeOD(raw); ok {
|
||||
t.Fatal("name not matching the Heavy Axe filter must be null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_Crossbow(t *testing.T) {
|
||||
// Xbow baseline 77, k839=89 → clamp(89-77,1,10)=10.
|
||||
raw := map[string]any{"ObjectClass": 9.0, "Name": "Fire Compound Crossbow",
|
||||
"IntValues": map[string]any{"218103840": 47.0, "353": 9.0, "218103839": 89.0}}
|
||||
if od, ok := computeOD(raw); !ok || od != 10 {
|
||||
t.Fatalf("Xbow OD=%v ok=%v want 10", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_WarCaster(t *testing.T) {
|
||||
// War caster: elemVsMon 1.40 → clamp(round((1.40-1.18)*100),1,7)=clamp(22,1,7)=7.
|
||||
raw := map[string]any{"ObjectClass": 31.0, "Name": "Diamond Frost Baton",
|
||||
"IntValues": map[string]any{"159": 34.0},
|
||||
"DoubleValues": map[string]any{"152": 1.40}}
|
||||
if od, ok := computeOD(raw); !ok || od != 7 {
|
||||
t.Fatalf("War caster OD=%v ok=%v want 7", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_VoidCasterTier(t *testing.T) {
|
||||
// Void caster: name must contain "Nether"; elemVsMon 1.22 → round((1.22-1.18)*100)=4.
|
||||
raw := map[string]any{"ObjectClass": 31.0, "Name": "Silver Nether Staff",
|
||||
"IntValues": map[string]any{"159": 43.0},
|
||||
"DoubleValues": map[string]any{"152": 1.22}}
|
||||
if od, ok := computeOD(raw); !ok || od != 4 {
|
||||
t.Fatalf("Void caster OD=%v ok=%v want 4", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_BowDeferredNull(t *testing.T) {
|
||||
// Bows (mastery 8) are deferred → always null in this pass.
|
||||
raw := map[string]any{"ObjectClass": 9.0, "Name": "Corsair's Arc",
|
||||
"IntValues": map[string]any{"218103840": 47.0, "353": 8.0, "218103839": 70.0}}
|
||||
if _, ok := computeOD(raw); ok {
|
||||
t.Fatal("bows are deferred → null")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run → RED** (`-run TestOD`): fails (old computeOD signature returns float; these expect int). Build/logic failure expected.
|
||||
|
||||
- [ ] **Step 3: Rewrite `od.go`** (delete ALL old content — table, spell dicts, variance math — and replace):
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// Weapon OD = the user's VirindiTank Loot.utl classification (0–10, casters
|
||||
// 0–7), printed by Mag-Tools on ident. OD = clamp(stat − baseline, 1, cap) for
|
||||
// the first matching bucket, else null. Buckets/baselines transcribed from
|
||||
// Loot.utl (see design doc 2026-07-15-weapon-od-loot-profile).
|
||||
|
||||
type odBucket struct {
|
||||
objectClass int // 1 melee, 9 missile, 31 caster
|
||||
skill int // 218103840 for melee/missile, 159 for casters; 0 = any
|
||||
mastery int // IntValues 353; -1 = not checked
|
||||
nameAlts []string // OR of case-sensitive substrings; empty = no name gate
|
||||
statKey string // IntValues key (melee/missile) or ""
|
||||
dblStatKey string // DoubleValues key (casters) or ""
|
||||
baseline float64
|
||||
cap int
|
||||
}
|
||||
|
||||
// Ordered: name-gated buckets are effectively specific; first match wins.
|
||||
var odBuckets = []odBucket{
|
||||
// --- Melee (object_class 1, stat MaxDamage 218103842, cap 10) ---
|
||||
{1, 44, 3, []string{"Silifi", "Lugian", "War Axe", "Battle"}, "218103842", "", 74, 10}, // (H) Axe
|
||||
{1, 45, 3, []string{"Dolabra", "Ono", "Hand", "War Hammer"}, "218103842", "", 61, 10}, // (L) Axe
|
||||
{1, 46, 3, []string{"Hatchet", "Shou-ono", "Tungi", "Hammer"}, "218103842", "", 61, 10}, // (F) Axe
|
||||
{1, 44, 6, []string{"Stiletto", "Jambiya"}, "218103842", "", 38, 10}, // (H) Dagger MS
|
||||
{1, 44, 6, nil, "218103842", "", 71, 10}, // (H) Dagger
|
||||
{1, 45, 6, nil, "218103842", "", 58, 10}, // (L) Dagger (MS shares names=nil; see note)
|
||||
{1, 46, 6, []string{"Knife", "Lancet"}, "218103842", "", 28, 10}, // (F) Dagger MS
|
||||
{1, 46, 6, nil, "218103842", "", 58, 10}, // (F) Dagger
|
||||
{1, 44, 4, []string{"Mazule", "Mace", "Morning"}, "218103842", "", 69, 10}, // (H) Mace
|
||||
{1, 45, 4, []string{"Club", "Kasrullah"}, "218103842", "", 56, 10}, // (L) Mace
|
||||
{1, 46, 4, []string{"Board", "Tofun", "Dabus"}, "218103842", "", 56, 10}, // (F) Mace
|
||||
{1, 44, 5, []string{"Glaive", "Trident", "Partizan"}, "218103842", "", 72, 10}, // (H) Spear
|
||||
{1, 45, 5, []string{"Spear", "Yari"}, "218103842", "", 60, 10}, // (L) Spear
|
||||
{1, 44, 7, []string{"Nabut", "Stick"}, "218103842", "", 69, 10}, // (H) Staff
|
||||
{1, 45, 7, nil, "218103842", "", 57, 10}, // (L) Staff
|
||||
{1, 46, 7, []string{"Jo", "Bastone"}, "218103842", "", 57, 10}, // (F) Staff
|
||||
{1, 44, 2, []string{"Takuba", "Flamberge", "Ken", "Long", "Tachi"}, "218103842", "", 71, 10}, // (H) Sword
|
||||
{1, 45, 2, []string{"Dericost", "Broad", "Shamshir", "Kaskara", "Spada"}, "218103842", "", 58, 10}, // (L) Sword
|
||||
{1, 46, 2, []string{"Scimitar", "Yaoji", "Short", "Sabra", "Simi"}, "218103842", "", 58, 10}, // (F) Sword
|
||||
{1, 44, 1, []string{"Nekode", "Cestus"}, "218103842", "", 59, 10}, // (H) UA
|
||||
{1, 45, 1, []string{"Katar", "Knuckles"}, "218103842", "", 48, 10}, // (L) UA
|
||||
{1, 46, 1, []string{"Claw", "Wraps"}, "218103842", "", 48, 10}, // (F) UA
|
||||
{1, 41, -1, []string{"Nodachi", "Shashqa", "Spadone", "Greataxe", "Quadrelle", "Khanda-handled", "Tetsubo", "Star"}, "218103842", "", 45, 10}, // (2H) Cleaving
|
||||
{1, 41, -1, []string{"Assagai", "Pike", "Magari", "Corsesca"}, "218103842", "", 48, 10}, // (2H) Spear
|
||||
// --- Crossbow (object_class 9, mastery 9, stat 218103839, cap 10) ---
|
||||
{9, 47, 9, nil, "218103839", "", 77, 10},
|
||||
// --- Casters (object_class 31, stat elemVsMon 152, cap 7) ---
|
||||
{31, 34, -1, []string{"Baton", "Sceptre", "Staff"}, "", "152", 1.18, 7}, // War
|
||||
{31, 43, -1, []string{"Nether"}, "", "152", 1.18, 7}, // Void
|
||||
// Bows (mastery 8) and Thrown (mastery 10) are intentionally ABSENT → null.
|
||||
}
|
||||
|
||||
func nameMatches(name string, alts []string) bool {
|
||||
if len(alts) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, a := range alts {
|
||||
if strings.Contains(name, a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// computeOD returns the loot-profile OD (integer, as float64 for the DB column)
|
||||
// and ok=false when no bucket matches or the stat is at/below baseline.
|
||||
func computeOD(raw map[string]any) (float64, bool) {
|
||||
oc := rawI(raw, "ObjectClass", 0)
|
||||
iv := bag(raw, "IntValues")
|
||||
dv := bag(raw, "DoubleValues")
|
||||
name := rawS(raw, "Name")
|
||||
skillMelee := ivI(iv, "218103840", 0)
|
||||
skillCaster := ivI(iv, "159", 0)
|
||||
mastery := ivI(iv, "353", -999)
|
||||
|
||||
for _, b := range odBuckets {
|
||||
if b.objectClass != oc {
|
||||
continue
|
||||
}
|
||||
sk := skillMelee
|
||||
if b.objectClass == 31 {
|
||||
sk = skillCaster
|
||||
}
|
||||
if b.skill != 0 && b.skill != sk {
|
||||
continue
|
||||
}
|
||||
if b.mastery != -1 && b.mastery != mastery {
|
||||
continue
|
||||
}
|
||||
if !nameMatches(name, b.nameAlts) {
|
||||
continue
|
||||
}
|
||||
var stat float64
|
||||
if b.dblStatKey != "" {
|
||||
stat = dvF(dv, b.dblStatKey, 0)
|
||||
} else {
|
||||
stat = float64(ivI(iv, b.statKey, 0))
|
||||
}
|
||||
od := stat - b.baseline
|
||||
if b.dblStatKey != "" {
|
||||
od = od * 100 // caster elemVsMon: percentage points
|
||||
}
|
||||
odInt := int(od + 0.5) // round
|
||||
if odInt < 1 {
|
||||
return 0, false
|
||||
}
|
||||
if odInt > b.cap {
|
||||
odInt = b.cap
|
||||
}
|
||||
return float64(odInt), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
```
|
||||
|
||||
Note on the (L) Dagger MS bucket: the profile has an `(L) Dagger MS` (baseline 28) with no name filter, which would collide with `(L) Dagger` (baseline 58, also no name). Two nameless same-skill/mastery buckets can't be distinguished by our fields; the plan keeps only `(L) Dagger` (baseline 58) — the more conservative (higher-baseline, fewer false OD10s). Document this as a known minor gap in the commit message.
|
||||
|
||||
- [ ] **Step 4: Run → GREEN** (`-run TestOD`) all pass; then `-run .` whole package + `go vet ./...` clean.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```
|
||||
feat(inventory-go): recompute OD from VTank loot-profile ladder (0-10 / casters 0-7)
|
||||
|
||||
Replaces the UtilityBelt over-retail float with the user's actual in-game OD:
|
||||
per-weapon-type MaxDamage/elemVsMon thresholds from Loot.utl. Bows/thrown
|
||||
deferred (null) pending a computed-stat calibration. (L) Dagger MS bucket
|
||||
folded into (L) Dagger (indistinguishable by captured fields).
|
||||
|
||||
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend integer rendering
|
||||
|
||||
**Files:** `frontend/src/components/inventory/ResultsTable.tsx`, `DetailPanel.tsx`.
|
||||
|
||||
- [ ] **Step 1:** In `ResultsTable.tsx` `cell()` `case 'od_rating'`, render integer:
|
||||
```ts
|
||||
case 'od_rating': {
|
||||
const v = item.od_rating;
|
||||
return v == null ? '—' : String(v);
|
||||
}
|
||||
```
|
||||
- [ ] **Step 2:** In `DetailPanel.tsx`, change the OD row to plain integer:
|
||||
```tsx
|
||||
{item.od_rating != null && <Row k="Weapon OD" v={String(item.od_rating)} />}
|
||||
```
|
||||
- [ ] **Step 3:** Build `cd frontend && npm run build`; delete `static/_build`. Commit:
|
||||
```
|
||||
feat(frontend): render weapon OD as integer (0-10 loot-profile scale)
|
||||
|
||||
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Deploy backend + backfill + verify
|
||||
|
||||
- [ ] **Step 1:** Sync + test-gated build + recreate inventory-go (cutover override) — standard flow from CLAUDE.md.
|
||||
- [ ] **Step 2:** Backfill: `ssh erik@overlord.snakedesert.se "curl -s -X POST http://127.0.0.1:8772/admin/backfill-od"`. Expect `{scanned, updated}`.
|
||||
- [ ] **Step 3:** Verify distribution is 0–10 / casters ≤7 and spot-check known items:
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "docker exec inventory-db psql -U inventory_user -d inventory_db -tc \"SELECT min(od_rating),max(od_rating),count(*) FILTER (WHERE od_rating IS NOT NULL) FROM items WHERE object_class IN (1,9,31);\""
|
||||
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8770/inv/search/items?include_all_characters=true&weapon_only=true&min_od=1&sort_by=od&sort_dir=desc&limit=10' | python3 -c \"import json,sys;d=json.load(sys.stdin);[print(i.get('od_rating'),'|',i['name']) for i in d['items']]\""
|
||||
```
|
||||
Expect max = 10, casters max 7, Tetsubo = 10.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Deploy frontend + user verify
|
||||
|
||||
- [ ] `bash deploy-frontend.sh`, commit `static/`+`frontend/`, push, server `git pull` (handle the tar-sync working-tree blocker as before: verify blockers match origin, checkout/rm, re-pull).
|
||||
- [ ] **User check:** ID a few weapons in-game and confirm the site's OD column matches the grey OD text (melee + crossbows + casters). Bows/thrown show `—` (deferred) — confirm that's acceptable and, if wanted, provide one bow OD reading to finish them.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
- Melee 25 buckets + xbow + 2 casters implemented; bows/thrown null (documented).
|
||||
- (L) Dagger MS folded into (L) Dagger — noted.
|
||||
- Old variance/retail od.go fully deleted.
|
||||
- od_rating stays the same column/param/sort; only values + rendering change.
|
||||
|
|
@ -1,388 +0,0 @@
|
|||
# Weapon OD Rating Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Compute a Mag-Tools "OD" (over-damage vs best retail) rating for melee/missile/wand weapons at ingest, store it on `items.od_rating`, backfill existing rows, and expose `min_od`/`max_od` search filters + an OD column/detail row in the React inventory search.
|
||||
|
||||
**Architecture:** New `inventory-go/od.go` ports UtilityBelt's OD calc (`UtilityBelt/UtilityBelt/Tools/ItemInfo.cs` + `Lib/ItemInfoHelper/{WeaponMods,MiscCalcs,Dictionaries}.cs`, all in this workspace). `processItem` calls it and writes `od_rating` into the `items` map (the dynamic `buildInsert` in `ingest.go` picks it up with no other change). A backfill endpoint recomputes over stored `item_raw_data.original_json`. Search adds `min_od`/`max_od`/sort. Frontend adds one rating + one column + one detail row. Spec: `docs/superpowers/specs/2026-07-15-weapon-od-rating-design.md`.
|
||||
|
||||
**Tech Stack:** Go 1.25 (backend); React 19 + TS + Vite (frontend). Go has no local toolchain — tests run in a throwaway `golang:1.25-bookworm` container on `overlord.snakedesert.se`; the Dockerfile's `RUN go test ./...` gates the image build. Frontend build = `npm run build` locally.
|
||||
|
||||
**Working dir for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`
|
||||
|
||||
**Go test command (sync + run in container):**
|
||||
```bash
|
||||
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
|
||||
tar czf - go-services | ssh erik@overlord.snakedesert.se \
|
||||
"rm -rf /tmp/tdd-verify && mkdir -p /tmp/tdd-verify && tar xzf - -C /tmp/tdd-verify" && \
|
||||
ssh erik@overlord.snakedesert.se \
|
||||
"docker run --rm -v /tmp/tdd-verify/go-services/inventory-go:/src -w /src golang:1.25-bookworm \
|
||||
sh -c 'go mod tidy >/dev/null 2>&1; go test ./... -run <PATTERN> -v 2>&1'"
|
||||
```
|
||||
|
||||
**Key facts (verified against live data + source):**
|
||||
- Raw item passed to `processItem(raw)`: `raw["IntValues"]`, `raw["DoubleValues"]` are `map[string]any` (JSON number values are `float64`); `raw["Spells"]` / `raw["ActiveSpells"]` are arrays. Helpers `bag`, `ivI`, `dvF`, `toIntList` already exist in `process.go`.
|
||||
- Int keys: `159` EquipSkill, `353` Mastery, `160` WieldReqValue, `218103842` MaxDamage, `204` ElementalDmgBonus, `171` Tinks, `179` Imbued (nonzero ⇒ imbued), `131` Material, `47` WeaponType(masterid).
|
||||
- Double keys (in DoubleValues): `167772171` Variance, `167772174` DamageBonus, `152` ElementalDamageVsMonsters, `167772169` SalvageWorkmanship.
|
||||
- `object_class`: 1 melee, 9 missile, 31 wand/caster.
|
||||
- Best-values table + spell-effect dicts: transcribe from the workspace source files named above — DO NOT invent values.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: OD calculation core (`od.go`) — TDD
|
||||
|
||||
**Files:**
|
||||
- Create: `go-services/inventory-go/od.go`
|
||||
- Create: `go-services/inventory-go/od_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests.** Create `od_test.go`. Golden values are computed by hand from the formulas + the workspace table; verify each against the source before trusting.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func approx(a, b float64) bool { return math.Abs(a-b) < 0.01 }
|
||||
|
||||
// Wand: OD = (buffedElemVsMonsters - tableMaxElemVsMonsters) * 100.
|
||||
// Live "Frost Baton": skill 34 (war), wieldreq 385, ElemVsMonsters 1.40,
|
||||
// no spirit-drinker spells. Table max for (34,*,*,385) = 1.18 → OD = +22.
|
||||
func TestOD_Wand(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"ObjectClass": 31.0,
|
||||
"IntValues": map[string]any{"159": 34.0, "160": 385.0},
|
||||
"DoubleValues": map[string]any{"152": 1.40, "167772169": 8.0},
|
||||
"Spells": []any{}, "ActiveSpells": []any{},
|
||||
}
|
||||
od, ok := computeOD(raw)
|
||||
if !ok || !approx(od, 22.0) {
|
||||
t.Fatalf("wand OD = %v ok=%v, want +22", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-loot (no workmanship) → no OD.
|
||||
func TestOD_NonLootNull(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"ObjectClass": 31.0,
|
||||
"IntValues": map[string]any{"159": 34.0, "160": 385.0},
|
||||
"DoubleValues": map[string]any{"152": 1.40},
|
||||
"Spells": []any{}, "ActiveSpells": []any{},
|
||||
}
|
||||
if _, ok := computeOD(raw); ok {
|
||||
t.Fatal("no-workmanship item must have no OD")
|
||||
}
|
||||
}
|
||||
|
||||
// Table lookup miss (unknown tier) → no OD.
|
||||
func TestOD_UnknownTierNull(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"ObjectClass": 31.0,
|
||||
"IntValues": map[string]any{"159": 34.0, "160": 999.0},
|
||||
"DoubleValues": map[string]any{"152": 1.40, "167772169": 8.0},
|
||||
"Spells": []any{}, "ActiveSpells": []any{},
|
||||
}
|
||||
if _, ok := computeOD(raw); ok {
|
||||
t.Fatal("unknown wieldreq tier must have no OD")
|
||||
}
|
||||
}
|
||||
|
||||
// Melee: OD = buffedMaxDmg - varianceTinks - tableMaxDmg,
|
||||
// varianceTinks = round(log(tableMaxVar/variance)/log(0.8), 2).
|
||||
// Heavy (44) sword (2) non-MS, wieldreq 430: tableMaxDmg=71, tableMaxVar=0.47.
|
||||
// A perfect retail-max item: maxDmg 71, variance 0.47 → varianceTinks=0 → OD 0.
|
||||
func TestOD_MeleeRetailMaxIsZero(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"ObjectClass": 1.0,
|
||||
"IntValues": map[string]any{"159": 44.0, "353": 2.0, "160": 430.0, "218103842": 71.0},
|
||||
"DoubleValues": map[string]any{"167772171": 0.47, "167772169": 10.0},
|
||||
"Spells": []any{}, "ActiveSpells": []any{},
|
||||
}
|
||||
od, ok := computeOD(raw)
|
||||
if !ok || !approx(od, 0.0) {
|
||||
t.Fatalf("melee retail-max OD = %v ok=%v, want 0", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Melee with tighter variance (0.376, one granite tink BETTER than the 0.47 baseline):
|
||||
// varianceTinks = round(log(0.47/0.376)/log(0.8), 2) = -1.00 → OD = 71-(-1)-71 = +1.
|
||||
// (Table MaxVar is the type's baseline variance; lower variance = higher OD.)
|
||||
func TestOD_MeleeOneTinkVariance(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"ObjectClass": 1.0,
|
||||
"IntValues": map[string]any{"159": 44.0, "353": 2.0, "160": 430.0, "218103842": 71.0},
|
||||
"DoubleValues": map[string]any{"167772171": 0.376, "167772169": 10.0},
|
||||
"Spells": []any{}, "ActiveSpells": []any{},
|
||||
}
|
||||
od, ok := computeOD(raw)
|
||||
if !ok || !approx(od, 1.0) {
|
||||
t.Fatalf("melee OD = %v ok=%v, want +1", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Melee buffed: innate Legendary Blood Thirst (id 6089, +10 MaxDamage).
|
||||
// maxDmg 61 + 10 = 71, variance 0.47 → OD 0 (vs 71 table).
|
||||
func TestOD_MeleeBuffedByBloodThirst(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"ObjectClass": 1.0,
|
||||
"IntValues": map[string]any{"159": 44.0, "353": 2.0, "160": 430.0, "218103842": 61.0},
|
||||
"DoubleValues": map[string]any{"167772171": 0.47, "167772169": 10.0},
|
||||
"Spells": []any{6089.0}, "ActiveSpells": []any{},
|
||||
}
|
||||
od, ok := computeOD(raw)
|
||||
if !ok || !approx(od, 0.0) {
|
||||
t.Fatalf("melee buffed OD = %v ok=%v, want 0", od, ok)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests, verify they fail** with `undefined: computeOD` (pattern `TestOD`).
|
||||
|
||||
- [ ] **Step 3: Implement `od.go`.** Structure:
|
||||
- A `bestValue` struct `{maxDmg, maxVar, maxDmgMod, maxElemBonus, maxElemVsMon float64}` and a lookup `map[bestKey]bestValue` keyed by `bestKey{skill, mastery, multiStrike, wieldReq int}`, transcribed from `UtilityBelt/.../WeaponMods.cs` — EVERY row (heavy/light/finesse/two-handed/missile/wand regions). Column order in the source `Rows.Add` is `(Skill, Mastery, MultiStrike, WieldReq, MaxDmg, MaxVar, MaxDmgMod, MaxElementalDmgBonus, MaxElementalDmgVsMonsters)`; rows that omit trailing columns default them to 0. Melee rows use `(skill,mastery,ms,wieldreq,maxdmg,maxvar)`; missile rows use `...,maxDmgMod(col7)=0? no` — READ the source: missile rows are `(47, mastery, 0, wieldreq, 0,0, MaxDmgMod-col? )`. Actually missile rows fill `MaxElementalDmgBonus` (col 8) — e.g. `table.Rows.Add(47, 8, 0, 0, 0, 0, 110, 0)` means MaxDmg=0,MaxVar=0,MaxDmgMod=110,MaxElemBonus=0. Wand rows fill col 9 (MaxElementalDmgVsMonsters), e.g. `(34,0,0,290,0,0,0,0,1.03)`. Map each `Rows.Add(...)` positionally onto the struct fields; verify with a couple of spot values in a test comment.
|
||||
- Spell-effect maps transcribed from `UtilityBelt/.../Dictionaries.cs`:
|
||||
- `maxDamageSpellBonus map[int]int`: 1616:20, 2096:22, 5183:24, 4395:24, 2598:2, 2586:4, 4661:7, 6089:10, 3688:300.
|
||||
- `elemVsMonSpellBonus map[int]float64`: 3258:.06, 3259:.07, 5182:.08, 4414:.08, 3251:.01, 3250:.03, 4670:.05, 6098:.07.
|
||||
- `buffedMaxDamage(raw, iv)`: `ivI(iv,"218103842",0)` + Σ bonus over innate `raw["Spells"]` − Σ bonus over `raw["ActiveSpells"]`, using `maxDamageSpellBonus`.
|
||||
- `buffedElemVsMon(raw, dv)`: `dvF(dv,"152",0)` + Σ over Spells − Σ over ActiveSpells using `elemVsMonSpellBonus`.
|
||||
- MultiStrike detection: `iv[47] ∈ {160,166,486}` OR (`iv[47]==4` AND `iv[353]==11`). (If key 47 absent, ms=0.)
|
||||
- `computeOD(raw) (float64, bool)`:
|
||||
- `oc := rawI(raw,"ObjectClass",0)`; only 1/9/31 proceed, else `(0,false)`.
|
||||
- Gate: `dvF(dv,"167772169",0) > 0` else `(0,false)`.
|
||||
- Look up `bestValue` by `{skill=ivI(iv,"159",0), mastery=ivI(iv,"353",0), multiStrike, wieldReq=ivI(iv,"160",0)}`; miss → `(0,false)`.
|
||||
- Melee (oc==1): `variance := dvF(dv,"167772171",0)`; if variance<=0 → `(0,false)`; `varianceTinks := round(math.Log(bv.maxVar/variance)/math.Log(0.8), 2)`; `od := float64(buffedMaxDamage) - varianceTinks - bv.maxDmg`.
|
||||
- Missile (oc==9): `arrowMax` by mastery 8→40,9→53,10→42 (else 0); `tinks := ivI(iv,"171",0)`; `remaining := 10 - tinks; if tinks==0 { remaining = 9 } else if imbued==0 { remaining-- }; if remaining<0 {remaining=0}` where `imbued := ivI(iv,"179",0)`; `dmgMod := dvF(dv,"167772174",0)*100 - 100`; `bDmg := float64(buffedMaxDamage); if bDmg<=10 { bDmg+=24 }`; `bElem := float64(ivI(iv,"204",0))` (no elem spell buff needed for missile per UB); `maxMod := (bv.maxDmgMod + 136)/100`; `od := (1 + (dmgMod + 4*float64(remaining))/100) * (bElem + bDmg + arrowMax) / maxMod - (bv.maxElemBonus + 24 + arrowMax)`.
|
||||
- Wand (oc==31): `od := (buffedElemVsMon - bv.maxElemVsMon) * 100`.
|
||||
- Return `round(od,2), true`. Provide a local `round2(x float64) float64` = `math.Round(x*100)/100`.
|
||||
|
||||
- [ ] **Step 4: Run tests, verify PASS** (pattern `TestOD`), then whole package (`-run .`) green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add go-services/inventory-go/od.go go-services/inventory-go/od_test.go
|
||||
git commit -m "feat(inventory-go): Mag-Tools OD weapon rating calculation (od.go)"
|
||||
```
|
||||
(append `Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>`)
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Wire OD into ingest + schema
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-services/inventory-go/process.go` (in `processItem`, after the `items` map is built ~line 137, before combat)
|
||||
- Modify: `go-services/inventory-go/schema.go` (items table DDL)
|
||||
|
||||
- [ ] **Step 1: Set od_rating in processItem.** After the `items := map[string]any{...}` literal closes, add:
|
||||
```go
|
||||
if od, ok := computeOD(raw); ok {
|
||||
items["od_rating"] = od
|
||||
}
|
||||
```
|
||||
(When not ok, the key is absent → buildInsert omits it → column stays NULL. Correct.)
|
||||
|
||||
- [ ] **Step 2: Add the column to schema.go.** Find the `CREATE TABLE ... items (` DDL and add `od_rating DOUBLE PRECISION,` alongside the other nullable numeric columns. (Fresh installs only; prod uses SKIP_SCHEMA_INIT and gets the manual ALTER in Task 5.)
|
||||
|
||||
- [ ] **Step 3: Build/test** — full package green (`-run .`). No new test needed here (Task 1 covers the math; ingest wiring is exercised in Task 5 live).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add go-services/inventory-go/process.go go-services/inventory-go/schema.go
|
||||
git commit -m "feat(inventory-go): store od_rating at ingest + schema column"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Backfill endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-services/inventory-go/ingest.go` (add handler) and `go-services/inventory-go/main.go` (register route)
|
||||
|
||||
- [ ] **Step 1: Add handler in ingest.go:**
|
||||
```go
|
||||
// POST /admin/backfill-od — recompute od_rating for all weapon rows from stored
|
||||
// raw JSON. Internal-only (service is bound to loopback/compose). Idempotent.
|
||||
func (s *Server) handleBackfillOD(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT rd.item_id, rd.original_json FROM item_raw_data rd
|
||||
JOIN items i ON i.id = rd.item_id
|
||||
WHERE i.object_class IN (1,9,31)`)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
type upd struct {
|
||||
id int
|
||||
od float64
|
||||
}
|
||||
var updates []upd
|
||||
scanned := 0
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var oj map[string]any
|
||||
if err := rows.Scan(&id, &oj); err != nil {
|
||||
rows.Close()
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
scanned++
|
||||
if od, ok := computeOD(oj); ok {
|
||||
updates = append(updates, upd{id, od})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
updated := 0
|
||||
for _, u := range updates {
|
||||
if _, err := s.pool.Exec(ctx, "UPDATE items SET od_rating=$1 WHERE id=$2", u.od, u.id); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "updated": updated})
|
||||
return
|
||||
}
|
||||
updated++
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"scanned": scanned, "updated": updated})
|
||||
}
|
||||
```
|
||||
Note: pgx scans a `jsonb` column into `map[string]any` directly; `computeOD` accepts exactly that shape (numbers as `float64`), same as ingest.
|
||||
|
||||
- [ ] **Step 2: Register in main.go** next to the other `mux.HandleFunc("POST /...")` lines:
|
||||
```go
|
||||
mux.HandleFunc("POST /admin/backfill-od", srv.handleBackfillOD)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build/test** green (`-run .`).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add go-services/inventory-go/ingest.go go-services/inventory-go/main.go
|
||||
git commit -m "feat(inventory-go): POST /admin/backfill-od recompute endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Search filter + sort + column
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-services/inventory-go/search.go`
|
||||
|
||||
- [ ] **Step 1: Expose od_rating in the CTE.** In `cteSelect`, add `i.od_rating,` to the selected columns (near `i.value, i.burden`). It flows into the row maps and out to JSON automatically (queryRowsAsMaps).
|
||||
|
||||
- [ ] **Step 2: Add the filters.** `geFilters` (search.go ~line 209) uses `strconv.ParseFloat` and `leFilters` (~line 230) uses int-only `qInt`. OD can be negative and fractional, so:
|
||||
- Add `min_od` to the `geFilters` slice: `{"min_od", "od_rating"}` (float path — correct).
|
||||
- Do NOT add `max_od` to `leFilters` (int-only would truncate negatives/decimals). Instead add a float block right after the existing `min_attack_bonus` block (~line 239), mirroring it:
|
||||
```go
|
||||
if v := q.Get("max_od"); v != "" {
|
||||
if n, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
conds = append(conds, "od_rating <= "+ab.add(n))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the sort key.** In `sortMapping`, add `"od": "od_rating",`.
|
||||
|
||||
- [ ] **Step 4: Build/test** green (`-run .`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add go-services/inventory-go/search.go
|
||||
git commit -m "feat(inventory-go): min_od/max_od search filters + od sort"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Deploy backend + backfill + live verify
|
||||
|
||||
- [ ] **Step 1: Manual ALTER on the live inventory DB** (prod skips schema init):
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "docker exec inventory-db psql -U inventory_user -d inventory_db -c 'ALTER TABLE items ADD COLUMN IF NOT EXISTS od_rating double precision;'"
|
||||
```
|
||||
- [ ] **Step 2: Sync + build + recreate inventory-go** (test-gated build):
|
||||
```bash
|
||||
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
|
||||
tar czf - go-services | ssh erik@overlord.snakedesert.se "tar xzf - -C /home/erik/MosswartOverlord/"
|
||||
ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \
|
||||
export BUILD_VERSION="$(date -u +%Y.%-m.%-d.%H%M)-$(git rev-parse --short HEAD)" && \
|
||||
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml build --build-arg BUILD_VERSION=$BUILD_VERSION inventory-go && \
|
||||
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml -f go-services/docker-compose.cutover.yml up -d --no-deps inventory-go'
|
||||
```
|
||||
- [ ] **Step 3: Run the backfill:**
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "curl -s -X POST http://127.0.0.1:8772/admin/backfill-od"
|
||||
```
|
||||
Expected: `{"scanned":<n>,"updated":<m>}` with m > 0.
|
||||
- [ ] **Step 4: Verify the known Frost Baton (+22) and a sort:**
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8772/search/items?include_all_characters=true&weapon_only=true&min_od=20&sort_by=od&sort_dir=desc&limit=10'" \
|
||||
| python3 -c "import json,sys; d=json.load(sys.stdin); print('total:',d['total_count']); [print(i['name'],'|',i.get('object_class_name'),'|',i.get('od_rating')) for i in d['items']]"
|
||||
```
|
||||
Expected: results include the Frost Baton with od_rating ≈ 22; all listed od_rating ≥ 20, descending.
|
||||
- [ ] **Step 5: Sanity — melee spot check** (pick a heavy sword the user knows in-game; compare od_rating sign to its Mag-Tools OD). Non-weapon search unaffected:
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8772/search/items?include_all_characters=true&jewelry_only=true&limit=1' | python3 -c \"import json,sys;d=json.load(sys.stdin);print('jewelry total',d['total_count'])\""
|
||||
```
|
||||
- [ ] **Step 6: Commit** (no code change; this task is operational). If a git commit is desired for the version bump it happens via the frontend push later.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend — filter + column + detail row
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/inventory/constants.ts`
|
||||
- Modify: `frontend/src/components/inventory/types.ts`
|
||||
- Modify: `frontend/src/components/inventory/DetailPanel.tsx`
|
||||
|
||||
- [ ] **Step 1: types.ts** — add to `InvItem`:
|
||||
```ts
|
||||
od_rating?: number | null;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: constants.ts** — add an OD rating filter and column. In `RATING_DEFS`, add as the first entry (so it appears with the common ratings):
|
||||
```ts
|
||||
{ param: 'min_od', label: 'Weapon OD', common: true },
|
||||
```
|
||||
In `COLUMNS`, add after `max_damage`:
|
||||
```ts
|
||||
{ key: 'od_rating', label: 'OD', sortKey: 'od', defaultVisible: false },
|
||||
```
|
||||
|
||||
- [ ] **Step 3: ResultsTable cell rendering.** In `ResultsTable.tsx` `cell()`, add a case so OD shows signed with 2 decimals and `—` for null:
|
||||
```ts
|
||||
case 'od_rating': {
|
||||
const v = item.od_rating;
|
||||
if (v == null) return '—';
|
||||
return v > 0 ? `+${v.toFixed(2)}` : v.toFixed(2);
|
||||
}
|
||||
```
|
||||
(Place it alongside the other explicit cases, before `default`.)
|
||||
|
||||
- [ ] **Step 4: DetailPanel.tsx** — add an OD row (only when present) after Max damage:
|
||||
```tsx
|
||||
{item.od_rating != null && <Row k="Weapon OD" v={item.od_rating > 0 ? `+${item.od_rating.toFixed(2)}` : item.od_rating.toFixed(2)} />}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build green:** `cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord/frontend" && npm run build`; delete `static/_build` after.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
git add frontend/src/components/inventory/
|
||||
git commit -m "feat(frontend): weapon OD rating filter, column, and detail row"
|
||||
```
|
||||
|
||||
Note: `min_od` chip label comes free via `RATING_DEFS` in `ActiveChips` ("Weapon OD ≥ N"); the rating sidebar input is auto-generated from `RATING_DEFS`. No changes needed in FilterSidebar/ActiveChips.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Deploy frontend + final verify
|
||||
|
||||
- [ ] **Step 1:** `bash deploy-frontend.sh` from repo root.
|
||||
- [ ] **Step 2:** `git add static/ frontend/ && git commit -m "build(frontend): deploy weapon OD rating" && git push origin master`
|
||||
- [ ] **Step 3:** `ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull --ff-only origin master"`
|
||||
- [ ] **Step 4: User verification** (login-gated, ask the user): open `/?view=inventory`, select Weapons, enter Weapon OD ≥ 10, confirm high-OD weapons appear; add the OD column via the picker; sort by OD; click a weapon and confirm the OD row in the detail panel; confirm a known good weapon's OD roughly matches its in-game Mag-Tools OD.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
- Spec coverage: melee/missile/wand formulas (Task 1), ingest+schema (2), backfill (3), filters+sort (4), deploy+backfill (5), UI (6), deploy (7). All spec bullets covered.
|
||||
- The best-values table + spell dicts are transcribed from named in-repo source files, not invented — reviewer must diff against those files.
|
||||
- `min_od` is a float filter (negative values valid) — placed in the ParseFloat `geFilters` path, not the int `leFilters` path. `max_od` likewise should accept floats: if `leFilters` is int-only (`qInt`), add `max_od` via the float `min_attack_bonus`-style manual block instead. Implementer: check `leFilters`'s parse path and use a float path for `max_od`.
|
||||
|
|
@ -1,326 +0,0 @@
|
|||
# Weapon-type (by skill) filter + OD column Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Multi-select weapon-type filter by skill (Heavy/Light/Finesse/Two-Handed/Missile/War/Void), and auto-show the OD column on weapon searches.
|
||||
|
||||
**Architecture:** Backend reworks `weaponTypeClause` to be object-class/key aware and adds a `weapon_types` CSV param (OR of per-type clauses) in `runSearch`; legacy `weapon_type` kept. Frontend changes `weaponType: string` → `weaponTypes: string[]` (multi-select checkboxes), redefines `WEAPON_TYPES` to seven skill categories, and unions `od_rating` into the visible columns when item type is Weapons. Spec: `docs/superpowers/specs/2026-07-15-weapon-type-skill-filter-design.md`.
|
||||
|
||||
**Tech Stack:** Go 1.25 (backend, no local toolchain — test in `golang:1.25-bookworm` on the server); React 19 + Vite (`npm run build` locally).
|
||||
|
||||
**Working dir for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`
|
||||
|
||||
**Go test command:**
|
||||
```bash
|
||||
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
|
||||
tar czf - go-services | ssh erik@overlord.snakedesert.se \
|
||||
"rm -rf /tmp/tdd-verify && mkdir -p /tmp/tdd-verify && tar xzf - -C /tmp/tdd-verify" && \
|
||||
ssh erik@overlord.snakedesert.se \
|
||||
"docker run --rm -v /tmp/tdd-verify/go-services/inventory-go:/src -w /src golang:1.25-bookworm \
|
||||
sh -c 'go mod tidy >/dev/null 2>&1; go test ./... -run <PATTERN> -v 2>&1'"
|
||||
```
|
||||
|
||||
**Verified field keys:** melee `object_class=1` + `int_values->>'218103840'` (44/45/46/41); missile `object_class=9` + `218103840=47`, mastery `353` (8 bow/9 xbow/10 thrown); wand `object_class=31` + equip skill `int_values->>'159'` (34 war/43 void).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — reworked weaponTypeClause + weapon_types param (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-services/inventory-go/search.go` (`weaponTypeClause` ~line 545; the `weapon_only` case ~line 193)
|
||||
- Create: `go-services/inventory-go/search_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests.** Create `search_test.go`:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWeaponTypeClause(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"heavy": {"object_class = 1", "'218103840')::int = 44"},
|
||||
"light": {"object_class = 1", "= 45"},
|
||||
"finesse": {"object_class = 1", "= 46"},
|
||||
"two_handed": {"object_class = 1", "= 41"},
|
||||
"missile": {"object_class = 9", "'218103840')::int = 47"},
|
||||
"bow": {"object_class = 9", "'218103840')::int = 47", "'353')::int = 8"},
|
||||
"crossbow": {"object_class = 9", "'353')::int = 9"},
|
||||
"thrown": {"object_class = 9", "'353')::int = 10"},
|
||||
"war": {"object_class = 31", "'159')::int = 34"},
|
||||
"void": {"object_class = 31", "'159')::int = 43"},
|
||||
"caster": {"object_class = 31"},
|
||||
}
|
||||
for wt, subs := range cases {
|
||||
got := weaponTypeClause(wt)
|
||||
for _, s := range subs {
|
||||
if !strings.Contains(got, s) {
|
||||
t.Errorf("weaponTypeClause(%q) = %q, missing %q", wt, got, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := weaponTypeClause("nonsense"); !strings.Contains(got, "object_class IN (1, 9, 31)") {
|
||||
t.Errorf("unknown type = %q, want all-weapons fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeaponOnlyClause(t *testing.T) {
|
||||
// No types → all weapons.
|
||||
if got := weaponOnlyClause(url.Values{}); !strings.Contains(got, "object_class IN (1, 9, 31)") {
|
||||
t.Errorf("empty = %q, want all weapons", got)
|
||||
}
|
||||
// Single type via weapon_types.
|
||||
got := weaponOnlyClause(url.Values{"weapon_types": {"heavy"}})
|
||||
if !strings.Contains(got, "= 44") || strings.Contains(got, " OR ") {
|
||||
t.Errorf("single = %q, want just heavy, no OR", got)
|
||||
}
|
||||
// Multiple → parenthesized OR, one clause per type.
|
||||
got = weaponOnlyClause(url.Values{"weapon_types": {"heavy,two_handed,war"}})
|
||||
if !strings.HasPrefix(got, "(") || !strings.HasSuffix(got, ")") ||
|
||||
strings.Count(got, " OR ") != 2 ||
|
||||
!strings.Contains(got, "= 44") || !strings.Contains(got, "= 41") || !strings.Contains(got, "= 34") {
|
||||
t.Errorf("multi = %q, want (heavy OR two_handed OR war)", got)
|
||||
}
|
||||
// Legacy weapon_type still honored, deduped against weapon_types.
|
||||
got = weaponOnlyClause(url.Values{"weapon_type": {"missile"}})
|
||||
if !strings.Contains(got, "object_class = 9") {
|
||||
t.Errorf("legacy weapon_type = %q, want missile", got)
|
||||
}
|
||||
got = weaponOnlyClause(url.Values{"weapon_types": {"heavy"}, "weapon_type": {"heavy"}})
|
||||
if strings.Contains(got, " OR ") {
|
||||
t.Errorf("dedup = %q, want single heavy clause", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests → RED** (`-run 'TestWeaponTypeClause|TestWeaponOnlyClause'`): `undefined: weaponOnlyClause` build failure.
|
||||
|
||||
- [ ] **Step 3: Implement.** Replace the whole `weaponTypeClause` func (~545-575) with:
|
||||
|
||||
```go
|
||||
func weaponTypeClause(wt string) string {
|
||||
meleeSkill := func(skill int) string {
|
||||
return fmt.Sprintf("(object_class = 1 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = %d))", skill)
|
||||
}
|
||||
missileMastery := func(mastery int) string {
|
||||
return fmt.Sprintf("(object_class = 9 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = 47 AND (wrd.int_values->>'353')::int = %d))", mastery)
|
||||
}
|
||||
casterSkill := func(skill int) string {
|
||||
return fmt.Sprintf("(object_class = 31 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'159')::int = %d))", skill)
|
||||
}
|
||||
switch strings.ToLower(wt) {
|
||||
case "heavy":
|
||||
return meleeSkill(44)
|
||||
case "light":
|
||||
return meleeSkill(45)
|
||||
case "finesse":
|
||||
return meleeSkill(46)
|
||||
case "two_handed":
|
||||
return meleeSkill(41)
|
||||
case "missile":
|
||||
return "(object_class = 9 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = 47))"
|
||||
case "bow":
|
||||
return missileMastery(8)
|
||||
case "crossbow":
|
||||
return missileMastery(9)
|
||||
case "thrown":
|
||||
return missileMastery(10)
|
||||
case "war":
|
||||
return casterSkill(34)
|
||||
case "void":
|
||||
return casterSkill(43)
|
||||
case "caster":
|
||||
return "object_class = 31"
|
||||
default:
|
||||
return "object_class IN (1, 9, 31)"
|
||||
}
|
||||
}
|
||||
|
||||
// weaponOnlyClause builds the WHERE fragment for weapon_only searches: an OR of
|
||||
// the per-type clauses named in weapon_types (CSV) plus the legacy single
|
||||
// weapon_type. Empty selection = all weapons. Types are deduped.
|
||||
func weaponOnlyClause(q url.Values) string {
|
||||
var types []string
|
||||
types = append(types, splitNonEmpty(q.Get("weapon_types"))...)
|
||||
if wt := strings.TrimSpace(q.Get("weapon_type")); wt != "" {
|
||||
types = append(types, wt)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var clauses []string
|
||||
for _, t := range types {
|
||||
key := strings.ToLower(strings.TrimSpace(t))
|
||||
if key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
clauses = append(clauses, weaponTypeClause(key))
|
||||
}
|
||||
if len(clauses) == 0 {
|
||||
return "object_class IN (1, 9, 31)"
|
||||
}
|
||||
if len(clauses) == 1 {
|
||||
return clauses[0]
|
||||
}
|
||||
return "(" + strings.Join(clauses, " OR ") + ")"
|
||||
}
|
||||
```
|
||||
|
||||
Then change the `weapon_only` case (~line 193-194) from
|
||||
`conds = append(conds, weaponTypeClause(q.Get("weapon_type")))`
|
||||
to
|
||||
`conds = append(conds, weaponOnlyClause(q))`.
|
||||
|
||||
(`url` and `strings` and `fmt` and `splitNonEmpty` are already imported/defined in search.go.)
|
||||
|
||||
- [ ] **Step 4: Run tests → GREEN** (`-run 'TestWeapon'`) then whole package (`-run .`) + `go vet ./...` clean.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add go-services/inventory-go/search.go go-services/inventory-go/search_test.go
|
||||
git commit -m "feat(inventory-go): weapon_types multi-select filter by skill (melee/missile/war/void)
|
||||
|
||||
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — multi-select weapon type + auto OD column
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/inventory/constants.ts`
|
||||
- Modify: `frontend/src/components/inventory/types.ts`
|
||||
- Modify: `frontend/src/components/inventory/useInventorySearch.ts`
|
||||
- Modify: `frontend/src/components/inventory/FilterSidebar.tsx`
|
||||
- Modify: `frontend/src/components/inventory/ActiveChips.tsx`
|
||||
- Modify: `frontend/src/components/inventory/ResultsTable.tsx`
|
||||
|
||||
- [ ] **Step 1: constants.ts** — replace the `WEAPON_TYPES` array with the seven skill categories:
|
||||
```ts
|
||||
export const WEAPON_TYPES: Array<{ value: string; label: string }> = [
|
||||
{ value: 'heavy', label: 'Heavy' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'finesse', label: 'Finesse' },
|
||||
{ value: 'two_handed', label: 'Two-Handed' },
|
||||
{ value: 'missile', label: 'Missile' },
|
||||
{ value: 'war', label: 'War Magic' },
|
||||
{ value: 'void', label: 'Void Magic' },
|
||||
];
|
||||
```
|
||||
(Removed the leading `{ value: '', label: 'All weapons' }` — multi-select uses "none checked = all".)
|
||||
|
||||
- [ ] **Step 2: types.ts** — in `SearchFilters` change `weaponType: string;` to `weaponTypes: string[];`; in `DEFAULT_FILTERS` change `weaponType: '',` to `weaponTypes: [],`.
|
||||
|
||||
- [ ] **Step 3: useInventorySearch.ts**
|
||||
- `validateFilterValue`: add a case
|
||||
```ts
|
||||
case 'weaponTypes':
|
||||
return isStringArray(value) ? value : undefined;
|
||||
```
|
||||
(place near the `slots`/`cantrips` case; `isStringArray` already exists.)
|
||||
- `buildParams`: in the `case 'weapon':` block, replace
|
||||
```ts
|
||||
p.set('weapon_only', 'true');
|
||||
if (f.weaponType) p.set('weapon_type', f.weaponType);
|
||||
```
|
||||
with
|
||||
```ts
|
||||
p.set('weapon_only', 'true');
|
||||
if (f.weaponTypes.length) p.set('weapon_types', f.weaponTypes.join(','));
|
||||
```
|
||||
- Anywhere `weaponType` is referenced elsewhere (e.g. `update({ itemType: v, weaponType: '' })` resets) — grep and change to `weaponTypes: []`.
|
||||
|
||||
- [ ] **Step 4: FilterSidebar.tsx** — replace the weapon-type `<select>` (the `{f.itemType === 'weapon' && (<select>...)}` block, ~line 72-77) with a checkbox group:
|
||||
```tsx
|
||||
{f.itemType === 'weapon' && (
|
||||
<div className="inv-subgroup">
|
||||
{WEAPON_TYPES.map(w => (
|
||||
<label className="inv-fitem" key={w.value}>
|
||||
<input type="checkbox" checked={f.weaponTypes.includes(w.value)}
|
||||
onChange={() => update({
|
||||
weaponTypes: f.weaponTypes.includes(w.value)
|
||||
? f.weaponTypes.filter(x => x !== w.value)
|
||||
: [...f.weaponTypes, w.value],
|
||||
})} /> {w.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
Also update the item-type radio `onChange` that currently does `update({ itemType: v, weaponType: '' })` → `update({ itemType: v, weaponTypes: [] })`. Add `.inv-subgroup{margin:2px 0 2px 10px}` to inventory.css.
|
||||
|
||||
- [ ] **Step 5: ActiveChips.tsx** — replace the single item-type/weaponType chip logic. The current block:
|
||||
```ts
|
||||
if (f.itemType !== 'all') chips.push({
|
||||
label: f.itemType[0].toUpperCase() + f.itemType.slice(1) + (f.weaponType ? `: ${f.weaponType}` : ''),
|
||||
remove: () => update({ itemType: 'all', weaponType: '' }),
|
||||
});
|
||||
```
|
||||
becomes:
|
||||
```ts
|
||||
if (f.itemType !== 'all') chips.push({
|
||||
label: f.itemType[0].toUpperCase() + f.itemType.slice(1),
|
||||
remove: () => update({ itemType: 'all', weaponTypes: [] }),
|
||||
});
|
||||
for (const wt of f.weaponTypes) {
|
||||
const label = WEAPON_TYPES.find(w => w.value === wt)?.label ?? wt;
|
||||
chips.push({ label, remove: () => update({ weaponTypes: f.weaponTypes.filter(x => x !== wt) }) });
|
||||
}
|
||||
```
|
||||
Add `import { WEAPON_TYPES } from './constants';` if not present (RATING_DEFS import is already there).
|
||||
|
||||
- [ ] **Step 6: ResultsTable.tsx** — make the OD column auto-visible on weapon searches. Change the `cols` memo:
|
||||
```ts
|
||||
const cols = useMemo(() => {
|
||||
const eff = new Set(visible);
|
||||
if (f.itemType === 'weapon') eff.add('od_rating');
|
||||
return COLUMNS.filter(c => eff.has(c.key));
|
||||
}, [visible, f.itemType]);
|
||||
```
|
||||
(`f` is already destructured from `search`.)
|
||||
|
||||
- [ ] **Step 7: Build** — `cd frontend && npm run build`; delete `static/_build` after. Grep to confirm no stray `weaponType` (singular) references remain: `grep -rn "weaponType\b" frontend/src/components/inventory` should only match `weaponTypes`.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
```bash
|
||||
git add frontend/src/components/inventory/ frontend/src/styles/inventory.css
|
||||
git commit -m "feat(frontend): multi-select weapon-type-by-skill filter + auto OD column
|
||||
|
||||
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Deploy + verify
|
||||
|
||||
- [ ] **Step 1: Backend** — sync + test-gated build + recreate:
|
||||
```bash
|
||||
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
|
||||
tar czf - go-services | ssh erik@overlord.snakedesert.se "tar xzf - -C /home/erik/MosswartOverlord/"
|
||||
ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \
|
||||
export BUILD_VERSION="$(date -u +%Y.%-m.%-d.%H%M)-$(git rev-parse --short HEAD)" && \
|
||||
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml build --build-arg BUILD_VERSION=$BUILD_VERSION inventory-go && \
|
||||
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml -f go-services/docker-compose.cutover.yml up -d --no-deps inventory-go'
|
||||
```
|
||||
- [ ] **Step 2: Frontend** — `bash deploy-frontend.sh`, then `git add static/ frontend/ && git commit -m "build(frontend): deploy weapon-type filter" && git push origin master`.
|
||||
- [ ] **Step 3: Server pull** — `ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull --ff-only origin master"`. If it aborts on the tar-synced working-tree files, verify each blocker matches origin (`git show origin/master:<f> | diff -q - <f>`), `git checkout --`/`rm` them, then re-pull. Confirm the served bundle hash changed.
|
||||
- [ ] **Step 4: Backend live checks** (through the `/inv` proxy the browser uses):
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "for t in heavy two_handed missile war void; do echo -n \"\$t: \"; curl -s \"http://127.0.0.1:8770/inv/search/items?include_all_characters=true&weapon_only=true&weapon_types=\$t&limit=1\" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d[\"total_count\"])'; done"
|
||||
```
|
||||
Expected: each returns a plausible non-zero count. Then a multi-select + no-Bowl check:
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8770/inv/search/items?include_all_characters=true&weapon_only=true&weapon_types=missile&limit=200' | python3 -c \"import json,sys;d=json.load(sys.stdin);names=[i['name'] for i in d['items']];print('missile total',d['total_count'],'| any Bowl?', any('Bowl' in n for n in names))\""
|
||||
```
|
||||
Expected: total > 0, `any Bowl? False`.
|
||||
- [ ] **Step 5: User verification** (login-gated — ask the user): open `/?view=inventory`, select Weapons, check Heavy + Two-Handed, confirm results; confirm the OD column shows automatically; check War Magic / Void Magic return casters; chips add/remove per type.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
- Spec coverage: multi-select by skill (T1 backend, T2 frontend), OD column on weapon searches (T2 step 6), verify (T3). All covered.
|
||||
- Back-compat: legacy `weapon_type` still honored by `weaponOnlyClause`; URL state with old `weaponType` string is dropped by validation (falls back to `[]`), harmless.
|
||||
- Bugfix ride-along: missile subtypes now mastery-based (fixes "Bowl").
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
# Inventory search: spell filters + jewelry-type selection
|
||||
|
||||
**Date:** 2026-07-14
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The inventory search UI (`static/inventory.html`) has a Legendary Cantrips
|
||||
checkbox grid and a "Spell name contains..." text box, and `inventory.js`
|
||||
sends them as `legendary_cantrips` / `spell_contains` query params — but the
|
||||
Go inventory service (`go-services/inventory-go/search.go`) never implemented
|
||||
these filters, so they are silently ignored. The legacy Python service
|
||||
(`inventory-service/main.py:3345-3452`) supported them.
|
||||
|
||||
Additionally there is no way to narrow a jewelry search to a specific type
|
||||
(ring / bracelet / necklace / trinket), even though the backend already
|
||||
supports this via the `slot_names` param (used by the suitbuilder).
|
||||
|
||||
Goal: check e.g. Invulnerability + Summoning, pick jewelry type Ring, and get
|
||||
back only rings carrying BOTH cantrips.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **AND semantics** for multiple checked cantrips (matches legacy intent):
|
||||
an item must carry every checked cantrip.
|
||||
- **Jewelry type: reuse the existing Equipment Slots card.** During planning
|
||||
we found `inventory.html` already has Ring / Bracelet / Neck / Trinket
|
||||
checkboxes (`#all-slots`) that `inventory.js` sends as `slot_names`, which
|
||||
the Go backend fully supports. No new UI — verify the existing checkboxes
|
||||
compose correctly with the new spell filters.
|
||||
- **Also wire `spell_contains`** (the dead free-text box) and `has_spell`
|
||||
(exact-name variant referenced by the agent tools) in the same pass — same
|
||||
mechanism, nearly free.
|
||||
|
||||
## Backend design (`go-services/inventory-go/search.go`)
|
||||
|
||||
Three new query params handled in `runSearch`, emitted as ordinary `WHERE`
|
||||
conditions on the `items_with_slots` CTE so they compose with all existing
|
||||
filters, sorting, pagination, and the count query:
|
||||
|
||||
1. **`legendary_cantrips`** — CSV of display names (e.g.
|
||||
`Legendary Invulnerability,Legendary Summoning Prowess`). For each name,
|
||||
case-insensitive substring match (both directions, mirroring Python's
|
||||
flexible matching) against the in-memory spell enum map (`s.spells`,
|
||||
loaded from `comprehensive_enum_database_v2.json`) to collect that
|
||||
cantrip's spell IDs. Each cantrip emits:
|
||||
|
||||
```sql
|
||||
EXISTS (SELECT 1 FROM item_spells sp
|
||||
WHERE sp.item_id = db_item_id AND sp.spell_id IN ($n, ...))
|
||||
```
|
||||
|
||||
Cantrips are ANDed. A cantrip matching zero spells contributes `1 = 0`.
|
||||
|
||||
Note: this deliberately does NOT replicate Python's
|
||||
`COUNT(DISTINCT spell_id) >= N` trick, which can false-positive when one
|
||||
cantrip name matches two spell IDs on the same item while another matches
|
||||
zero. Per-cantrip EXISTS is strictly correct AND semantics.
|
||||
|
||||
2. **`spell_contains`** — free text; collect all spell IDs whose name
|
||||
contains the text (case-insensitive); a single `EXISTS ... IN (ids)`.
|
||||
No matching spell → `1 = 0`.
|
||||
|
||||
3. **`has_spell`** — exact name match (case-insensitive) to one spell ID; a
|
||||
single `EXISTS`. Unknown name → `1 = 0`.
|
||||
|
||||
Spell IDs originate from our own enum map, not user input, but are still
|
||||
bound via the existing `argBuilder` positional params for consistency.
|
||||
|
||||
## Frontend design
|
||||
|
||||
**No frontend changes.** The cantrip grid, spell text box, and Equipment
|
||||
Slots checkboxes (Ring / Bracelet / Neck / Trinket) already send the correct
|
||||
params (`legendary_cantrips`, `spell_contains`, `slot_names`); the existing
|
||||
`slotNameClause` per-type OR approaches (including the Trinket clause's
|
||||
`%bracelet%` exclusion) are reused untouched. Live verification must confirm
|
||||
jewelry-type + cantrip searches compose end-to-end.
|
||||
|
||||
## Testing
|
||||
|
||||
TDD in `go-services/inventory-go/search_test.go` (new file):
|
||||
|
||||
- Name→spell-ID matcher: substring both-directions semantics, case
|
||||
insensitivity, zero-match behavior.
|
||||
- Condition builder: correct EXISTS SQL shape, one clause per cantrip ANDed,
|
||||
`1 = 0` on no-match, arg binding via argBuilder.
|
||||
|
||||
Query execution against the real DB is validated manually after deploy
|
||||
(consistent with the rest of search.go, which has no DB-backed tests).
|
||||
The Dockerfile's `RUN go test ./...` gates the image build.
|
||||
|
||||
## Deploy
|
||||
|
||||
- Backend: standard inventory-go sync + build + recreate (cutover override).
|
||||
- Frontend: `inventory.html`/`inventory.js` are plain static files on the
|
||||
bind mount — `git pull` on the server picks them up; `deploy-frontend.sh`
|
||||
(React build) is NOT involved.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Cantrip tiers other than Legendary in the checkbox grid (the free-text box
|
||||
covers Epic/Major/etc. searches).
|
||||
- React frontend inventory window changes.
|
||||
- Suitbuilder behavior (unchanged; it passes its own slot_names).
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
# Inventory Search redesign — React full-page view
|
||||
|
||||
**Date:** 2026-07-15
|
||||
**Status:** Approved (layout + behaviors validated interactively in visual-companion mockup)
|
||||
**Mockup:** `2026-07-15-inventory-search-redesign-mockup.html` (same directory — the approved clickable reference)
|
||||
|
||||
## Problem
|
||||
|
||||
The inventory search UI (`static/inventory.html` + `inventory.js`, ~2100 lines of
|
||||
2010s-era dense gray vanilla HTML) is functionally complete (all filters work
|
||||
against the Go `inventory-go` service, including the July 2026 spell filters)
|
||||
but visually dated and inconsistent with the React dashboard. Full redesign
|
||||
requested.
|
||||
|
||||
## Decisions (user-validated)
|
||||
|
||||
1. **Platform:** new full-page view in the existing React app (`frontend/`),
|
||||
reached via `/?view=inventory` — same routing pattern as `?view=dashboard`
|
||||
in `App.tsx`.
|
||||
2. **Layout:** facet sidebar (direction A of three mocked options) + active-
|
||||
filter chips row borrowed from direction B.
|
||||
3. **Search trigger:** instant auto-search, debounced ~400 ms, no Search
|
||||
button; in-flight requests aborted via `AbortController`.
|
||||
4. **Item details:** right-side detail panel opened by row click; ↑/↓ moves
|
||||
selection, Esc closes.
|
||||
5. **Set analysis:** dropped entirely. The new page fully replaces
|
||||
`inventory.html`/`inventory.js` — both deleted at cutover; the "Analyze
|
||||
Sets" feature ceases to exist.
|
||||
6. **Shareable URLs:** filter state serialized to the query string.
|
||||
|
||||
## Layout (per approved mockup)
|
||||
|
||||
- **Top bar:** title, name/material search box (`text` param), Reset (clears
|
||||
all filters), column picker (⚙), result count + query time.
|
||||
- **Chips row:** one removable chip per active filter; legendary-cantrip chips
|
||||
styled gold (#fc6 on #2a2418).
|
||||
- **Facet sidebar** (~210 px, collapsible groups with active-count badges):
|
||||
- **Characters** — searchable checkbox list, All/None/Online links, green
|
||||
online dots (from the `/live` data the app already polls).
|
||||
- **Item type** — radios: All / Armor / Jewelry / Weapons (+subtype) /
|
||||
Clothing (maps to `armor_only`/`jewelry_only`/`weapon_only`+`weapon_type`/
|
||||
`clothing_only`; shirts/pants reachable as clothing subtypes).
|
||||
- **Slots** — jewelry slots (Ring/Bracelet/Neck/Trinket/Cloak) up front,
|
||||
armor slots behind a "show armor slots" expander (`slot_names` CSV).
|
||||
- **Cantrips** — find-as-you-type filter over the legendary cantrip list,
|
||||
grouped (Attributes / Skills / Defense / Banes / Other); checked items pin
|
||||
to a "Selected" section on top; gold styling; plus an "any-tier spell
|
||||
search" text input (`spell_contains`). Checkbox values = the same display
|
||||
names the old page sent (`legendary_cantrips` CSV, AND semantics).
|
||||
- **Ratings** (collapsed) — min inputs for the 4 common ratings, "all 20
|
||||
ratings" expander for the rest (`min_*` params).
|
||||
- **Equipment sets** (collapsed) — searchable set list (`item_set`/`item_sets`).
|
||||
- **Item state** (collapsed) — equipped-only, bonded, attuned, rare
|
||||
(`equipment_status`, `bonded`, `attuned`, `is_rare`).
|
||||
- **Reqs & value** (collapsed) — wield level ≤, value ≥, workmanship ≥,
|
||||
burden ≤ (`max_level`, `min_value`, `min_workmanship`, `max_burden`).
|
||||
- **Results table:** server-side sort via `sort_by`/`sort_dir` (click headers);
|
||||
sticky header; equipped marker (green ⚔); Spells column with legendary names
|
||||
gold; pagination footer (200/page default, `page`/`limit`). Column set
|
||||
togglable via the ⚙ picker, persisted to `localStorage`.
|
||||
- **Detail panel** (~250 px): item name, slot, equipped state, character,
|
||||
value/burden/wield/workmanship/mana, full ordered spell list (legendary
|
||||
gold). Keyboard: ↑/↓/Esc.
|
||||
|
||||
## Implementation shape
|
||||
|
||||
- **No backend changes.** The existing `GET /api/inv/search/items` supports
|
||||
every filter above (validated; the spell filters shipped 2026-07-14).
|
||||
- New directory `frontend/src/components/inventory/`:
|
||||
- `InventorySearchPage.tsx` — page layout, keyboard handling.
|
||||
- `FilterSidebar.tsx` + one small component per filter group.
|
||||
- `ActiveChips.tsx`, `ResultsTable.tsx`, `DetailPanel.tsx`.
|
||||
- `useInventorySearch.ts` — single-reducer filter state, debounce,
|
||||
AbortController fetch, URL (de)serialization.
|
||||
- Filter state ↔ URL: serialize non-default filters into query params on
|
||||
change (replaceState), parse on mount — bookmarkable searches.
|
||||
- Styling: `frontend/src/styles/inventory.css`, `.inv-*` class prefix,
|
||||
dashboard dark tokens (#111 bg, #1a1a1a panels, #333 borders, #88f accent,
|
||||
#fc6 legendary gold, Segoe UI) — mirrors `map-layout.css` conventions.
|
||||
- **No new npm dependencies.**
|
||||
- Cantrip list + grouping: lift the value strings from the old
|
||||
`inventory.html` checkbox grid (they are the strings the backend matches);
|
||||
store as a typed constant module.
|
||||
- Character list: reuse the app's existing live-players data source for names
|
||||
+ online status; fall back to `/api/inv/characters/list` for characters that
|
||||
are offline/never online.
|
||||
|
||||
## Cutover
|
||||
|
||||
Same commit series: add the new view → repoint the dashboard tool-link that
|
||||
currently opens `/inventory.html` to `/?view=inventory` → delete
|
||||
`static/inventory.html` and `static/inventory.js`. `/inventory.html` then 404s
|
||||
to the SPA fallback; acceptable (single-user tool, no external links).
|
||||
|
||||
## Testing & verification
|
||||
|
||||
The frontend has no test framework (build = `tsc -b && vite build`).
|
||||
Verification per task: TypeScript build green + live checks in the Vite dev
|
||||
server against the production API, then post-deploy spot checks of the same
|
||||
scenarios used for the spell-filter validation (Invuln+Summon → 2 items;
|
||||
Ring+Invuln → 56; filterless jewelry → ~4.8k). Adding a test harness is out
|
||||
of scope.
|
||||
|
||||
## Deploy
|
||||
|
||||
`bash deploy-frontend.sh` (builds + copies into `static/`), commit `static/`
|
||||
+ `frontend/` + deletions, push; server picks it up via bind mount, no
|
||||
restart.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Set analysis (dropped per decision 5).
|
||||
- Suitbuilder page (untouched).
|
||||
- React-window (`InventoryWindow.tsx`) per-character inventory — unrelated.
|
||||
- Mobile layout (desktop tool); no test framework introduction.
|
||||
|
|
@ -1,294 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Inventory Search — mockup</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body { background:#111; color:#eee; font-family:"Segoe UI",sans-serif; font-size:13px; display:flex; flex-direction:column; }
|
||||
|
||||
/* ── Top bar ── */
|
||||
.topbar { display:flex; align-items:center; gap:10px; padding:10px 14px; background:#1a1a1a; border-bottom:2px solid #333; }
|
||||
.topbar .title { color:#88f; font-weight:600; font-size:15px; white-space:nowrap; }
|
||||
.searchbox { flex:1; max-width:520px; background:#222; border:1px solid #444; border-radius:4px; padding:7px 12px; color:#eee; font-size:13px; outline:none; }
|
||||
.searchbox:focus { border-color:#88f; }
|
||||
.searchbox::placeholder { color:#666; }
|
||||
.btn { background:#333; border:1px solid #555; color:#ccc; border-radius:4px; padding:6px 14px; font-size:12px; cursor:pointer; }
|
||||
.btn:hover { background:#444; color:#fff; }
|
||||
.count { margin-left:auto; color:#888; font-size:12px; white-space:nowrap; }
|
||||
.count b { color:#eee; }
|
||||
|
||||
/* ── Chips ── */
|
||||
.chipsrow { display:flex; align-items:center; flex-wrap:wrap; gap:6px; padding:8px 14px; background:#151515; border-bottom:1px solid #2a2a2a; min-height:37px; }
|
||||
.chipsrow .lbl { color:#666; font-size:10px; letter-spacing:1px; }
|
||||
.chip { display:inline-flex; align-items:center; gap:6px; background:#23233a; border:1px solid #55f; border-radius:12px; padding:2px 10px; font-size:11px; color:#bbd; cursor:default; }
|
||||
.chip .x { color:#c66; cursor:pointer; font-weight:bold; }
|
||||
.chip .x:hover { color:#f88; }
|
||||
.chip.gold { border-color:#a80; color:#fc6; background:#2a2418; }
|
||||
|
||||
/* ── Main ── */
|
||||
.main { display:flex; flex:1; overflow:hidden; }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar { width:210px; min-width:210px; background:#1a1a1a; border-right:2px solid #333; overflow-y:auto; padding:8px 10px; }
|
||||
.grp { border-bottom:1px solid #262626; padding:6px 0; }
|
||||
.grp-head { display:flex; align-items:center; gap:6px; cursor:pointer; color:#999; text-transform:uppercase; font-size:10px; letter-spacing:1px; padding:3px 0; user-select:none; }
|
||||
.grp-head:hover { color:#ccc; }
|
||||
.grp-head .arrow { font-size:9px; transition:transform .15s; }
|
||||
.grp.open .arrow { transform:rotate(90deg); }
|
||||
.grp-head .badge { margin-left:auto; background:#3a3a6e; color:#cce; border-radius:8px; padding:0 7px; font-size:9px; }
|
||||
.grp-body { display:none; padding:5px 0 3px; }
|
||||
.grp.open .grp-body { display:block; }
|
||||
.minisearch { width:100%; background:#222; border:1px solid #3a3a3a; border-radius:3px; padding:4px 8px; color:#ccc; font-size:11px; outline:none; margin-bottom:5px; }
|
||||
.minisearch::placeholder { color:#555; }
|
||||
.fitem { display:flex; align-items:center; gap:6px; padding:2px 0; font-size:12px; color:#bbb; cursor:pointer; }
|
||||
.fitem:hover { color:#fff; }
|
||||
.fitem input { accent-color:#88f; }
|
||||
.fitem .online { width:6px; height:6px; border-radius:50%; background:#4c4; }
|
||||
.fitem.gold { color:#fc6; }
|
||||
.subhead { color:#666; font-size:9px; text-transform:uppercase; letter-spacing:1px; margin:6px 0 2px; }
|
||||
.linky { color:#88f; font-size:10px; cursor:pointer; }
|
||||
.linky:hover { text-decoration:underline; }
|
||||
.range { display:flex; gap:4px; align-items:center; margin:2px 0; }
|
||||
.range label { flex:1; color:#999; font-size:11px; }
|
||||
.range input { width:52px; background:#222; border:1px solid #3a3a3a; border-radius:3px; color:#ccc; padding:2px 5px; font-size:11px; }
|
||||
|
||||
/* ── Results ── */
|
||||
.results { flex:1; overflow-y:auto; display:flex; flex-direction:column; }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
thead { position:sticky; top:0; background:#191919; z-index:1; }
|
||||
th { text-align:left; color:#88f; font-size:11px; font-weight:600; padding:8px 10px; border-bottom:2px solid #333; cursor:pointer; white-space:nowrap; user-select:none; }
|
||||
th:hover { color:#aaf; }
|
||||
th.sorted::after { content:" ▼"; font-size:9px; }
|
||||
td { padding:6px 10px; border-bottom:1px solid #1e1e1e; font-size:12px; color:#bbb; vertical-align:top; }
|
||||
tbody tr { cursor:pointer; }
|
||||
tbody tr:hover td { background:#191922; }
|
||||
tbody tr.sel td { background:#20203a; }
|
||||
td .leg { color:#fc6; }
|
||||
td .spells { color:#889; font-size:11px; }
|
||||
td.num { text-align:right; font-variant-numeric:tabular-nums; }
|
||||
.equipped { color:#4c4; font-size:10px; }
|
||||
.pager { display:flex; align-items:center; gap:8px; padding:8px 14px; color:#888; font-size:12px; border-top:1px solid #262626; background:#151515; margin-top:auto; }
|
||||
.pager .pg { padding:2px 8px; border:1px solid #333; border-radius:3px; cursor:pointer; }
|
||||
.pager .pg.cur { background:#3a3a6e; border-color:#88f; color:#fff; }
|
||||
|
||||
/* ── Detail panel ── */
|
||||
.detail { width:250px; min-width:250px; background:#161620; border-left:2px solid #333; overflow-y:auto; padding:12px; }
|
||||
.detail h3 { color:#88f; font-size:14px; margin-bottom:2px; }
|
||||
.detail .sub { color:#777; font-size:11px; margin-bottom:10px; }
|
||||
.kv { display:flex; justify-content:space-between; padding:2px 0; font-size:12px; color:#999; }
|
||||
.kv b { color:#ddd; font-weight:normal; text-align:right; }
|
||||
.detail hr { border:none; border-top:1px solid #2a2a35; margin:9px 0; }
|
||||
.detail .sphead { color:#666; font-size:10px; text-transform:uppercase; letter-spacing:1px; margin-bottom:4px; }
|
||||
.sp { padding:2px 0; font-size:12px; color:#bbb; }
|
||||
.sp.leg { color:#fc6; }
|
||||
.detail .hint { color:#555; font-size:10px; margin-top:12px; }
|
||||
.closex { float:right; color:#666; cursor:pointer; font-size:14px; }
|
||||
.closex:hover { color:#f88; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<span class="title">⚔ Inventory Search</span>
|
||||
<input class="searchbox" placeholder="Search name or material… (e.g. 'sapphire ring')">
|
||||
<button class="btn" onclick="document.querySelectorAll('.chip').forEach(c=>c.remove())">Reset</button>
|
||||
<button class="btn">⚙ Columns</button>
|
||||
<span class="count"><b>56</b> items · 24 ms</span>
|
||||
</div>
|
||||
|
||||
<div class="chipsrow">
|
||||
<span class="lbl">ACTIVE</span>
|
||||
<span class="chip">Jewelry <span class="x" onclick="this.parentNode.remove()">×</span></span>
|
||||
<span class="chip">Slot: Ring <span class="x" onclick="this.parentNode.remove()">×</span></span>
|
||||
<span class="chip gold">Legendary Invulnerability <span class="x" onclick="this.parentNode.remove()">×</span></span>
|
||||
<span class="chip gold">Legendary Summoning <span class="x" onclick="this.parentNode.remove()">×</span></span>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="grp open">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Characters <span class="badge">All</span></div>
|
||||
<div class="grp-body">
|
||||
<input class="minisearch" placeholder="filter characters…">
|
||||
<div style="margin-bottom:4px"><span class="linky">All</span> · <span class="linky">None</span> · <span class="linky">Online</span></div>
|
||||
<div class="fitem"><input type="checkbox" checked> Bank of Sawato</div>
|
||||
<div class="fitem"><input type="checkbox" checked> Bowgod <span class="online"></span></div>
|
||||
<div class="fitem"><input type="checkbox" checked> Larsson <span class="online"></span></div>
|
||||
<div class="fitem"><input type="checkbox" checked> Mrbow</div>
|
||||
<div class="fitem"><input type="checkbox" checked> Kosmo Kramer <span class="online"></span></div>
|
||||
<div class="fitem" style="color:#555">… 33 more</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grp open">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Item type <span class="badge">1</span></div>
|
||||
<div class="grp-body">
|
||||
<div class="fitem"><input type="radio" name="t"> All items</div>
|
||||
<div class="fitem"><input type="radio" name="t"> Armor</div>
|
||||
<div class="fitem"><input type="radio" name="t" checked> Jewelry</div>
|
||||
<div class="fitem"><input type="radio" name="t"> Weapons</div>
|
||||
<div class="fitem"><input type="radio" name="t"> Clothing</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grp open">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Slots <span class="badge">1</span></div>
|
||||
<div class="grp-body">
|
||||
<div class="fitem"><input type="checkbox" checked> Ring</div>
|
||||
<div class="fitem"><input type="checkbox"> Bracelet</div>
|
||||
<div class="fitem"><input type="checkbox"> Neck</div>
|
||||
<div class="fitem"><input type="checkbox"> Trinket</div>
|
||||
<div class="fitem"><input type="checkbox"> Cloak</div>
|
||||
<span class="linky">show armor slots ▾</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grp open">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Cantrips <span class="badge">2</span></div>
|
||||
<div class="grp-body">
|
||||
<input class="minisearch" id="cantripSearch" placeholder="find cantrip… e.g. invuln" oninput="filterCantrips(this.value)">
|
||||
<div class="subhead">Selected</div>
|
||||
<div class="fitem gold"><input type="checkbox" checked> Invulnerability</div>
|
||||
<div class="fitem gold"><input type="checkbox" checked> Summoning</div>
|
||||
<div class="subhead">Attributes</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Strength</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Endurance</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Quickness</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Focus</div>
|
||||
<div class="subhead">Skills</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Heavy Weapon</div>
|
||||
<div class="fitem cf"><input type="checkbox"> War Magic</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Defender</div>
|
||||
<div class="subhead">Defense</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Impenetrability</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Magic Resistance</div>
|
||||
<div class="fitem" style="color:#555">… all 45 in real page</div>
|
||||
<div class="subhead">Any-tier spell search</div>
|
||||
<input class="minisearch" placeholder="spell name contains… e.g. Epic Invuln">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grp">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Ratings</div>
|
||||
<div class="grp-body">
|
||||
<div class="range"><label>Dmg rating ≥</label><input placeholder="min"></div>
|
||||
<div class="range"><label>Crit dmg ≥</label><input placeholder="min"></div>
|
||||
<div class="range"><label>Heal boost ≥</label><input placeholder="min"></div>
|
||||
<div class="range"><label>Vitality ≥</label><input placeholder="min"></div>
|
||||
<span class="linky">all 20 ratings ▾</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grp">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Equipment sets</div>
|
||||
<div class="grp-body"><input class="minisearch" placeholder="find set…"></div>
|
||||
</div>
|
||||
|
||||
<div class="grp">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Item state</div>
|
||||
<div class="grp-body">
|
||||
<div class="fitem"><input type="checkbox"> Equipped only</div>
|
||||
<div class="fitem"><input type="checkbox"> Bonded</div>
|
||||
<div class="fitem"><input type="checkbox"> Rare</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grp">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Reqs & value</div>
|
||||
<div class="grp-body">
|
||||
<div class="range"><label>Wield lvl ≤</label><input placeholder="max"></div>
|
||||
<div class="range"><label>Value ≥</label><input placeholder="min"></div>
|
||||
<div class="range"><label>Workmanship ≥</label><input placeholder="min"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Character</th><th>Slot</th><th class="num sorted">Value</th><th>Wield</th><th>Spells / Cantrips</th></tr>
|
||||
</thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
<div class="pager">
|
||||
<span class="pg">◀</span><span class="pg cur">1</span><span class="pg">2</span><span class="pg">3</span><span class="pg">▶</span>
|
||||
<span style="margin-left:auto">200 / page</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail" id="detail"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const items = [
|
||||
{ name:"Gold Ring", char:"Bowgod", slot:"Ring", value:24510, wield:"180", equipped:false, burden:50, work:"—", mana:"142/200",
|
||||
spells:[["Legendary Invulnerability",1],["Legendary Summoning Prowess",1],["Major Heavy Weapon Aptitude",0],["Blessing of the Arrow Turner",0]] },
|
||||
{ name:"Sapphire Ring", char:"Larsson", slot:"Ring", value:18200, wield:"150", equipped:true, burden:50, work:"—", mana:"98/180",
|
||||
spells:[["Legendary Invulnerability",1],["Epic Focus",0],["Minor Impenetrability",0]] },
|
||||
{ name:"Bronze Ring", char:"Mrbow", slot:"Ring", value:9340, wield:"125", equipped:false, burden:50, work:"—", mana:"77/120",
|
||||
spells:[["Legendary Invulnerability",1],["Epic Willpower",0]] },
|
||||
{ name:"Diamond Ring", char:"Kosmo Kramer", slot:"Ring", value:31000, wield:"200", equipped:false, burden:50, work:"—", mana:"200/200",
|
||||
spells:[["Legendary Invulnerability",1],["Legendary Willpower",1],["Epic Mana Conversion Prowess",0]] },
|
||||
{ name:"Ivory Ring", char:"Bank of Sawato", slot:"Ring", value:12750, wield:"150", equipped:false, burden:50, work:"—", mana:"64/150",
|
||||
spells:[["Legendary Invulnerability",1],["Major Void Magic Aptitude",0]] },
|
||||
{ name:"Black Opal Ring", char:"Bowgod", slot:"Ring", value:27890, wield:"180", equipped:true, burden:50, work:"—", mana:"180/220",
|
||||
spells:[["Legendary Invulnerability",1],["Legendary Critical Damage",1]] },
|
||||
];
|
||||
|
||||
function render() {
|
||||
const tb = document.getElementById('rows');
|
||||
tb.innerHTML = items.map((it,i) => `
|
||||
<tr onclick="sel(${i})" id="r${i}">
|
||||
<td>${it.name}${it.equipped ? ' <span class="equipped">⚔</span>' : ''}</td>
|
||||
<td>${it.char}</td><td>${it.slot}</td>
|
||||
<td class="num">${it.value.toLocaleString()}</td><td>${it.wield}</td>
|
||||
<td class="spells">${it.spells.map(([s,l]) => l ? `<span class="leg">${s}</span>` : s).join(', ')}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function sel(i) {
|
||||
document.querySelectorAll('tbody tr').forEach(r=>r.classList.remove('sel'));
|
||||
document.getElementById('r'+i).classList.add('sel');
|
||||
const it = items[i];
|
||||
document.getElementById('detail').innerHTML = `
|
||||
<span class="closex" onclick="document.getElementById('detail').innerHTML=hint">×</span>
|
||||
<h3>${it.name}</h3>
|
||||
<div class="sub">${it.slot} · ${it.equipped ? '⚔ Equipped' : '📦 Inventory'}</div>
|
||||
<div class="kv"><span>Character</span><b>${it.char}</b></div>
|
||||
<div class="kv"><span>Value</span><b>${it.value.toLocaleString()}</b></div>
|
||||
<div class="kv"><span>Burden</span><b>${it.burden}</b></div>
|
||||
<div class="kv"><span>Wield req</span><b>Level ${it.wield}</b></div>
|
||||
<div class="kv"><span>Workmanship</span><b>${it.work}</b></div>
|
||||
<div class="kv"><span>Mana</span><b>${it.mana}</b></div>
|
||||
<hr>
|
||||
<div class="sphead">Spells (${it.spells.length})</div>
|
||||
${it.spells.map(([s,l]) => `<div class="sp${l ? ' leg' : ''}">${s}</div>`).join('')}
|
||||
<div class="hint">↑/↓ next item · Esc close</div>`;
|
||||
}
|
||||
|
||||
function filterCantrips(q) {
|
||||
q = q.toLowerCase();
|
||||
document.querySelectorAll('.cf').forEach(el => {
|
||||
el.style.display = el.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
const hint = '<div class="hint">Click a row to inspect an item.</div>';
|
||||
document.getElementById('detail').innerHTML = hint;
|
||||
render();
|
||||
sel(0);
|
||||
document.addEventListener('keydown', e => {
|
||||
const cur = document.querySelector('tbody tr.sel');
|
||||
if (!cur) return;
|
||||
const i = +cur.id.slice(1);
|
||||
if (e.key === 'ArrowDown' && i < items.length-1) { sel(i+1); e.preventDefault(); }
|
||||
if (e.key === 'ArrowUp' && i > 0) { sel(i-1); e.preventDefault(); }
|
||||
if (e.key === 'Escape') { document.querySelectorAll('tbody tr').forEach(r=>r.classList.remove('sel')); document.getElementById('detail').innerHTML = hint; }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
# Weapon OD — recompute from the VTank loot profile (0–10 scale)
|
||||
|
||||
**Date:** 2026-07-15
|
||||
**Status:** Approved (user directed the rebuild)
|
||||
|
||||
## Problem
|
||||
|
||||
The shipped OD (`od.go`) ports UtilityBelt's unbounded "over-retail damage"
|
||||
float (−16 … +43). The user's actual in-game OD is a **0–10** (casters 0–7)
|
||||
integer they see on ident. Investigation (this session) proved:
|
||||
|
||||
- Mag-Tools has **no** OD calculation. Its `ItemInfoPrinter` prints the item
|
||||
info **plus the matching VirindiTank loot-rule name** (`GetLootRuleInfoFromItemInfo`).
|
||||
- The user's `VirindiTank\Loot.utl` defines a ladder of rules named
|
||||
`(H) Axe (OD +10)`, `(OD +9)`, … per weapon bucket. Mag-Tools prints whichever
|
||||
matched — **that** is the OD the user sees.
|
||||
|
||||
The thresholds are the same across all the user's profiles (user-confirmed), so
|
||||
one table drives every weapon and caster.
|
||||
|
||||
## The rule (decoded from Loot.utl)
|
||||
|
||||
Each bucket is `OD = clamp(stat − baseline, 1, cap)`, where the bucket is matched
|
||||
by **weapon skill + mastery + (optional) weapon-name filter**, and `stat` is a
|
||||
raw captured value. Ladders step by 1 per tier.
|
||||
|
||||
- **Melee** (object_class 1): `stat = MaxDamage` (int key `218103842`), cap 10.
|
||||
27 buckets (skill `218103840`, mastery `353`, name filter, baseline) — see the
|
||||
table in the plan. Example: 2H Cleaving baseline 45 → Tetsubo (maxdmg 85) = OD
|
||||
**+10** (matches in-game); Heavy Axe baseline 74.
|
||||
- **Crossbow** (object_class 9, mastery `353`=9): `stat = int key 218103839`
|
||||
("effective missile damage"), baseline **77**, cap 10 (OD+10=87 … OD+1=78,
|
||||
verified on Fire Compound Crossbow k839=89 → +10).
|
||||
- **Casters** (object_class 31): `stat = ElementalDamageVersusMonsters` (double
|
||||
key `152`), baseline **1.18**, ×100, cap **7**. War bucket: skill `159`=34,
|
||||
name `Baton|Sceptre|Staff`; Void bucket: skill 43, name `Nether`. OD+7 at
|
||||
elemVsMon 1.25, OD+1 at 1.19. Frost Baton (1.40) → +7.
|
||||
- **No match / stat below baseline+1 → NULL** (item shows no OD, exactly like
|
||||
in-game when no OD rule matches).
|
||||
|
||||
## Deferred: Bows & Thrown (mastery 8 / 10)
|
||||
|
||||
Their OD rules threshold a **computed decimal** damage stat (bow OD+10 = 78.6,
|
||||
thrown = 85.3) on a key not reliably identifiable from the profile alone (bow
|
||||
`218103839` values 45–70 don't reach 78.6; the rule uses a DoubleValKeyGE the
|
||||
`.utl` encodes indirectly). To avoid showing a *wrong* number (the very problem
|
||||
we're fixing), **bows and thrown get NULL OD** in this pass. Reopened once the
|
||||
user supplies one in-game bow OD reading to calibrate the stat.
|
||||
|
||||
## Design
|
||||
|
||||
- **`od.go` rewritten** around an ordered bucket table (extracted from
|
||||
`Loot.utl`, committed as data): `{objectClass, skill, mastery, nameAlts[],
|
||||
statKey, baseline, cap}`. `computeOD` finds the first bucket whose
|
||||
objectClass+skill+mastery+name match, then `OD = clamp(stat − baseline, 1,
|
||||
cap)`; returns null if none match or stat ≤ baseline. Name match = any
|
||||
alternative is a substring of the item name (VTank substring semantics).
|
||||
- **Type**: `od_rating` stays `DOUBLE PRECISION NULL` but now holds an integer
|
||||
1–10 (or 1–7). Frontend already renders it; drop the `+`/decimals for the new
|
||||
integer (show plain `7`, `10`, `—`).
|
||||
- Search `min_od`/`max_od`/sort unchanged. Backfill via existing
|
||||
`POST /admin/backfill-od`.
|
||||
- The best-values table, spell-effect dicts, and variance math in the old
|
||||
`od.go` are **deleted** (wrong metric).
|
||||
|
||||
## Testing
|
||||
|
||||
Go golden tests from real items: Tetsubo → 10, Frost Baton → 7, Fire Compound
|
||||
Crossbow → 10, a mid melee → its expected tier, a below-baseline weapon → null,
|
||||
a bow → null (deferred). Then live backfill + spot-checks; the column must read
|
||||
0–10/0–7 and the user confirms against ident.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Bows/thrown OD (deferred, null).
|
||||
- The loot-rule name filters are reproduced as substring lists; exotic renamed
|
||||
items may differ from a live VTank match — acceptable.
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
# Weapon OD rating — compute, store, filter
|
||||
|
||||
**Date:** 2026-07-15
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The inventory search has weak weapon/wand support. Mag-Tools (and UtilityBelt's
|
||||
port of it, the "retail comparison" / OD feature) classifies weapons by **OD
|
||||
("over damage")** — how much better a weapon's optimally-tinked output is than
|
||||
the best possible *retail* loot weapon of its class and wield tier. Users want
|
||||
to filter weapons by OD.
|
||||
|
||||
## Authoritative algorithm (researched 2026-07-15)
|
||||
|
||||
Source: `UtilityBelt/UtilityBelt/Tools/ItemInfo.cs` (sections titled "Slightly
|
||||
modified MagTools item description classes", OD display at lines ~631-647) +
|
||||
`UtilityBelt/UtilityBelt/Lib/ItemInfoHelper/{MiscCalcs,WeaponMods,Dictionaries}.cs`
|
||||
in this workspace. WeaponMods.cs holds the **best-values table**: rows keyed by
|
||||
`(Skill, Mastery, MultiStrike, WieldReq)` with columns
|
||||
`MaxDmg, MaxVar, MaxDmgMod, MaxElementalDmgBonus, MaxElementalDmgVsMonsters`
|
||||
(sourced from acpedia; ~330 rows covering heavy/light/finesse/two-handed melee
|
||||
subtypes, bow/xbow/thrown, war/void casters).
|
||||
|
||||
Raw item keys (verified present in our `item_raw_data`):
|
||||
- IntValues: `159` EquipSkill, `353` Mastery/WeaponType, `160` WieldReqValue,
|
||||
`218103842` MaxDamage, `204` ElementalDmgBonus, `171` Tinks, `179` Imbued
|
||||
(bitmask, nonzero = imbued), `131` Material.
|
||||
- `original_json->DoubleValues`: `167772171` Variance, `167772174` DamageBonus
|
||||
(missile dmg modifier, e.g. 2.73 = 173%), `152` ElementalDmgVsMonsters
|
||||
(wands), `167772169` SalvageWorkmanship.
|
||||
- `original_json->Spells` / `ActiveSpells`: innate and active spell id arrays.
|
||||
|
||||
**Buffed values** (GetBuffedIntValueKey / GetBuffedDoubleValueKey semantics):
|
||||
value + Σ **bonus**(innate Spells) − Σ **change**(ActiveSpells). Each spell
|
||||
entry is a {change, bonus} pair (`SpellInfo(key, change, bonus=0)`,
|
||||
Dictionaries.cs:356). Old-style item spells were converted to player auras in
|
||||
2012, so their innate **bonus is 0** — only their active-enchant change is
|
||||
subtracted; the Blood/Spirit Thirst cantrips have bonus == change:
|
||||
- MaxDamage — 1616 BD VI {20,0}, 2096 Infected Caress {22,0}, 5183/4395
|
||||
Incant. BD {24,0}, 2598 Minor BT {2,2}, 2586 Major BT {4,4}, 4661 Epic BT
|
||||
{7,7}, 6089 Legendary BT {10,10}, 3688 Prodigal BD {300,0}.
|
||||
- ElemVsMonsters — 3258 SD VI {.06,0}, 3259 Infected Spirit Caress {.07,0},
|
||||
5182/4414 Incant. SD {.08,0}, 3251 Minor ST {.01,.01}, 3250 Major ST
|
||||
{.03,.03}, 4670 Epic ST {.05,.05}, 6098 Legendary ST {.07,.07},
|
||||
3735 Prodigal Spirit Drinker {.15,0}.
|
||||
|
||||
**Missile dmgMod key**: UB's literal CalcMissileDamage reads `DoubleValues[63]`,
|
||||
but no DoubleValueKey ordinal 63 exists in Decal — the populated key is
|
||||
`167772174` (DamageBonus), consistent with UB's own CalcedBuffedMissileDamage.
|
||||
We use 167772174 deliberately.
|
||||
|
||||
**Melee variance orientation**: the table's MaxVar is the type's *baseline*
|
||||
variance; a weapon with LOWER variance than baseline yields a NEGATIVE
|
||||
varianceTinks (log ratio < 1 over log 0.8) and therefore a HIGHER OD —
|
||||
`varianceTinks = round(log(MaxVar_t / variance)/log(0.8), 2)` exactly as
|
||||
ItemInfo.cs:1096.
|
||||
|
||||
**MultiStrike** weapons: WeaponType (key 47 in UB code = our int key 47? no —
|
||||
UB reads LongValueKey 47 = WeaponType master id; the multistrike check is:
|
||||
`IntValues[47] ∈ {160,166,486}` or (`47==4` and mastery `353==11`)). Verify key
|
||||
47 exists in our data during implementation; if absent, derive from mastery
|
||||
subtype comments in the table (msdagger/mssword/cleaver rows are the
|
||||
MultiStrike=1 rows).
|
||||
|
||||
Formulas (OD, higher = better; retail max = 0):
|
||||
- **Melee** (object_class 1):
|
||||
`varianceTinks = round(log(MaxVar_t / variance) / log(0.8), 2)`;
|
||||
`OD = buffedMaxDmg − varianceTinks − MaxDmg_t`.
|
||||
- **Missile** (object_class 9):
|
||||
`dmgMod = buffedDmgBonus·100 − 100`; `arrowMax` by mastery: bow(8)=40,
|
||||
xbow(9)=53, thrown(10)=42; `remainingTinks`: 10 − tinks, −1 more if not
|
||||
imbued, floor 0 (loot-gen only, see gate below; UB nuance: if tinks==0,
|
||||
remainingTinks=9);
|
||||
`buffedDmg = buffedMaxDmg; if ≤10 then +24`;
|
||||
`maxMod = (MaxDmgMod_t + 100 + 36)/100`;
|
||||
`OD = (1 + (dmgMod + 4·remainingTinks)/100) · (buffedElemBonus + buffedDmg + arrowMax) / maxMod − (MaxElemBonus_t + 24 + arrowMax)`.
|
||||
- **Wand** (object_class 31, skill 34 war / 43 void):
|
||||
`OD = (buffedElemVsMonsters − MaxElemVsMonsters_t) · 100` (percentage points;
|
||||
e.g. live Frost Baton: (1.40 − 1.18)·100 = +22).
|
||||
- **Gate**: OD only for loot-gen items (`SalvageWorkmanship > 0`); otherwise
|
||||
NULL. Table lookup miss (unknown skill/mastery/wieldreq combo) → NULL.
|
||||
Round stored OD to 2 decimals.
|
||||
|
||||
## Decisions (user-approved)
|
||||
|
||||
1. **Raw OD number** with a `min OD ≥` filter (not a bucketed 0-10 scale).
|
||||
2. **All classes**: melee, missile, wands.
|
||||
3. **Computed at ingest**, stored on `items.od_rating DOUBLE PRECISION NULL`,
|
||||
with a one-off backfill for existing rows.
|
||||
|
||||
## Backend (inventory-go)
|
||||
|
||||
- New `od.go`: best-values table ported from UB `WeaponMods.cs` (verify row
|
||||
count and spot-check values against the source file), buffed-value helpers,
|
||||
`computeOD(raw map[string]any) (float64, bool)`. TDD with golden values
|
||||
(incl. the live Frost Baton +22 case).
|
||||
- `processItem` sets `items["od_rating"]` (or omits when NULL) — the dynamic
|
||||
insert in ingest.go picks it up automatically.
|
||||
- `schema.go`: add `od_rating DOUBLE PRECISION` to the items DDL (fresh
|
||||
installs). Live DB gets a manual
|
||||
`ALTER TABLE items ADD COLUMN IF NOT EXISTS od_rating double precision`
|
||||
(prod runs SKIP_SCHEMA_INIT=true).
|
||||
- Search: `i.od_rating` added to the CTE; `min_od` (>=) and `max_od` (<=)
|
||||
params; sort key `od`. NULL od never matches the filters (SQL NULL
|
||||
semantics) and sorts NULLS LAST/FIRST as usual.
|
||||
- Backfill: `POST /admin/backfill-od` — iterates items of object_class 1/9/31
|
||||
joined to item_raw_data, recomputes, UPDATEs `od_rating`; returns counts.
|
||||
Service is internal-only (127.0.0.1:8772 / compose network).
|
||||
|
||||
## Frontend
|
||||
|
||||
- `RATING_DEFS` gains `{param:'min_od', label:'Weapon OD', common:true}` —
|
||||
flows through the existing ratings record, sidebar inputs, chips, and URL
|
||||
state with no new plumbing.
|
||||
- `COLUMNS` gains `{key:'od_rating', label:'OD', sortKey:'od', defaultVisible:false}`;
|
||||
cell renders signed 2-decimal (`+3.25` / `-1.29`), `—` for null.
|
||||
- `InvItem.od_rating?: number | null`; DetailPanel row "OD" for weapons.
|
||||
|
||||
## Verification
|
||||
|
||||
- Go unit tests (golden formula values + table spot checks) gate the image
|
||||
build as usual.
|
||||
- Live: backfill count > 0; `min_od=20` returns the Frost Baton; melee/missile
|
||||
spot checks against UB in-game values if available; sort by OD desc.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Bucketed OD tiers (can be derived later).
|
||||
- The old suitbuilder page.
|
||||
- Recomputing OD when spell/enchant state changes intra-session (ingest
|
||||
updates overwrite it naturally via the debounced item updates).
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
# Weapon-type (by skill) multi-select filter + OD column for weapon searches
|
||||
|
||||
**Date:** 2026-07-15
|
||||
**Status:** Pre-approved by user
|
||||
|
||||
## Problem
|
||||
|
||||
Weapon searching is weak: the current weapon-type filter is a single-select
|
||||
dropdown (`weapon_type`, shown only under the "Weapons" item type) and the OD
|
||||
column is hidden by default. The user wants to search across weapon types by
|
||||
skill (Heavy, Light, Finesse, Two-Handed, Missile, War/Void casters), pick
|
||||
more than one at once, and see the OD value in the results columns.
|
||||
|
||||
## Field semantics (verified against live data)
|
||||
|
||||
- Melee (`object_class = 1`): WeaponSkill `int_values->>'218103840'` = 44 Heavy,
|
||||
45 Light, 46 Finesse, 41 Two-Handed.
|
||||
- Missile (`object_class = 9`): WeaponSkill `218103840` = 47; subtype from
|
||||
mastery `int_values->>'353'` = 8 Bow, 9 Crossbow, 10 Thrown. Arrows/ammo lack
|
||||
skill 47, so gating on it excludes them — and fixes the existing bug where
|
||||
the name-based `%bow%` clause matched "Bowl".
|
||||
- Wands (`object_class = 31`): WeaponSkill is null; use equip skill
|
||||
`int_values->>'159'` = 34 War Magic, 43 Void Magic (the only caster schools
|
||||
the OD table rates).
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Weapon-type becomes multi-select** (checkbox group) with seven
|
||||
skill-level categories: Heavy, Light, Finesse, Two-Handed, Missile,
|
||||
War Magic, Void Magic. Any combination; none = all weapons. Shown when the
|
||||
"Weapons" item type is selected (same nesting the single dropdown used).
|
||||
2. **Backend** accepts a new `weapon_types` CSV param → OR of per-type clauses,
|
||||
ANDed into the query. The legacy single `weapon_type` param is kept for
|
||||
back-compat. `weaponTypeClause` is reworked to be object-class/key aware
|
||||
(melee by 218103840, missile launcher by 218103840=47 + mastery, casters by
|
||||
159), replacing the melee-only `exists(skill)` helper. Existing type keys
|
||||
(bow/crossbow/thrown/caster/light/finesse/heavy/two_handed) keep working;
|
||||
bow/crossbow/thrown switch to the robust mastery-based clause.
|
||||
3. **OD column auto-shows for weapon searches**: when `itemType === 'weapon'`,
|
||||
the results table includes the `od_rating` column regardless of the user's
|
||||
saved column set (union, non-destructive — their other prefs and the column
|
||||
picker still work; OD just can't be hidden while weapon-searching). No
|
||||
change to default sort.
|
||||
|
||||
## Frontend model
|
||||
|
||||
- `SearchFilters.weaponType: string` → `weaponTypes: string[]`. Update
|
||||
DEFAULT_FILTERS, `validateFilterValue` (string array), `buildParams`
|
||||
(`weapon_types` CSV when non-empty), FilterSidebar (checkbox group replacing
|
||||
the `<select>`), ActiveChips (one chip per selected type, removable).
|
||||
- `WEAPON_TYPES` in constants becomes the seven skill categories
|
||||
`{value,label}`: heavy/light/finesse/two_handed/missile/war/void.
|
||||
- ResultsTable computes effective visible columns = saved set ∪
|
||||
(`itemType==='weapon'` ? {od_rating} : {}).
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend: Go unit tests on the reworked `weaponTypeClause` (correct SQL per
|
||||
type: object_class, keys, mastery) and the `weapon_types` CSV → OR
|
||||
composition in `runSearch` (via the existing search-string test approach if
|
||||
present, else targeted clause tests). No DB-backed tests (consistent with the
|
||||
rest of search.go); live verification after deploy.
|
||||
- Frontend: `npm run build` (tsc) green; live browser verification.
|
||||
|
||||
## Verification (live)
|
||||
|
||||
- `weapon_types=heavy,two_handed` returns only skill-44 and skill-41 melee.
|
||||
- `weapon_types=missile` returns bows/xbows/thrown, no "Bowl".
|
||||
- `weapon_types=war` / `void` return the respective caster schools.
|
||||
- Multi-select in the UI; OD column visible automatically on weapon searches;
|
||||
chips add/remove; sort by OD works.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing OD math or the best-values table.
|
||||
- Non-weapon filters, suitbuilder.
|
||||
- Splitting missile back into per-subtype UI checkboxes (Missile is one
|
||||
category; the backend still supports bow/crossbow/thrown individually).
|
||||
|
|
@ -1,25 +1,20 @@
|
|||
import { MapLayout } from './components/map/MapLayout';
|
||||
import { PlayerDashboardFullPage } from './components/PlayerDashboardFullPage';
|
||||
import { InventorySearchPage } from './components/inventory/InventorySearchPage';
|
||||
import { MidsummerProvider } from './hooks/useMidsummer';
|
||||
import { useLiveData } from './hooks/useLiveData';
|
||||
import './styles/map-layout.css';
|
||||
import './styles/midsummer.css';
|
||||
import './styles/inventory.css';
|
||||
|
||||
/**
|
||||
* Single SPA entry. Branches on `?view=` query param:
|
||||
* /?view=dashboard → fullscreen PlayerDashboardFullPage (new-tab target)
|
||||
* /?view=inventory → fullscreen InventorySearchPage (new-tab target)
|
||||
* / → default map + sidebar layout
|
||||
*/
|
||||
export default function App() {
|
||||
const view = new URLSearchParams(window.location.search).get('view');
|
||||
return (
|
||||
<MidsummerProvider>
|
||||
{view === 'dashboard' ? <PlayerDashboardFullPage />
|
||||
: view === 'inventory' ? <InventorySearchPage />
|
||||
: <DefaultApp />}
|
||||
{view === 'dashboard' ? <PlayerDashboardFullPage /> : <DefaultApp />}
|
||||
</MidsummerProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
import type { InventorySearch } from './useInventorySearch';
|
||||
import { RATING_DEFS, WEAPON_TYPES } from './constants';
|
||||
|
||||
interface Chip { label: string; gold?: boolean; remove: () => void; }
|
||||
|
||||
export function ActiveChips({ search }: { search: InventorySearch }) {
|
||||
const { filters: f, update } = search;
|
||||
const chips: Chip[] = [];
|
||||
if (f.text) chips.push({ label: `"${f.text}"`, remove: () => update({ text: '' }) });
|
||||
if (f.characters !== 'all') chips.push({
|
||||
label: `${f.characters.length} character${f.characters.length === 1 ? '' : 's'}`,
|
||||
remove: () => update({ characters: 'all' }),
|
||||
});
|
||||
if (f.itemType !== 'all') chips.push({
|
||||
label: f.itemType[0].toUpperCase() + f.itemType.slice(1),
|
||||
remove: () => update({ itemType: 'all', weaponTypes: [] }),
|
||||
});
|
||||
if (f.itemType === 'weapon') for (const wt of f.weaponTypes) {
|
||||
const label = WEAPON_TYPES.find(w => w.value === wt)?.label ?? wt;
|
||||
chips.push({ label, remove: () => update({ weaponTypes: f.weaponTypes.filter(x => x !== wt) }) });
|
||||
}
|
||||
for (const s of f.slots) chips.push({ label: `Slot: ${s}`, remove: () => update({ slots: f.slots.filter(x => x !== s) }) });
|
||||
for (const c of f.cantrips) chips.push({
|
||||
label: c.replace(/^Legendary /, 'Leg. '), gold: true,
|
||||
remove: () => update({ cantrips: f.cantrips.filter(x => x !== c) }),
|
||||
});
|
||||
if (f.spellContains) chips.push({ label: `Spell: ${f.spellContains}`, remove: () => update({ spellContains: '' }) });
|
||||
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') {
|
||||
const def = RATING_DEFS.find(r => r.param === param);
|
||||
chips.push({
|
||||
label: `${def?.label ?? param} ≥ ${v}`,
|
||||
remove: () => {
|
||||
const { [param]: _omit, ...rest } = f.ratings;
|
||||
update({ ratings: rest });
|
||||
},
|
||||
});
|
||||
}
|
||||
if (f.itemSet) chips.push({ label: `Set: ${f.itemSet}`, remove: () => update({ itemSet: '' }) });
|
||||
if (f.equipStatus) chips.push({
|
||||
label: f.equipStatus === 'equipped' ? 'Equipped' : 'Not equipped',
|
||||
remove: () => update({ equipStatus: '' }),
|
||||
});
|
||||
if (f.bonded) chips.push({ label: 'Bonded', remove: () => update({ bonded: false }) });
|
||||
if (f.attuned) chips.push({ label: 'Attuned', remove: () => update({ attuned: false }) });
|
||||
if (f.rare) chips.push({ label: 'Rare', remove: () => update({ rare: false }) });
|
||||
if (f.maxLevel !== '') chips.push({ label: `Wield ≤ ${f.maxLevel}`, remove: () => update({ maxLevel: '' }) });
|
||||
if (f.minValue !== '') chips.push({ label: `Value ≥ ${f.minValue}`, remove: () => update({ minValue: '' }) });
|
||||
if (f.minWorkmanship !== '') chips.push({ label: `Work ≥ ${f.minWorkmanship}`, remove: () => update({ minWorkmanship: '' }) });
|
||||
if (f.maxBurden !== '') chips.push({ label: `Burden ≤ ${f.maxBurden}`, remove: () => update({ maxBurden: '' }) });
|
||||
|
||||
if (!chips.length) return null;
|
||||
return (
|
||||
<div className="inv-chipsrow">
|
||||
<span className="inv-chips-lbl">ACTIVE</span>
|
||||
{chips.map((c, i) => (
|
||||
<span className={`inv-chip${c.gold ? ' inv-chip-gold' : ''}`} key={i}>
|
||||
{c.label} <span className="inv-chip-x" onClick={c.remove}>×</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { CANTRIP_GROUPS } from './constants';
|
||||
import type { InventorySearch } from './useInventorySearch';
|
||||
import { Group } from './FilterSidebar';
|
||||
|
||||
export function CantripFilter({ search }: { search: InventorySearch }) {
|
||||
const { filters: f, update } = search;
|
||||
const [q, setQ] = useState('');
|
||||
const toggle = (v: string) => update({
|
||||
cantrips: f.cantrips.includes(v) ? f.cantrips.filter(x => x !== v) : [...f.cantrips, v],
|
||||
});
|
||||
const match = (label: string) => label.toLowerCase().includes(q.toLowerCase());
|
||||
|
||||
return (
|
||||
<Group title="Cantrips" defaultOpen badge={f.cantrips.length || undefined}>
|
||||
<input className="inv-minisearch" placeholder="find cantrip… e.g. invuln"
|
||||
value={q} onChange={e => setQ(e.target.value)} />
|
||||
{f.cantrips.length > 0 && (<>
|
||||
<div className="inv-subhead">Selected</div>
|
||||
{f.cantrips.map(v => (
|
||||
<label className="inv-fitem inv-gold" key={v}>
|
||||
<input type="checkbox" checked onChange={() => toggle(v)} />
|
||||
{v.replace(/^Legendary /, '')}
|
||||
</label>
|
||||
))}
|
||||
</>)}
|
||||
{CANTRIP_GROUPS.map(g => {
|
||||
const items = g.items.filter(i => !f.cantrips.includes(i.value) && match(i.label));
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div key={g.group}>
|
||||
<div className="inv-subhead">{g.group}</div>
|
||||
{items.map(i => (
|
||||
<label className="inv-fitem" key={i.value}>
|
||||
<input type="checkbox" checked={false} onChange={() => toggle(i.value)} /> {i.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="inv-subhead">Any-tier spell search</div>
|
||||
<input className="inv-minisearch" placeholder="spell name contains… e.g. Epic Invuln"
|
||||
value={f.spellContains} onChange={e => update({ spellContains: e.target.value })} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { apiFetch } from '../../api/client';
|
||||
import type { InventorySearch } from './useInventorySearch';
|
||||
import { Group } from './FilterSidebar';
|
||||
|
||||
export function CharacterFilter({ search }: { search: InventorySearch }) {
|
||||
const { filters: f, update } = search;
|
||||
const [names, setNames] = useState<string[]>([]);
|
||||
const [online, setOnline] = useState<Set<string>>(new Set());
|
||||
const [q, setQ] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ characters: Array<{ character_name: string }> }>('/inv/characters/list')
|
||||
.then(r => setNames(r.characters.map(c => c.character_name).sort((a, b) => a.localeCompare(b))))
|
||||
.catch(() => {});
|
||||
apiFetch<{ players: Array<{ character_name: string }> }>('/live')
|
||||
.then(r => setOnline(new Set(r.players.map(p => p.character_name))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const checked = useMemo(() =>
|
||||
f.characters === 'all' ? new Set(names) : new Set(f.characters), [f.characters, names]);
|
||||
const toggle = (n: string) => {
|
||||
const next = new Set(checked);
|
||||
if (next.has(n)) next.delete(n); else next.add(n);
|
||||
update({ characters: next.size === names.length ? 'all' : [...next] });
|
||||
};
|
||||
const shown = names.filter(n => n.toLowerCase().includes(q.toLowerCase()));
|
||||
|
||||
return (
|
||||
<Group title="Characters" defaultOpen
|
||||
badge={f.characters === 'all' ? 'All' : f.characters.length}>
|
||||
<input className="inv-minisearch" placeholder="filter characters…"
|
||||
value={q} onChange={e => setQ(e.target.value)} />
|
||||
<div className="inv-links">
|
||||
<span className="inv-linky" onClick={() => update({ characters: 'all' })}>All</span>{' · '}
|
||||
<span className="inv-linky" onClick={() => update({ characters: [] })}>None</span>{' · '}
|
||||
<span className="inv-linky" onClick={() => update({ characters: names.filter(n => online.has(n)) })}>Online</span>
|
||||
</div>
|
||||
{shown.map(n => (
|
||||
<label className="inv-fitem" key={n}>
|
||||
<input type="checkbox" checked={checked.has(n)} onChange={() => toggle(n)} />
|
||||
{n} {online.has(n) && <span className="inv-online" />}
|
||||
</label>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import React from 'react';
|
||||
import type { InvItem } from './types';
|
||||
|
||||
function Row({ k, v }: { k: string; v: React.ReactNode }) {
|
||||
return <div className="inv-kv"><span>{k}</span><b>{v ?? '—'}</b></div>;
|
||||
}
|
||||
|
||||
export function DetailPanel({ item, onClose }: { item: InvItem; onClose: () => void }) {
|
||||
return (
|
||||
<div className="inv-detail">
|
||||
<span className="inv-closex" onClick={onClose}>×</span>
|
||||
<h3>{item.name}</h3>
|
||||
<div className="inv-detail-sub">
|
||||
{item.slot_name ?? item.object_class_name ?? ''} · {item.is_equipped ? '⚔ Equipped' : '📦 Inventory'}
|
||||
{item.is_rare && ' · ★ Rare'}
|
||||
</div>
|
||||
<Row k="Character" v={item.character_name} />
|
||||
<Row k="Value" v={item.value?.toLocaleString()} />
|
||||
<Row k="Burden" v={item.burden} />
|
||||
<Row k="Wield req" v={item.wield_level ? `Level ${item.wield_level}` : '—'} />
|
||||
<Row k="Workmanship" v={item.workmanship ?? '—'} />
|
||||
{item.armor_level != null && item.armor_level > 0 && <Row k="Armor" v={item.armor_level} />}
|
||||
{item.max_damage != null && item.max_damage > 0 && <Row k="Max damage" v={item.max_damage} />}
|
||||
{item.od_rating != null && <Row k="Weapon OD" v={String(item.od_rating)} />}
|
||||
{item.condition_percent != null && <Row k="Condition" v={`${item.condition_percent}%`} />}
|
||||
{item.item_set_name && <Row k="Set" v={item.item_set_name} />}
|
||||
{(item.is_bonded || item.is_attuned) && <Row k="Binding" v={[item.is_bonded && 'Bonded', item.is_attuned && 'Attuned'].filter(Boolean).join(', ')} />}
|
||||
<hr />
|
||||
<div className="inv-sphead">Spells ({item.spell_names?.length ?? 0})</div>
|
||||
{(item.spell_names ?? []).map((s, i) => (
|
||||
<div className={`inv-sp${/legendary/i.test(s) ? ' inv-leg' : ''}`} key={i}>{s}</div>
|
||||
))}
|
||||
<div className="inv-hint">↑/↓ next item · Esc close</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import type { InventorySearch } from './useInventorySearch';
|
||||
import { CharacterFilter } from './CharacterFilter';
|
||||
import { CantripFilter } from './CantripFilter';
|
||||
import { ARMOR_SLOTS, JEWELRY_SLOTS, RATING_DEFS, WEAPON_TYPES } from './constants';
|
||||
import type { ItemType } from './types';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
export function Group(props: {
|
||||
title: string; badge?: string | number; defaultOpen?: boolean; children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(props.defaultOpen ?? false);
|
||||
return (
|
||||
<div className={`inv-grp${open ? ' inv-open' : ''}`}>
|
||||
<div className="inv-grp-head" onClick={() => setOpen(o => !o)}>
|
||||
<span className="inv-arrow">▶</span> {props.title}
|
||||
{props.badge ? <span className="inv-badge">{props.badge}</span> : null}
|
||||
</div>
|
||||
{open && <div className="inv-grp-body">{props.children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterSidebar({ search }: { search: InventorySearch }) {
|
||||
const { filters: f, update } = search;
|
||||
const [showArmorSlots, setShowArmorSlots] = useState(false);
|
||||
const [allRatings, setAllRatings] = useState(false);
|
||||
const [sets, setSets] = useState<Array<{ id: string; item_count: number }>>([]);
|
||||
const [setQuery, setSetQuery] = useState('');
|
||||
useEffect(() => {
|
||||
apiFetch<{ sets: Array<{ id: string; item_count: number }> }>('/inv/sets/list')
|
||||
.then(r => setSets(r.sets)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleSlot = (s: string) => update({
|
||||
slots: f.slots.includes(s) ? f.slots.filter(x => x !== s) : [...f.slots, s],
|
||||
});
|
||||
const setRating = (param: string, v: string) => update({
|
||||
ratings: { ...f.ratings, [param]: v === '' ? '' : Number(v) },
|
||||
});
|
||||
const ratingCount = Object.values(f.ratings).filter(v => v !== '').length;
|
||||
const stateCount = [f.equipStatus !== '', f.bonded, f.attuned, f.rare].filter(Boolean).length;
|
||||
const reqCount = [f.maxLevel, f.minValue, f.minWorkmanship, f.maxBurden].filter(v => v !== '').length;
|
||||
const types: Array<[ItemType, string]> = [['all', 'All items'], ['armor', 'Armor'],
|
||||
['jewelry', 'Jewelry'], ['weapon', 'Weapons'], ['clothing', 'Clothing'],
|
||||
['shirt', 'Shirts'], ['pants', 'Pants']];
|
||||
|
||||
return (
|
||||
<div className="inv-sidebar">
|
||||
<CharacterFilter search={search} />
|
||||
<Group title="Item state" defaultOpen badge={stateCount || undefined}>
|
||||
{([['', 'Any'], ['equipped', 'Equipped'], ['unequipped', 'Not equipped']] as const).map(([v, label]) => (
|
||||
<label className="inv-fitem" key={label}>
|
||||
<input type="radio" name="inv-eq" checked={f.equipStatus === v}
|
||||
onChange={() => update({ equipStatus: v })} /> {label}
|
||||
</label>
|
||||
))}
|
||||
<label className="inv-fitem"><input type="checkbox" checked={f.bonded}
|
||||
onChange={e => update({ bonded: e.target.checked })} /> Bonded</label>
|
||||
<label className="inv-fitem"><input type="checkbox" checked={f.attuned}
|
||||
onChange={e => update({ attuned: e.target.checked })} /> Attuned</label>
|
||||
<label className="inv-fitem"><input type="checkbox" checked={f.rare}
|
||||
onChange={e => update({ rare: e.target.checked })} /> Rare</label>
|
||||
</Group>
|
||||
<Group title="Item type" defaultOpen badge={f.itemType !== 'all' ? 1 : undefined}>
|
||||
{types.map(([v, label]) => (
|
||||
<label className="inv-fitem" key={v}>
|
||||
<input type="radio" name="inv-t" checked={f.itemType === v}
|
||||
onChange={() => update({ itemType: v, weaponTypes: [] })} /> {label}
|
||||
</label>
|
||||
))}
|
||||
{f.itemType === 'weapon' && (
|
||||
<div className="inv-subgroup">
|
||||
{WEAPON_TYPES.map(w => (
|
||||
<label className="inv-fitem" key={w.value}>
|
||||
<input type="checkbox" checked={f.weaponTypes.includes(w.value)}
|
||||
onChange={() => update({
|
||||
weaponTypes: f.weaponTypes.includes(w.value)
|
||||
? f.weaponTypes.filter(x => x !== w.value)
|
||||
: [...f.weaponTypes, w.value],
|
||||
})} /> {w.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
<Group title="Slots" defaultOpen badge={f.slots.length || undefined}>
|
||||
{JEWELRY_SLOTS.map(s => (
|
||||
<label className="inv-fitem" key={s}>
|
||||
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
|
||||
</label>
|
||||
))}
|
||||
<span className="inv-linky" onClick={() => setShowArmorSlots(v => !v)}>
|
||||
{showArmorSlots ? 'hide' : 'show'} armor slots {showArmorSlots ? '▴' : '▾'}
|
||||
</span>
|
||||
{showArmorSlots && ARMOR_SLOTS.map(s => (
|
||||
<label className="inv-fitem" key={s}>
|
||||
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
|
||||
</label>
|
||||
))}
|
||||
</Group>
|
||||
<CantripFilter search={search} />
|
||||
<Group title="Ratings" badge={ratingCount || undefined}>
|
||||
{RATING_DEFS.filter(r => allRatings || r.common).map(r => (
|
||||
<div className="inv-range" key={r.param}>
|
||||
<label>{r.label} ≥</label>
|
||||
<input type="number" value={f.ratings[r.param] ?? ''} placeholder="min"
|
||||
onChange={e => setRating(r.param, e.target.value)} />
|
||||
</div>
|
||||
))}
|
||||
<span className="inv-linky" onClick={() => setAllRatings(v => !v)}>
|
||||
{allRatings ? 'common ratings ▴' : `all ${RATING_DEFS.length} ratings ▾`}
|
||||
</span>
|
||||
</Group>
|
||||
<Group title="Equipment sets" badge={f.itemSet ? 1 : undefined}>
|
||||
<input className="inv-minisearch" placeholder="find set…" value={setQuery}
|
||||
onChange={e => setSetQuery(e.target.value)} />
|
||||
<label className="inv-fitem">
|
||||
<input type="radio" name="inv-set" checked={f.itemSet === ''}
|
||||
onChange={() => update({ itemSet: '' })} /> Any set
|
||||
</label>
|
||||
{sets.filter(s => s.id.toLowerCase().includes(setQuery.toLowerCase())).map(s => (
|
||||
<label className="inv-fitem" key={s.id}>
|
||||
<input type="radio" name="inv-set" checked={f.itemSet === s.id}
|
||||
onChange={() => update({ itemSet: s.id })} /> {s.id} <span className="inv-dim">({s.item_count})</span>
|
||||
</label>
|
||||
))}
|
||||
</Group>
|
||||
<Group title="Reqs & value" badge={reqCount || undefined}>
|
||||
<div className="inv-range"><label>Wield lvl ≤</label>
|
||||
<input type="number" value={f.maxLevel} placeholder="max"
|
||||
onChange={e => update({ maxLevel: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
|
||||
<div className="inv-range"><label>Value ≥</label>
|
||||
<input type="number" value={f.minValue} placeholder="min"
|
||||
onChange={e => update({ minValue: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
|
||||
<div className="inv-range"><label>Workmanship ≥</label>
|
||||
<input type="number" value={f.minWorkmanship} placeholder="min"
|
||||
onChange={e => update({ minWorkmanship: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
|
||||
<div className="inv-range"><label>Burden ≤</label>
|
||||
<input type="number" value={f.maxBurden} placeholder="max"
|
||||
onChange={e => update({ maxBurden: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { Component, useEffect, useState, type ErrorInfo, type ReactNode } from 'react';
|
||||
import { useInventorySearch } from './useInventorySearch';
|
||||
import { FilterSidebar } from './FilterSidebar';
|
||||
import { ActiveChips } from './ActiveChips';
|
||||
import { ResultsTable } from './ResultsTable';
|
||||
import { DetailPanel } from './DetailPanel';
|
||||
import type { InvItem } from './types';
|
||||
|
||||
/** Composite key used to re-match a selected item across refetches (objects are recreated each fetch). */
|
||||
function itemKey(i: InvItem): string {
|
||||
return `${i.name} ${i.character_name} ${i.last_updated ?? ''}`;
|
||||
}
|
||||
|
||||
class InventorySearchErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
|
||||
constructor(props: { children: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
static getDerivedStateFromError(): { hasError: boolean } {
|
||||
return { hasError: true };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('Inventory search crashed:', error, info);
|
||||
}
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="inv-page">
|
||||
<div className="inv-error" style={{ margin: 'auto', textAlign: 'center', padding: 24 }}>
|
||||
<p style={{ marginBottom: 12 }}>Something went wrong loading the inventory search page.</p>
|
||||
<button className="inv-btn" onClick={() => { window.location.href = '/?view=inventory'; }}>
|
||||
Reset filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function InventorySearchPageInner() {
|
||||
const search = useInventorySearch();
|
||||
const [selected, setSelected] = useState<InvItem | null>(null);
|
||||
|
||||
// Selection is by object identity; every refetch produces new item objects, so re-match the
|
||||
// previously selected item by composite key in the new result set (or clear it if it's gone).
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const items = search.result?.items ?? [];
|
||||
const key = itemKey(selected);
|
||||
const match = items.find(i => itemKey(i) === key);
|
||||
if (match && match !== selected) setSelected(match);
|
||||
else if (!match) setSelected(null);
|
||||
}, [search.result]);
|
||||
|
||||
return (
|
||||
<div className="inv-page">
|
||||
<div className="inv-topbar">
|
||||
<span className="inv-title">⚔ Inventory Search</span>
|
||||
<input
|
||||
className="inv-searchbox"
|
||||
placeholder="Search name or material…"
|
||||
value={search.filters.text}
|
||||
onChange={e => search.update({ text: e.target.value })}
|
||||
/>
|
||||
<button className="inv-btn" onClick={() => { search.reset(); setSelected(null); }}>Reset</button>
|
||||
<span className="inv-count">
|
||||
{search.error ? <span className="inv-error">{search.error}</span>
|
||||
: search.result ? <><b>{search.result.total_count.toLocaleString()}</b> items
|
||||
{search.queryMs != null && <> · {search.queryMs} ms</>}
|
||||
{search.loading && ' · …'}</>
|
||||
: 'loading…'}
|
||||
</span>
|
||||
</div>
|
||||
<ActiveChips search={search} />
|
||||
<div className="inv-main">
|
||||
<FilterSidebar search={search} />
|
||||
<ResultsTable search={search} selected={selected} onSelect={setSelected} />
|
||||
{selected && <DetailPanel item={selected} onClose={() => setSelected(null)} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InventorySearchPage() {
|
||||
return (
|
||||
<InventorySearchErrorBoundary>
|
||||
<InventorySearchPageInner />
|
||||
</InventorySearchErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { COLUMNS, COLUMNS_LS_KEY, RENDER_CHUNK } from './constants';
|
||||
import type { InventorySearch } from './useInventorySearch';
|
||||
import type { InvItem } from './types';
|
||||
|
||||
function loadVisible(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(COLUMNS_LS_KEY);
|
||||
if (raw) return new Set(JSON.parse(raw));
|
||||
} catch { /* fall through */ }
|
||||
return new Set(COLUMNS.filter(c => c.defaultVisible).map(c => c.key));
|
||||
}
|
||||
|
||||
function cell(item: InvItem, key: string): React.ReactNode {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return <>{item.name}{item.is_equipped && <span className="inv-equipped"> ⚔</span>}</>;
|
||||
case 'spell_names':
|
||||
return <span className="inv-spells">{(item.spell_names ?? []).map((s, i) => (
|
||||
<span key={i}>{i > 0 && ', '}<span className={/legendary/i.test(s) ? 'inv-leg' : ''}>{s}</span></span>
|
||||
))}</span>;
|
||||
case 'value':
|
||||
return item.value != null ? item.value.toLocaleString() : '—';
|
||||
case 'last_updated':
|
||||
return item.last_updated ? item.last_updated.slice(0, 16).replace('T', ' ') : '—';
|
||||
case 'od_rating': {
|
||||
const v = item.od_rating;
|
||||
return v == null ? '—' : String(v);
|
||||
}
|
||||
default: {
|
||||
const v = (item as any)[key];
|
||||
return v == null || v === -1 ? '—' : String(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ResultsTable({ search, selected, onSelect }: {
|
||||
search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void;
|
||||
}) {
|
||||
const { filters: f, update, result } = search;
|
||||
const [visible, setVisible] = useState<Set<string>>(loadVisible);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
// Incremental rendering: the full result set is loaded in one request, but
|
||||
// only renderCount rows are in the DOM; scrolling near the bottom grows it.
|
||||
const [renderCount, setRenderCount] = useState(RENDER_CHUNK);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const cols = useMemo(() => {
|
||||
const eff = new Set(visible);
|
||||
if (f.itemType === 'weapon') eff.add('od_rating');
|
||||
return COLUMNS.filter(c => eff.has(c.key));
|
||||
}, [visible, f.itemType]);
|
||||
const items = result?.items ?? [];
|
||||
const shown = items.slice(0, renderCount);
|
||||
|
||||
// New result set (search/sort/filter change) → jump back to the top.
|
||||
useEffect(() => {
|
||||
setRenderCount(RENDER_CHUNK);
|
||||
scrollRef.current?.scrollTo(0, 0);
|
||||
}, [result]);
|
||||
|
||||
const onScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || renderCount >= items.length) return;
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 600) {
|
||||
setRenderCount(c => Math.min(c + RENDER_CHUNK, items.length));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible]));
|
||||
} catch {
|
||||
/* Safari private mode throws on quota — non-fatal, column prefs just won't persist. */
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Keyboard: ↑/↓ moves selection, Esc clears — ignore while typing in inputs.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.target as HTMLElement)?.tagName === 'INPUT' || (e.target as HTMLElement)?.tagName === 'SELECT') return;
|
||||
if (e.key === 'Escape') { onSelect(null); return; }
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
e.preventDefault();
|
||||
const idx = selected ? items.indexOf(selected) : -1;
|
||||
const next = e.key === 'ArrowDown' ? Math.min(idx + 1, items.length - 1) : Math.max(idx - 1, 0);
|
||||
if (items[next]) {
|
||||
onSelect(items[next]);
|
||||
// Keep keyboard navigation working past the rendered window.
|
||||
if (next >= renderCount) setRenderCount(c => Math.min(c + RENDER_CHUNK, items.length));
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [items, selected, onSelect, renderCount]);
|
||||
|
||||
const sortOn = (sortKey?: string) => {
|
||||
if (!sortKey) return;
|
||||
if (f.sortBy === sortKey) update({ sortDir: f.sortDir === 'asc' ? 'desc' : 'asc' });
|
||||
else update({ sortBy: sortKey, sortDir: 'asc' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inv-results" ref={scrollRef} onScroll={onScroll}>
|
||||
<div className="inv-colpicker-anchor">
|
||||
<button className="inv-btn inv-colpicker-btn" onClick={() => setPickerOpen(o => !o)}>⚙ Columns</button>
|
||||
{pickerOpen && (
|
||||
<div className="inv-colpicker">
|
||||
{COLUMNS.map(c => (
|
||||
<label className="inv-fitem" key={c.key}>
|
||||
<input type="checkbox" checked={visible.has(c.key)} onChange={() => {
|
||||
const next = new Set(visible);
|
||||
if (next.has(c.key)) next.delete(c.key); else next.add(c.key);
|
||||
setVisible(next);
|
||||
}} /> {c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
{cols.map(c => (
|
||||
<th key={c.key} className={f.sortBy === c.sortKey ? `inv-sorted-${f.sortDir}` : ''}
|
||||
onClick={() => sortOn(c.sortKey)}>{c.label}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{shown.map((it, i) => (
|
||||
<tr key={i} className={it === selected ? 'inv-sel' : ''}
|
||||
onClick={() => onSelect(it === selected ? null : it)}>
|
||||
{cols.map(c => <td key={c.key}>{cell(it, c.key)}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
{!items.length && <tr><td colSpan={cols.length} className="inv-dim">No items match.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{renderCount < items.length && (
|
||||
<div className="inv-pager"><span className="inv-dim">Scroll for more… ({shown.length.toLocaleString()} of {items.length.toLocaleString()} shown)</span></div>
|
||||
)}
|
||||
{result?.has_next && renderCount >= items.length && (
|
||||
<div className="inv-pager"><span className="inv-dim">
|
||||
Showing the first {items.length.toLocaleString()} of {result.total_count.toLocaleString()} matches — narrow your filters to see the rest.
|
||||
</span></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
export interface CantripDef { value: string; label: string; }
|
||||
export interface CantripGroup { group: string; items: CantripDef[]; }
|
||||
|
||||
const c = (value: string): CantripDef => ({ value, label: value.replace(/^Legendary /, '') });
|
||||
|
||||
export const CANTRIP_GROUPS: CantripGroup[] = [
|
||||
{ group: 'Attributes', items: [
|
||||
c('Legendary Strength'), c('Legendary Endurance'), c('Legendary Quickness'),
|
||||
c('Legendary Coordination'), c('Legendary Willpower'), c('Legendary Focus'),
|
||||
]},
|
||||
{ group: 'Weapon skills', items: [
|
||||
c('Legendary Heavy Weapon Aptitude'), c('Legendary Light Weapon Aptitude'),
|
||||
c('Legendary Finesse Weapon Aptitude'), c('Legendary Missile Weapon Aptitude'),
|
||||
c('Legendary Two Handed Combat Aptitude'), c('Legendary Dual Wield Aptitude'),
|
||||
c('Legendary Shield Aptitude'), c('Legendary Sneak Attack Prowess'),
|
||||
c('Legendary Dirty Fighting Prowess'), c('Legendary Recklessness Prowess'),
|
||||
c('Legendary Defender'), c('Legendary Blood Thirst'),
|
||||
]},
|
||||
{ group: 'Magic', items: [
|
||||
c('Legendary War Magic Aptitude'), c('Legendary Void Magic Aptitude'),
|
||||
c('Legendary Creature Enchantment Aptitude'), c('Legendary Item Enchantment Aptitude'),
|
||||
c('Legendary Life Magic Aptitude'), c('Legendary Mana Conversion Prowess'),
|
||||
c('Legendary Arcane Prowess'), c('Legendary Hermetic Link'),
|
||||
c('Legendary Spirit Thirst'), c('Legendary Magic Resistance'),
|
||||
]},
|
||||
{ group: 'Utility', items: [
|
||||
c('Legendary Summoning Prowess'), c('Legendary Healing Prowess'),
|
||||
c('Legendary Leadership'), c('Legendary Deception Prowess'),
|
||||
c('Legendary Person Attunement'), c('Legendary Magic Item Tinkering Expertise'),
|
||||
]},
|
||||
{ group: 'Defense', items: [
|
||||
c('Legendary Invulnerability'), c('Legendary Impenetrability'),
|
||||
c('Legendary Impregnability'), c('Legendary Armor'),
|
||||
]},
|
||||
{ group: 'Wards & Banes', items: [
|
||||
c('Legendary Flame Ward'), c('Legendary Frost Ward'), c('Legendary Acid Ward'),
|
||||
c('Legendary Storm Ward'), c('Legendary Slashing Ward'), c('Legendary Piercing Ward'),
|
||||
c('Legendary Bludgeoning Ward'), c('Legendary Piercing Bane'), c('Legendary Storm Bane'),
|
||||
]},
|
||||
];
|
||||
|
||||
export const JEWELRY_SLOTS = ['Ring', 'Bracelet', 'Neck', 'Trinket', 'Cloak'];
|
||||
export const ARMOR_SLOTS = ['Head', 'Chest', 'Abdomen', 'Upper Arms', 'Lower Arms',
|
||||
'Hands', 'Upper Legs', 'Lower Legs', 'Feet', 'Shield'];
|
||||
|
||||
export const WEAPON_TYPES: Array<{ value: string; label: string }> = [
|
||||
{ value: 'heavy', label: 'Heavy' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'finesse', label: 'Finesse' },
|
||||
{ value: 'two_handed', label: 'Two-Handed' },
|
||||
{ value: 'missile', label: 'Missile' },
|
||||
{ value: 'war', label: 'War Magic' },
|
||||
{ value: 'void', label: 'Void Magic' },
|
||||
];
|
||||
|
||||
export interface RatingDef { param: string; label: string; common?: boolean; }
|
||||
export const RATING_DEFS: RatingDef[] = [
|
||||
{ param: 'min_od', label: 'Weapon OD', common: true },
|
||||
{ param: 'min_damage_rating', label: 'Damage rating', common: true },
|
||||
{ param: 'min_crit_damage_rating', label: 'Crit damage', common: true },
|
||||
{ param: 'min_heal_boost_rating', label: 'Heal boost', common: true },
|
||||
{ param: 'min_vitality_rating', label: 'Vitality', common: true },
|
||||
{ param: 'min_armor', label: 'Armor level' },
|
||||
{ param: 'min_damage_resist_rating', label: 'Damage resist' },
|
||||
{ param: 'min_crit_resist_rating', label: 'Crit resist' },
|
||||
{ param: 'min_crit_damage_resist_rating', label: 'Crit dmg resist' },
|
||||
{ param: 'min_healing_resist_rating', label: 'Healing resist' },
|
||||
{ param: 'min_nether_resist_rating', label: 'Nether resist' },
|
||||
{ param: 'min_healing_rating', label: 'Healing rating' },
|
||||
{ param: 'min_dot_resist_rating', label: 'DoT resist' },
|
||||
{ param: 'min_life_resist_rating', label: 'Life resist' },
|
||||
{ param: 'min_sneak_attack_rating', label: 'Sneak attack' },
|
||||
{ param: 'min_recklessness_rating', label: 'Recklessness' },
|
||||
{ param: 'min_deception_rating', label: 'Deception' },
|
||||
{ param: 'min_pk_damage_rating', label: 'PK damage' },
|
||||
{ param: 'min_pk_damage_resist_rating', label: 'PK dmg resist' },
|
||||
{ param: 'min_gear_pk_damage_rating', label: 'Gear PK dmg' },
|
||||
{ param: 'min_gear_pk_damage_resist_rating', label: 'Gear PK resist' },
|
||||
{ param: 'min_tinks', label: 'Tinks' },
|
||||
];
|
||||
|
||||
export interface ColumnDef {
|
||||
key: string; label: string; sortKey?: string; defaultVisible: boolean;
|
||||
}
|
||||
// sortKey values must exist in the backend's sortMapping (search.go).
|
||||
export const COLUMNS: ColumnDef[] = [
|
||||
{ key: 'name', label: 'Item', sortKey: 'name', defaultVisible: true },
|
||||
{ key: 'character_name', label: 'Character', sortKey: 'character_name', defaultVisible: true },
|
||||
{ key: 'slot_name', label: 'Slot', defaultVisible: true },
|
||||
{ key: 'value', label: 'Value', sortKey: 'value', defaultVisible: true },
|
||||
{ key: 'wield_level', label: 'Wield', sortKey: 'level', defaultVisible: true },
|
||||
{ key: 'spell_names', label: 'Spells / Cantrips', sortKey: 'spell_names', defaultVisible: true },
|
||||
{ key: 'armor_level', label: 'Armor', sortKey: 'armor', defaultVisible: false },
|
||||
{ key: 'max_damage', label: 'Max Dmg', sortKey: 'damage', defaultVisible: false },
|
||||
{ key: 'od_rating', label: 'OD', sortKey: 'od', defaultVisible: false },
|
||||
{ key: 'workmanship', label: 'Work', sortKey: 'workmanship', defaultVisible: false },
|
||||
{ key: 'item_set_name', label: 'Set', sortKey: 'item_set', defaultVisible: false },
|
||||
{ key: 'object_class_name', label: 'Type', sortKey: 'item_type_name', defaultVisible: false },
|
||||
{ key: 'burden', label: 'Burden', defaultVisible: false },
|
||||
{ key: 'condition_percent', label: 'Cond %', defaultVisible: false },
|
||||
{ key: 'last_updated', label: 'Updated', sortKey: 'last_updated', defaultVisible: false },
|
||||
];
|
||||
|
||||
// One-shot fetch: every search loads the full result set in a single request
|
||||
// (no pagination); rows are rendered incrementally on scroll. Capped at 10k —
|
||||
// a filterless all-characters browse is ~38k items / 65 MB JSON, which no
|
||||
// browser should be asked to swallow; real searches are a few thousand rows.
|
||||
export const PAGE_SIZE = 10000;
|
||||
export const RENDER_CHUNK = 300;
|
||||
export const COLUMNS_LS_KEY = 'inv.visibleColumns';
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
// Item shape returned by /api/inv/search/items (subset we render).
|
||||
export interface InvItem {
|
||||
name: string;
|
||||
character_name: string;
|
||||
slot_name: string | null;
|
||||
value: number | null;
|
||||
burden: number | null;
|
||||
wield_level: number | null;
|
||||
workmanship: number | null;
|
||||
spell_names?: string[];
|
||||
is_equipped: boolean;
|
||||
is_bonded: boolean;
|
||||
is_attuned: boolean;
|
||||
is_rare: boolean;
|
||||
item_set_name?: string;
|
||||
armor_level: number | null;
|
||||
max_damage: number | null;
|
||||
od_rating?: number | null;
|
||||
condition_percent: number | null;
|
||||
object_class_name?: string;
|
||||
material_name?: string;
|
||||
last_updated?: string;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
items: InvItem[];
|
||||
total_count: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
has_next: boolean;
|
||||
has_previous: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ItemType = 'all' | 'armor' | 'jewelry' | 'weapon' | 'clothing' | 'shirt' | 'pants';
|
||||
|
||||
export interface SearchFilters {
|
||||
text: string;
|
||||
/** 'all' → include_all_characters=true; otherwise explicit list. */
|
||||
characters: 'all' | string[];
|
||||
itemType: ItemType;
|
||||
weaponTypes: string[]; // [] = all weapon types
|
||||
slots: string[];
|
||||
cantrips: string[]; // full "Legendary X" value strings
|
||||
spellContains: string;
|
||||
/** min-rating param name -> value ('' = unset). */
|
||||
ratings: Record<string, number | ''>;
|
||||
itemSet: string; // set id, '' = none
|
||||
/** '' = any, or the backend's equipment_status values. */
|
||||
equipStatus: '' | 'equipped' | 'unequipped';
|
||||
bonded: boolean;
|
||||
attuned: boolean;
|
||||
rare: boolean;
|
||||
maxLevel: number | '';
|
||||
minValue: number | '';
|
||||
minWorkmanship: number | '';
|
||||
maxBurden: number | '';
|
||||
sortBy: string;
|
||||
sortDir: 'asc' | 'desc';
|
||||
page: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_FILTERS: SearchFilters = {
|
||||
text: '', characters: 'all', itemType: 'all', weaponTypes: [], slots: [],
|
||||
cantrips: [], spellContains: '', ratings: {}, itemSet: '',
|
||||
equipStatus: '', bonded: false, attuned: false, rare: false,
|
||||
maxLevel: '', minValue: '', minWorkmanship: '', maxBurden: '',
|
||||
sortBy: 'name', sortDir: 'asc', page: 1,
|
||||
};
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { DEFAULT_FILTERS, type SearchFilters, type SearchResponse } from './types';
|
||||
import { PAGE_SIZE } from './constants';
|
||||
|
||||
/** Serialize only the keys that differ from defaults, as a compact q= JSON param. */
|
||||
function filtersToUrl(f: SearchFilters): void {
|
||||
const diff: Partial<SearchFilters> = {};
|
||||
for (const k of Object.keys(DEFAULT_FILTERS) as Array<keyof SearchFilters>) {
|
||||
if (JSON.stringify(f[k]) !== JSON.stringify(DEFAULT_FILTERS[k])) (diff as any)[k] = f[k];
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('view', 'inventory');
|
||||
if (Object.keys(diff).length) url.searchParams.set('q', JSON.stringify(diff));
|
||||
else url.searchParams.delete('q');
|
||||
try {
|
||||
window.history.replaceState(null, '', url);
|
||||
} catch {
|
||||
/* Safari throttles history.replaceState and throws when exceeded — non-fatal. */
|
||||
}
|
||||
}
|
||||
|
||||
const ITEM_TYPES = new Set(['all', 'armor', 'jewelry', 'weapon', 'clothing', 'shirt', 'pants']);
|
||||
const SORT_DIRS = new Set(['asc', 'desc']);
|
||||
|
||||
function isStringArray(v: unknown): v is string[] {
|
||||
return Array.isArray(v) && v.every(x => typeof x === 'string');
|
||||
}
|
||||
|
||||
/** Validate a single filter key's parsed value; return the valid value or undefined to drop it. */
|
||||
function validateFilterValue(key: keyof SearchFilters, value: unknown): unknown {
|
||||
switch (key) {
|
||||
case 'characters':
|
||||
if (value === 'all' || isStringArray(value)) return value;
|
||||
return undefined;
|
||||
case 'slots':
|
||||
case 'cantrips':
|
||||
case 'weaponTypes':
|
||||
return isStringArray(value) ? value : undefined;
|
||||
case 'itemType':
|
||||
return typeof value === 'string' && ITEM_TYPES.has(value) ? value : undefined;
|
||||
case 'sortDir':
|
||||
return typeof value === 'string' && SORT_DIRS.has(value) ? value : undefined;
|
||||
case 'ratings': {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
||||
const out: Record<string, number | ''> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (v === '' || typeof v === 'number') out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case 'text':
|
||||
case 'spellContains':
|
||||
case 'itemSet':
|
||||
case 'sortBy':
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
case 'equipStatus':
|
||||
return value === '' || value === 'equipped' || value === 'unequipped' ? value : undefined;
|
||||
case 'bonded':
|
||||
case 'attuned':
|
||||
case 'rare':
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
case 'maxLevel':
|
||||
case 'minValue':
|
||||
case 'minWorkmanship':
|
||||
case 'maxBurden':
|
||||
return value === '' || typeof value === 'number' ? value : undefined;
|
||||
case 'page':
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function filtersFromUrl(): SearchFilters {
|
||||
try {
|
||||
const q = new URLSearchParams(window.location.search).get('q');
|
||||
if (!q) return DEFAULT_FILTERS;
|
||||
const parsed = JSON.parse(q);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return DEFAULT_FILTERS;
|
||||
const merged: SearchFilters = { ...DEFAULT_FILTERS };
|
||||
for (const k of Object.keys(DEFAULT_FILTERS) as Array<keyof SearchFilters>) {
|
||||
if (!(k in parsed)) continue;
|
||||
const validated = validateFilterValue(k, (parsed as any)[k]);
|
||||
if (validated !== undefined) (merged as any)[k] = validated;
|
||||
}
|
||||
return merged;
|
||||
} catch {
|
||||
return DEFAULT_FILTERS;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildParams(f: SearchFilters): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
if (f.characters === 'all') p.set('include_all_characters', 'true');
|
||||
else if (f.characters.length === 1) p.set('character', f.characters[0]);
|
||||
else p.set('characters', f.characters.join(','));
|
||||
|
||||
if (f.text) p.set('text', f.text);
|
||||
switch (f.itemType) {
|
||||
case 'armor': p.set('armor_only', 'true'); break;
|
||||
case 'jewelry': p.set('jewelry_only', 'true'); break;
|
||||
case 'clothing': p.set('clothing_only', 'true'); break;
|
||||
case 'shirt': p.set('shirt_only', 'true'); break;
|
||||
case 'pants': p.set('pants_only', 'true'); break;
|
||||
case 'weapon':
|
||||
p.set('weapon_only', 'true');
|
||||
if (f.weaponTypes.length) p.set('weapon_types', f.weaponTypes.join(','));
|
||||
break;
|
||||
}
|
||||
if (f.slots.length) p.set('slot_names', f.slots.join(','));
|
||||
if (f.cantrips.length) p.set('legendary_cantrips', f.cantrips.join(','));
|
||||
if (f.spellContains) p.set('spell_contains', f.spellContains);
|
||||
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') p.set(param, String(v));
|
||||
if (f.itemSet) p.set('item_set', f.itemSet);
|
||||
if (f.equipStatus) p.set('equipment_status', f.equipStatus);
|
||||
if (f.bonded) p.set('bonded', 'true');
|
||||
if (f.attuned) p.set('attuned', 'true');
|
||||
if (f.rare) p.set('is_rare', 'true');
|
||||
if (f.maxLevel !== '') p.set('max_level', String(f.maxLevel));
|
||||
if (f.minValue !== '') p.set('min_value', String(f.minValue));
|
||||
if (f.minWorkmanship !== '') p.set('min_workmanship', String(f.minWorkmanship));
|
||||
if (f.maxBurden !== '') p.set('max_burden', String(f.maxBurden));
|
||||
p.set('sort_by', f.sortBy);
|
||||
p.set('sort_dir', f.sortDir);
|
||||
p.set('page', String(f.page));
|
||||
p.set('limit', String(PAGE_SIZE));
|
||||
return p;
|
||||
}
|
||||
|
||||
export interface InventorySearch {
|
||||
filters: SearchFilters;
|
||||
/** Patch filters; resets page to 1 unless the patch itself sets page. */
|
||||
update: (patch: Partial<SearchFilters>) => void;
|
||||
reset: () => void;
|
||||
result: SearchResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
queryMs: number | null;
|
||||
}
|
||||
|
||||
export function useInventorySearch(): InventorySearch {
|
||||
const [filters, setFilters] = useState<SearchFilters>(filtersFromUrl);
|
||||
const [result, setResult] = useState<SearchResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [queryMs, setQueryMs] = useState<number | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const update = useCallback((patch: Partial<SearchFilters>) => {
|
||||
setFilters(prev => ({ ...prev, page: 'page' in patch ? prev.page : 1, ...patch }));
|
||||
}, []);
|
||||
const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
filtersToUrl(filters);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const res = await fetch(`/api/inv/search/items?${buildParams(filters)}`, {
|
||||
credentials: 'include', signal: ctrl.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`search: HTTP ${res.status}`);
|
||||
const data: SearchResponse = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
setResult(data);
|
||||
setError(null);
|
||||
setQueryMs(Math.round(performance.now() - t0));
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
if (abortRef.current === ctrl) setLoading(false);
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [filters]);
|
||||
|
||||
return { filters, update, reset, result, loading, error, queryMs };
|
||||
}
|
||||
|
|
@ -98,7 +98,7 @@ export const Sidebar: React.FC<Props> = ({
|
|||
|
||||
{/* Tool links */}
|
||||
<div className="ml-tool-links">
|
||||
<a href="/?view=inventory" target="_blank" className="ml-tool-link">🔍 Inv Search</a>
|
||||
<a href="/inventory.html" target="_blank" className="ml-tool-link">🔍 Inv Search</a>
|
||||
<a href="/suitbuilder.html" target="_blank" className="ml-tool-link">🛡️ Suitbuilder</a>
|
||||
<a href="/debug.html" target="_blank" className="ml-tool-link">🐛 Debug</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
/* Inventory Search (/?view=inventory) — dark theme ported from
|
||||
docs/superpowers/specs/2026-07-15-inventory-search-redesign-mockup.html
|
||||
All rules are scoped under .inv-* classes (this SPA also renders the
|
||||
map/dashboard views, so no bare element selectors at file scope). */
|
||||
|
||||
.inv-page, .inv-page * { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
.inv-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
.inv-main { display: flex; flex: 1; overflow: hidden; }
|
||||
.inv-error { color: #c66; }
|
||||
|
||||
/* ── Top bar ── */
|
||||
.inv-topbar { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: #1a1a1a; border-bottom: 2px solid #333; }
|
||||
.inv-title { color: #88f; font-weight: 600; font-size: 15px; white-space: nowrap; }
|
||||
.inv-searchbox { flex: 1; max-width: 520px; background: #222; border: 1px solid #444; border-radius: 4px; padding: 7px 12px; color: #eee; font-size: 13px; outline: none; }
|
||||
.inv-searchbox:focus { border-color: #88f; }
|
||||
.inv-searchbox::placeholder { color: #666; }
|
||||
.inv-btn { background: #333; border: 1px solid #555; color: #ccc; border-radius: 4px; padding: 6px 14px; font-size: 12px; cursor: pointer; }
|
||||
.inv-btn:hover { background: #444; color: #fff; }
|
||||
.inv-count { margin-left: auto; color: #888; font-size: 12px; white-space: nowrap; }
|
||||
.inv-count b { color: #eee; }
|
||||
|
||||
/* ── Chips ── */
|
||||
.inv-chipsrow { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 8px 14px; background: #151515; border-bottom: 1px solid #2a2a2a; min-height: 37px; }
|
||||
.inv-chips-lbl { color: #666; font-size: 10px; letter-spacing: 1px; }
|
||||
.inv-chip { display: inline-flex; align-items: center; gap: 6px; background: #23233a; border: 1px solid #55f; border-radius: 12px; padding: 2px 10px; font-size: 11px; color: #bbd; cursor: default; }
|
||||
.inv-chip-x { color: #c66; cursor: pointer; font-weight: bold; }
|
||||
.inv-chip-x:hover { color: #f88; }
|
||||
.inv-chip-gold { border-color: #a80; color: #fc6; background: #2a2418; }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.inv-sidebar { width: 210px; min-width: 210px; background: #1a1a1a; border-right: 2px solid #333; overflow-y: auto; padding: 8px 10px; }
|
||||
.inv-grp { border-bottom: 1px solid #262626; padding: 6px 0; }
|
||||
.inv-grp-head { display: flex; align-items: center; gap: 6px; cursor: pointer; color: #999; text-transform: uppercase; font-size: 10px; letter-spacing: 1px; padding: 3px 0; user-select: none; }
|
||||
.inv-grp-head:hover { color: #ccc; }
|
||||
.inv-arrow { font-size: 9px; transition: transform .15s; }
|
||||
.inv-grp.inv-open .inv-arrow { transform: rotate(90deg); }
|
||||
.inv-badge { margin-left: auto; background: #3a3a6e; color: #cce; border-radius: 8px; padding: 0 7px; font-size: 9px; }
|
||||
.inv-grp-body { padding: 5px 0 3px; }
|
||||
.inv-minisearch { width: 100%; background: #222; border: 1px solid #3a3a3a; border-radius: 3px; padding: 4px 8px; color: #ccc; font-size: 11px; outline: none; margin-bottom: 5px; }
|
||||
.inv-minisearch::placeholder { color: #555; }
|
||||
.inv-fitem { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 12px; color: #bbb; cursor: pointer; }
|
||||
.inv-fitem:hover { color: #fff; }
|
||||
.inv-fitem input { accent-color: #88f; }
|
||||
.inv-subgroup { margin: 2px 0 2px 10px; }
|
||||
.inv-subhead { color: #666; font-size: 9px; text-transform: uppercase; letter-spacing: 1px; margin: 6px 0 2px; }
|
||||
.inv-linky { color: #88f; font-size: 10px; cursor: pointer; }
|
||||
.inv-linky:hover { text-decoration: underline; }
|
||||
.inv-range { display: flex; gap: 4px; align-items: center; margin: 2px 0; }
|
||||
.inv-range label { flex: 1; color: #999; font-size: 11px; }
|
||||
.inv-range input { width: 52px; background: #222; border: 1px solid #3a3a3a; border-radius: 3px; color: #ccc; padding: 2px 5px; font-size: 11px; }
|
||||
|
||||
/* ── Results ── */
|
||||
.inv-results { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
.inv-results table { width: 100%; border-collapse: collapse; }
|
||||
.inv-results thead { position: sticky; top: 0; background: #191919; z-index: 1; }
|
||||
.inv-results th { text-align: left; color: #88f; font-size: 11px; font-weight: 600; padding: 8px 10px; border-bottom: 2px solid #333; cursor: pointer; white-space: nowrap; user-select: none; }
|
||||
.inv-results th:hover { color: #aaf; }
|
||||
.inv-results td { padding: 6px 10px; border-bottom: 1px solid #1e1e1e; font-size: 12px; color: #bbb; vertical-align: top; }
|
||||
.inv-results tbody tr { cursor: pointer; }
|
||||
.inv-results tbody tr:hover td { background: #191922; }
|
||||
.inv-results tbody tr.inv-sel td { background: #20203a; }
|
||||
.inv-results td.inv-num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.inv-leg { color: #fc6; }
|
||||
.inv-spells { color: #889; font-size: 11px; }
|
||||
.inv-equipped { color: #4c4; font-size: 10px; }
|
||||
.inv-pager { display: flex; align-items: center; gap: 8px; padding: 8px 14px; color: #888; font-size: 12px; border-top: 1px solid #262626; background: #151515; margin-top: auto; }
|
||||
.inv-pg { padding: 2px 8px; border: 1px solid #333; border-radius: 3px; cursor: pointer; }
|
||||
.inv-pg.inv-cur { background: #3a3a6e; border-color: #88f; color: #fff; }
|
||||
|
||||
/* ── Detail panel ── */
|
||||
.inv-detail { width: 250px; min-width: 250px; background: #161620; border-left: 2px solid #333; overflow-y: auto; padding: 12px; }
|
||||
.inv-detail h3 { color: #88f; font-size: 14px; margin-bottom: 2px; }
|
||||
.inv-detail-sub { color: #777; font-size: 11px; margin-bottom: 10px; }
|
||||
.inv-kv { display: flex; justify-content: space-between; padding: 2px 0; font-size: 12px; color: #999; }
|
||||
.inv-kv b { color: #ddd; font-weight: normal; text-align: right; }
|
||||
.inv-detail hr { border: none; border-top: 1px solid #2a2a35; margin: 9px 0; }
|
||||
.inv-sphead { color: #666; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
|
||||
.inv-sp { padding: 2px 0; font-size: 12px; color: #bbb; }
|
||||
.inv-hint { color: #555; font-size: 10px; margin-top: 12px; }
|
||||
.inv-closex { float: right; color: #666; cursor: pointer; font-size: 14px; }
|
||||
.inv-closex:hover { color: #f88; }
|
||||
|
||||
/* ── FilterSidebar additions (Task 4) ── */
|
||||
.inv-dim { color: #666; font-size: 10px; }
|
||||
.inv-online { width: 6px; height: 6px; border-radius: 50%; background: #4c4; display: inline-block; margin-left: 4px; }
|
||||
.inv-links { margin-bottom: 4px; font-size: 10px; }
|
||||
.inv-gold { color: #fc6; }
|
||||
|
||||
/* ── ResultsTable additions (Task 5) ── */
|
||||
.inv-colpicker-anchor { position: relative; align-self: flex-end; padding: 4px 10px; }
|
||||
.inv-colpicker { position: absolute; right: 10px; top: 30px; background: #1a1a1a; border: 1px solid #444; border-radius: 4px; padding: 8px; z-index: 5; max-height: 300px; overflow-y: auto; }
|
||||
.inv-sorted-asc::after { content: " ▲"; font-size: 9px; }
|
||||
.inv-sorted-desc::after { content: " ▼"; font-size: 9px; }
|
||||
.inv-pager-right { margin-left: auto; }
|
||||
|
|
@ -1 +1 @@
|
|||
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/endpoints.ts","./src/components/playerdashboardfullpage.tsx","./src/components/effects/deathnotification.tsx","./src/components/effects/rarenotification.tsx","./src/components/inventory/activechips.tsx","./src/components/inventory/cantripfilter.tsx","./src/components/inventory/characterfilter.tsx","./src/components/inventory/detailpanel.tsx","./src/components/inventory/filtersidebar.tsx","./src/components/inventory/inventorysearchpage.tsx","./src/components/inventory/resultstable.tsx","./src/components/inventory/constants.ts","./src/components/inventory/types.ts","./src/components/inventory/useinventorysearch.ts","./src/components/map/heatmapcanvas.tsx","./src/components/map/maplayout.tsx","./src/components/map/mapview.tsx","./src/components/map/playerdots.tsx","./src/components/map/portalmarkers.tsx","./src/components/map/sidebar.tsx","./src/components/map/trailssvg.tsx","./src/components/midsummer/frogtoggle.tsx","./src/components/midsummer/maypole.tsx","./src/components/midsummer/midsummerbanner.tsx","./src/components/midsummer/midsummerrain.tsx","./src/components/sidebar/playerlist.tsx","./src/components/sidebar/playerrow.tsx","./src/components/sidebar/sidebarwindowbuttons.tsx","./src/components/sidebar/sortbuttons.tsx","./src/components/windows/adminuserswindow.tsx","./src/components/windows/agentwindow.tsx","./src/components/windows/characterwindow.tsx","./src/components/windows/chatwindow.tsx","./src/components/windows/combatpickerwindow.tsx","./src/components/windows/combatstatswindow.tsx","./src/components/windows/draggablewindow.tsx","./src/components/windows/inventorywindow.tsx","./src/components/windows/issueswindow.tsx","./src/components/windows/playerdashboardwindow.tsx","./src/components/windows/queststatuswindow.tsx","./src/components/windows/radarwindow.tsx","./src/components/windows/statswindow.tsx","./src/components/windows/vitalsharingwindow.tsx","./src/components/windows/windowrenderer.tsx","./src/contexts/windowmanagercontext.tsx","./src/hooks/usecurrentuser.ts","./src/hooks/uselivedata.ts","./src/hooks/usemidsummer.tsx","./src/hooks/useplayercolors.ts","./src/hooks/usewebsocket.ts","./src/types/index.ts","./src/utils/coordinates.ts"],"version":"5.8.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/endpoints.ts","./src/components/effects/deathnotification.tsx","./src/components/effects/rarenotification.tsx","./src/components/map/heatmapcanvas.tsx","./src/components/map/maplayout.tsx","./src/components/map/mapview.tsx","./src/components/map/playerdots.tsx","./src/components/map/portalmarkers.tsx","./src/components/map/sidebar.tsx","./src/components/map/trailssvg.tsx","./src/components/sidebar/playerlist.tsx","./src/components/sidebar/playerrow.tsx","./src/components/sidebar/sidebarwindowbuttons.tsx","./src/components/sidebar/sortbuttons.tsx","./src/components/windows/characterwindow.tsx","./src/components/windows/chatwindow.tsx","./src/components/windows/combatpickerwindow.tsx","./src/components/windows/combatstatswindow.tsx","./src/components/windows/draggablewindow.tsx","./src/components/windows/inventorywindow.tsx","./src/components/windows/issueswindow.tsx","./src/components/windows/playerdashboardwindow.tsx","./src/components/windows/queststatuswindow.tsx","./src/components/windows/radarwindow.tsx","./src/components/windows/statswindow.tsx","./src/components/windows/vitalsharingwindow.tsx","./src/components/windows/windowrenderer.tsx","./src/contexts/windowmanagercontext.tsx","./src/hooks/uselivedata.ts","./src/hooks/useplayercolors.ts","./src/hooks/usewebsocket.ts","./src/types/index.ts","./src/utils/coordinates.ts"],"version":"5.8.3"}
|
||||
9
go-services/.gitattributes
vendored
9
go-services/.gitattributes
vendored
|
|
@ -1,9 +0,0 @@
|
|||
# Go services run on Linux; keep LF in the working tree on all platforms.
|
||||
* text=auto eol=lf
|
||||
*.go text eol=lf
|
||||
*.mod text eol=lf
|
||||
*.sum text eol=lf
|
||||
*.py text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.conf text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Structural + value parity check for the tracker-go read API vs the Python service.
|
||||
|
||||
Run on the server (loopback access to both, plus `docker exec dereth-db` for the
|
||||
offline-character exact-match check):
|
||||
python3 compare_endpoints.py
|
||||
|
||||
Most live endpoints can't be value-equal byte-for-byte (the firehose updates
|
||||
between fetches), so we assert:
|
||||
* status code + top-level key-set parity for every read endpoint, and
|
||||
* EXACT equality of /character-stats and /combat-stats for *offline*
|
||||
characters (where Python also falls back to the DB, like Go). For online
|
||||
characters Python serves a richer live in-memory overlay that Phase-1 Go
|
||||
intentionally lacks (no ingest yet) — that difference is expected.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
PY = "http://127.0.0.1:8765"
|
||||
GO = "http://127.0.0.1:8770"
|
||||
|
||||
|
||||
def get(base, path):
|
||||
try:
|
||||
with urllib.request.urlopen(base + path, timeout=12) as r:
|
||||
return r.status, json.load(r)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, None
|
||||
except Exception as e: # noqa: BLE001
|
||||
return "ERR:" + str(e)[:40], None
|
||||
|
||||
|
||||
def topkeys(d):
|
||||
if isinstance(d, dict):
|
||||
return sorted(d.keys())
|
||||
if isinstance(d, list):
|
||||
return ["[list]"]
|
||||
return [type(d).__name__]
|
||||
|
||||
|
||||
def main():
|
||||
failures = 0
|
||||
_, live = get(PY, "/live")
|
||||
ch = live["players"][0]["character_name"] if live and live.get("players") else "Nobody"
|
||||
chq = urllib.parse.quote(ch)
|
||||
print(f"sample online character: {ch}\n")
|
||||
|
||||
endpoints = [
|
||||
"/total-rares", "/total-kills", "/server-health", "/portals",
|
||||
"/spawns/heatmap?hours=2", "/combat-stats", "/inventories",
|
||||
"/quest-status", "/vital-sharing/peers",
|
||||
f"/stats/{chq}", f"/combat-stats/{chq}",
|
||||
f"/inventory/{chq}/search", "/sets/list", "/inventory-characters",
|
||||
]
|
||||
print(f"{'endpoint':<36} {'py':>5} {'go':>5} keys")
|
||||
for ep in endpoints:
|
||||
ps, pj = get(PY, ep)
|
||||
gs, gj = get(GO, ep)
|
||||
pk, gk = topkeys(pj), topkeys(gj)
|
||||
ok = ps == gs and pk == gk
|
||||
if not ok:
|
||||
failures += 1
|
||||
print(f"{ep:<36} {str(ps):>5} {str(gs):>5} {'OK' if ok else 'MISMATCH py=%s go=%s' % (pk, gk)}")
|
||||
|
||||
# Online-overlay endpoints: only structural note (expected to differ for online chars).
|
||||
for ep in (f"/character-stats/{chq}", f"/equipment-cantrip-state/{chq}"):
|
||||
ps, _ = get(PY, ep)
|
||||
gs, _ = get(GO, ep)
|
||||
print(f"{ep:<36} {str(ps):>5} {str(gs):>5} (online live-overlay; exact match only for offline chars)")
|
||||
|
||||
# Offline-character exact-match check.
|
||||
print("\n-- offline-character exact match (/character-stats, /combat-stats) --")
|
||||
try:
|
||||
online = {p["character_name"] for p in live["players"]}
|
||||
names = subprocess.check_output(
|
||||
["docker", "exec", "dereth-db", "psql", "-U", "postgres", "-d", "dereth",
|
||||
"-tA", "-c", "SELECT character_name FROM character_stats"], text=True)
|
||||
off = [n for n in names.split("\n") if n.strip() and n not in online]
|
||||
tested = matched = 0
|
||||
for ch in off:
|
||||
q = urllib.parse.quote(ch)
|
||||
_, py = get(PY, f"/character-stats/{q}")
|
||||
_, go = get(GO, f"/character-stats/{q}")
|
||||
if not (isinstance(py, dict) and len(py.keys()) >= 18):
|
||||
continue
|
||||
tested += 1
|
||||
same = py == go
|
||||
matched += same
|
||||
if not same:
|
||||
failures += 1
|
||||
print(f" MISMATCH {ch}: keydiff={set(py) ^ set(go)}")
|
||||
if tested >= 8:
|
||||
break
|
||||
print(f" {matched}/{tested} rich offline chars exact-match")
|
||||
if tested == 0:
|
||||
print(" (no offline rich characters available to test)")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" (skipped DB-backed offline check: {e})")
|
||||
|
||||
print("\n" + ("RESULT: read-API parity OK" if failures == 0
|
||||
else f"RESULT: {failures} mismatch(es)"))
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate the Go shadow ingest (dereth_go) against production (dereth).
|
||||
|
||||
Run on the server. The shadow tracker replays Python's /ws/live firehose into
|
||||
its own dereth_go DB. Absolute counts differ (shadow started fresh; char_stats /
|
||||
rare_stats accumulate deltas from connect time), so we validate the paths whose
|
||||
writes are FULL upserts/inserts and therefore exactly comparable:
|
||||
|
||||
* character_stats: a full-payload upsert. For a character whose row has the
|
||||
SAME timestamp in both DBs, stats_data must be byte-identical.
|
||||
* /live online set: telemetry end-to-end (compared separately by the caller).
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
SEP = "\x1f"
|
||||
|
||||
|
||||
def q(container, db, sql):
|
||||
out = subprocess.check_output(
|
||||
["docker", "exec", container, "psql", "-U", "postgres", "-d", db, "-tA", "-F", SEP, "-c", sql],
|
||||
text=True)
|
||||
return [line.split(SEP) for line in out.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def main():
|
||||
print("=== dereth_go ingested row counts ===")
|
||||
counts = q("dereth-go-db", "dereth_go", """
|
||||
SELECT 'telemetry_events', count(*)::text FROM telemetry_events
|
||||
UNION ALL SELECT 'telemetry_distinct_chars', count(distinct character_name)::text FROM telemetry_events
|
||||
UNION ALL SELECT 'character_stats', count(*)::text FROM character_stats
|
||||
UNION ALL SELECT 'char_stats', count(*)::text FROM char_stats
|
||||
UNION ALL SELECT 'rare_events', count(*)::text FROM rare_events
|
||||
UNION ALL SELECT 'rare_stats', count(*)::text FROM rare_stats
|
||||
UNION ALL SELECT 'portals', count(*)::text FROM portals
|
||||
""")
|
||||
for k, v in counts:
|
||||
print(f" {k:26} {v}")
|
||||
|
||||
print("\n=== character_stats exact match (same-timestamp rows) ===")
|
||||
prod = {r[0]: (r[1], r[2]) for r in
|
||||
q("dereth-db", "dereth", "SELECT character_name, timestamp::text, stats_data::text FROM character_stats")}
|
||||
shadow = q("dereth-go-db", "dereth_go",
|
||||
"SELECT character_name, timestamp::text, stats_data::text FROM character_stats")
|
||||
match = mismatch = newer = 0
|
||||
for name, ts, sd in shadow:
|
||||
if name not in prod:
|
||||
continue
|
||||
pts, psd = prod[name]
|
||||
if ts != pts:
|
||||
newer += 1 # one side got a newer character_stats message; not comparable
|
||||
continue
|
||||
if json.loads(sd) == json.loads(psd):
|
||||
match += 1
|
||||
else:
|
||||
mismatch += 1
|
||||
print(f" MISMATCH {name}")
|
||||
print(f" exact match={match} mismatch={mismatch} skipped(diff timestamp)={newer}")
|
||||
print("\nRESULT:", "ingest OK" if mismatch == 0 else f"{mismatch} character_stats mismatch(es)")
|
||||
return 1 if mismatch else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Compare the Go tracker's /live (and /trails) against the live Python service.
|
||||
|
||||
Run on the server (or anywhere with loopback access to both):
|
||||
python3 compare_live.py # default loopback ports
|
||||
python3 compare_live.py --py http://127.0.0.1:8765 --go http://127.0.0.1:8770
|
||||
|
||||
Parity strategy for a live firehose
|
||||
-----------------------------------
|
||||
The two services rebuild their /live cache independently every 5s, so an
|
||||
actively-updating character can legitimately show a newer telemetry row in one
|
||||
than the other. We separate "is this a real divergence?" from "is this just
|
||||
cache timing?" using the server-stamped received_at:
|
||||
|
||||
* SAME ROW (py.received_at == go.received_at): both rendered the *same*
|
||||
telemetry_events row -> every field MUST match (numbers within epsilon,
|
||||
timestamps compared as instants). This is the rigorous render-parity proof.
|
||||
* DIFFERENT ROW: a newer row arrived between the two refreshes -> we only
|
||||
require identity + key-set + type/null-pattern parity, and report the
|
||||
volatile-field skew (which should be small and recent).
|
||||
|
||||
Exit code 0 if no real parity violations, 1 otherwise.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
EPS = 1e-6
|
||||
|
||||
# Fields that identify the entity / join keys — must always match for a player
|
||||
# present in both outputs.
|
||||
IDENTITY = ("character_name", "char_tag", "session_id")
|
||||
# Slowly-changing aggregates — informational when they differ on a same-row pair
|
||||
# (a kill/rare recorded between refreshes can bump these even for the same
|
||||
# telemetry row).
|
||||
AGGREGATES = ("total_kills", "total_rares", "session_rares")
|
||||
TIMESTAMP_FIELDS = ("timestamp", "received_at")
|
||||
|
||||
|
||||
def fetch(base, path):
|
||||
with urllib.request.urlopen(base.rstrip("/") + path, timeout=8) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def jtype(v):
|
||||
if v is None:
|
||||
return "null"
|
||||
if isinstance(v, bool):
|
||||
return "bool"
|
||||
if isinstance(v, (int, float)):
|
||||
return "num"
|
||||
if isinstance(v, str):
|
||||
return "str"
|
||||
return type(v).__name__
|
||||
|
||||
|
||||
def parse_ts(s):
|
||||
if s is None:
|
||||
return None
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def values_equal(key, a, b):
|
||||
"""Semantic equality for a single field value."""
|
||||
if a is None or b is None:
|
||||
return a is b or a == b
|
||||
if key in TIMESTAMP_FIELDS and isinstance(a, str) and isinstance(b, str):
|
||||
return parse_ts(a) == parse_ts(b)
|
||||
an, bn = isinstance(a, (int, float)) and not isinstance(a, bool), isinstance(b, (int, float)) and not isinstance(b, bool)
|
||||
if an and bn:
|
||||
return abs(float(a) - float(b)) <= EPS
|
||||
return a == b
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--py", default="http://127.0.0.1:8765")
|
||||
ap.add_argument("--go", default="http://127.0.0.1:8770")
|
||||
args = ap.parse_args()
|
||||
|
||||
py = fetch(args.py, "/live")["players"]
|
||||
go = fetch(args.go, "/live")["players"]
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
pyi = {p["character_name"]: p for p in py}
|
||||
goi = {p["character_name"]: p for p in go}
|
||||
common = sorted(set(pyi) & set(goi))
|
||||
only_py = sorted(set(pyi) - set(goi))
|
||||
only_go = sorted(set(goi) - set(pyi))
|
||||
|
||||
print("=" * 72)
|
||||
print("/live PARITY python(%s) vs go(%s)" % (args.py, args.go))
|
||||
print("=" * 72)
|
||||
print(f"python players : {len(py)}")
|
||||
print(f"go players : {len(go)}")
|
||||
print(f"common : {len(common)}")
|
||||
|
||||
violations = 0
|
||||
|
||||
# --- key-set parity (all players) ---
|
||||
py_keys = set().union(*[set(p) for p in py]) if py else set()
|
||||
go_keys = set().union(*[set(p) for p in go]) if go else set()
|
||||
if py_keys == go_keys:
|
||||
print(f"key set : IDENTICAL ({len(py_keys)} keys)")
|
||||
else:
|
||||
violations += 1
|
||||
print("key set : MISMATCH")
|
||||
print(" only in python:", sorted(py_keys - go_keys))
|
||||
print(" only in go :", sorted(go_keys - py_keys))
|
||||
|
||||
# --- online-set parity (boundary-aware) ---
|
||||
def age(p):
|
||||
ts = parse_ts(p.get("received_at") or p.get("timestamp"))
|
||||
return (now - ts).total_seconds() if ts else None
|
||||
|
||||
print("\n-- online set --")
|
||||
if not only_py and not only_go:
|
||||
print("online set : IDENTICAL")
|
||||
else:
|
||||
# Players near the 30s boundary can flap between the two refreshes.
|
||||
def explain(names, idx):
|
||||
for n in names:
|
||||
a = age(idx[n])
|
||||
tag = "boundary-flap (age %.1fs)" % a if a is not None and 22 <= a <= 38 else "age %s" % (None if a is None else round(a, 1))
|
||||
print(f" only_{('py' if idx is pyi else 'go')}: {n:<20} {tag}")
|
||||
if only_py:
|
||||
print(f"only in python : {len(only_py)}")
|
||||
explain(only_py, pyi)
|
||||
if only_go:
|
||||
print(f"only in go : {len(only_go)}")
|
||||
explain(only_go, goi)
|
||||
unexplained = [n for n in (only_py + only_go)
|
||||
if not (lambda a: a is not None and 22 <= a <= 38)(age((pyi.get(n) or goi.get(n))))]
|
||||
if unexplained:
|
||||
violations += 1
|
||||
print(" UNEXPLAINED set difference (not near 30s boundary):", unexplained)
|
||||
else:
|
||||
print(" (all set differences explained by the 30s online boundary)")
|
||||
|
||||
# --- per-player field parity ---
|
||||
same_row = [] # py.received_at == go.received_at -> must fully match
|
||||
diff_row = [] # newer row arrived between refreshes
|
||||
for n in common:
|
||||
a, b = pyi[n], goi[n]
|
||||
if a.get("received_at") is not None and a.get("received_at") == b.get("received_at"):
|
||||
same_row.append(n)
|
||||
else:
|
||||
diff_row.append(n)
|
||||
|
||||
print("\n-- per-player parity --")
|
||||
print(f"same-row pairs (identical received_at, must fully match): {len(same_row)}")
|
||||
print(f"diff-row pairs (newer telemetry between refreshes) : {len(diff_row)}")
|
||||
|
||||
# Identity + type/null-pattern parity across ALL common players.
|
||||
id_bad = type_bad = 0
|
||||
for n in common:
|
||||
a, b = pyi[n], goi[n]
|
||||
for k in IDENTITY:
|
||||
if a.get(k) != b.get(k):
|
||||
id_bad += 1
|
||||
print(f" IDENTITY mismatch {n}.{k}: py={a.get(k)!r} go={b.get(k)!r}")
|
||||
for k in py_keys:
|
||||
ta, tb = jtype(a.get(k)), jtype(b.get(k))
|
||||
if ta != tb:
|
||||
# null vs num/str is a real null-pattern divergence; num-vs-num
|
||||
# whole-float (0.0) vs int (0) is already unified under "num".
|
||||
type_bad += 1
|
||||
print(f" TYPE mismatch {n}.{k}: py={ta}({a.get(k)!r}) go={tb}({b.get(k)!r})")
|
||||
if id_bad:
|
||||
violations += id_bad
|
||||
if type_bad:
|
||||
violations += type_bad
|
||||
if not id_bad and not type_bad:
|
||||
print("identity+type : IDENTICAL for all common players")
|
||||
|
||||
# Rigorous: same-row pairs must match on every field.
|
||||
sr_full_match = 0
|
||||
for n in same_row:
|
||||
a, b = pyi[n], goi[n]
|
||||
diffs = []
|
||||
for k in py_keys:
|
||||
if not values_equal(k, a.get(k), b.get(k)):
|
||||
diffs.append((k, a.get(k), b.get(k)))
|
||||
if not diffs:
|
||||
sr_full_match += 1
|
||||
else:
|
||||
# Aggregate-only diffs are timing-explainable even on a same row.
|
||||
non_agg = [d for d in diffs if d[0] not in AGGREGATES]
|
||||
if non_agg:
|
||||
violations += 1
|
||||
print(f" SAME-ROW FIELD divergence {n}: " +
|
||||
", ".join(f"{k}: py={pa!r} go={ga!r}" for k, pa, ga in non_agg))
|
||||
else:
|
||||
print(f" (same-row {n}: only aggregate fields differ — kill/rare between refreshes: "
|
||||
+ ", ".join(f"{k} py={pa} go={ga}" for k, pa, ga in diffs) + ")")
|
||||
print(f"same-row full-field matches: {sr_full_match}/{len(same_row)}")
|
||||
|
||||
# Volatile-field skew on diff-row pairs (informational).
|
||||
if diff_row:
|
||||
ts_deltas = []
|
||||
for n in diff_row:
|
||||
da, db = parse_ts(pyi[n].get("timestamp")), parse_ts(goi[n].get("timestamp"))
|
||||
if da and db:
|
||||
ts_deltas.append(abs((da - db).total_seconds()))
|
||||
if ts_deltas:
|
||||
ts_deltas.sort()
|
||||
print(f"diff-row timestamp skew: min={ts_deltas[0]:.1f}s "
|
||||
f"median={ts_deltas[len(ts_deltas)//2]:.1f}s max={ts_deltas[-1]:.1f}s "
|
||||
"(bounded by the two 5s refresh cycles)")
|
||||
|
||||
print("\n" + "=" * 72)
|
||||
if violations == 0:
|
||||
print("RESULT: PARITY OK — no structural or same-row divergences.")
|
||||
else:
|
||||
print(f"RESULT: {violations} PARITY VIOLATION(S) — see above.")
|
||||
print("=" * 72)
|
||||
return 1 if violations else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# Multi-stage build for the Go discord-rare-monitor port. The unit test (rare
|
||||
# classification) runs at build time, so a classifier regression fails the build.
|
||||
FROM golang:1.25-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN go mod tidy
|
||||
RUN go test ./...
|
||||
ARG BUILD_VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w" -o /out/discord-go .
|
||||
|
||||
# distroless/static includes CA certificates (needed for Discord's HTTPS REST API).
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /out/discord-go /discord-go
|
||||
ENTRYPOINT ["/discord-go"]
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
package main
|
||||
|
||||
// Rare classification — a faithful port of discord_rare_monitor.py's
|
||||
// COMMON_RARES_PATTERN (an anchored exact-match regex of common-rare names).
|
||||
// Because the regex is fully anchored with no wildcards, an exact-match set is
|
||||
// equivalent. This list was extracted verbatim from the Python source, not
|
||||
// hand-transcribed. classify_test.go asserts every entry maps to "common".
|
||||
//
|
||||
// classify returns "common" for an exact match, "great" otherwise — identical
|
||||
// to classify_rare().
|
||||
func classify(rareName string) string {
|
||||
if commonRares[rareName] {
|
||||
return "common"
|
||||
}
|
||||
return "great"
|
||||
}
|
||||
|
||||
var commonRares = map[string]bool{
|
||||
"Alchemist's Crystal": true,
|
||||
"Scholar's Crystal": true,
|
||||
"Smithy's Crystal": true,
|
||||
"Hunter's Crystal": true,
|
||||
"Observer's Crystal": true,
|
||||
"Thorsten's Crystal": true,
|
||||
"Elysa's Crystal": true,
|
||||
"Chef's Crystal": true,
|
||||
"Enchanter's Crystal": true,
|
||||
"Oswald's Crystal": true,
|
||||
"Deceiver's Crystal": true,
|
||||
"Fletcher's Crystal": true,
|
||||
"Physician's Crystal": true,
|
||||
"Artificer's Crystal": true,
|
||||
"Tinker's Crystal": true,
|
||||
"Vaulter's Crystal": true,
|
||||
"Monarch's Crystal": true,
|
||||
"Life Giver's Crystal": true,
|
||||
"Thief's Crystal": true,
|
||||
"Adherent's Crystal": true,
|
||||
"Resister's Crystal": true,
|
||||
"Imbuer's Crystal": true,
|
||||
"Converter's Crystal": true,
|
||||
"Evader's Crystal": true,
|
||||
"Dodger's Crystal": true,
|
||||
"Zefir's Crystal": true,
|
||||
"Ben Ten's Crystal": true,
|
||||
"Corruptor's Crystal": true,
|
||||
"Artist's Crystal": true,
|
||||
"T'ing's Crystal": true,
|
||||
"Warrior's Crystal": true,
|
||||
"Brawler's Crystal": true,
|
||||
"Hieromancer's Crystal": true,
|
||||
"Rogue's Crystal": true,
|
||||
"Berzerker's Crystal": true,
|
||||
"Knight's Crystal": true,
|
||||
"Lugian's Pearl": true,
|
||||
"Ursuin's Pearl": true,
|
||||
"Wayfarer's Pearl": true,
|
||||
"Sprinter's Pearl": true,
|
||||
"Magus's Pearl": true,
|
||||
"Lich's Pearl": true,
|
||||
"Warrior's Jewel": true,
|
||||
"Melee's Jewel": true,
|
||||
"Mage's Jewel": true,
|
||||
"Duelist's Jewel": true,
|
||||
"Archer's Jewel": true,
|
||||
"Tusker's Jewel": true,
|
||||
"Olthoi's Jewel": true,
|
||||
"Inferno's Jewel": true,
|
||||
"Gelid's Jewel": true,
|
||||
"Astyrrian's Jewel": true,
|
||||
"Executor's Jewel": true,
|
||||
"Pearl of Blood Drinking": true,
|
||||
"Pearl of Heart Seeking": true,
|
||||
"Pearl of Defending": true,
|
||||
"Pearl of Swift Killing": true,
|
||||
"Pearl of Spirit Drinking": true,
|
||||
"Pearl of Hermetic Linking": true,
|
||||
"Pearl of Blade Baning": true,
|
||||
"Pearl of Pierce Baning": true,
|
||||
"Pearl of Bludgeon Baning": true,
|
||||
"Pearl of Acid Baning": true,
|
||||
"Pearl of Flame Baning": true,
|
||||
"Pearl of Frost Baning": true,
|
||||
"Pearl of Lightning Baning": true,
|
||||
"Pearl of Impenetrability": true,
|
||||
"Refreshing Elixir": true,
|
||||
"Invigorating Elixir": true,
|
||||
"Miraculous Elixir": true,
|
||||
"Medicated Health Kit": true,
|
||||
"Medicated Stamina Kit": true,
|
||||
"Medicated Mana Kit": true,
|
||||
"Casino Exquisite Keyring": true,
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// Every name in the common-rares set must classify as "common".
|
||||
func TestClassifyCommon(t *testing.T) {
|
||||
if len(commonRares) != 74 {
|
||||
t.Fatalf("expected 74 common rares, got %d", len(commonRares))
|
||||
}
|
||||
for name := range commonRares {
|
||||
if got := classify(name); got != "common" {
|
||||
t.Errorf("classify(%q) = %q, want common", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Names not in the set (including near-misses) must classify as "great".
|
||||
func TestClassifyGreat(t *testing.T) {
|
||||
greats := []string{
|
||||
"Shimmering Skeleton Key",
|
||||
"Star of Tukal",
|
||||
"Hieroglyph of the Bludgeoner",
|
||||
"Infinite Phial of Pyreal Flux",
|
||||
"Foolproof Hooks",
|
||||
"Staff of All Aphus",
|
||||
"Count Renari's Equctioneer",
|
||||
"Gelidite Long Sword",
|
||||
"Pearl of Blade Baning ", // trailing space — not an exact match
|
||||
"alchemist's crystal", // wrong case — not an exact match
|
||||
"Alchemist's Crystals", // plural — not an exact match
|
||||
"",
|
||||
}
|
||||
for _, name := range greats {
|
||||
if got := classify(name); got != "great" {
|
||||
t.Errorf("classify(%q) = %q, want great", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
module git.snakedesert.se/SawatoMosswartsEnjoyersClub/MosswartOverlord/go-services/discord-go
|
||||
|
||||
go 1.25
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
// Command discord-go is a Go port of discord-rare-monitor: it consumes the
|
||||
// tracker's /ws/live firehose (subscribed to rare+chat), classifies rares
|
||||
// common/great, posts embeds to Discord, and relays allegiance chat.
|
||||
//
|
||||
// SAFETY: it runs in dry-run (log only, no Discord posts) by default. Going live
|
||||
// requires BOTH a bot token AND DRY_RUN=0 — so it can never accidentally
|
||||
// double-post to the production channels during the parallel run. For a parallel
|
||||
// test, set a TEST token + TEST channel IDs + DRY_RUN=0.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"syscall"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// `discord-go dump-rares` prints the common-rares set (for parity diffing
|
||||
// against the Python regex). No network, no token.
|
||||
if len(os.Args) > 1 && os.Args[1] == "dump-rares" {
|
||||
names := make([]string, 0, len(commonRares))
|
||||
for n := range commonRares {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
fmt.Println(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
token := os.Getenv("DISCORD_RARE_BOT_TOKEN")
|
||||
// Dry-run unless a token is present AND DRY_RUN is explicitly "0".
|
||||
dryRun := token == "" || os.Getenv("DRY_RUN") != "0"
|
||||
|
||||
wsURL := envOr("DERETH_TRACKER_WS_URL", "ws://dereth-tracker:8765/ws/live")
|
||||
monitorChar := envOr("MONITOR_CHARACTER", "Dunking Rares")
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
var out poster
|
||||
if dryRun {
|
||||
reason := "no DISCORD_RARE_BOT_TOKEN"
|
||||
if token != "" {
|
||||
reason = "DRY_RUN != 0"
|
||||
}
|
||||
logger.Info("starting in DRY-RUN — classifying but NOT posting to Discord", "reason", reason, "ws", wsURL, "monitor", monitorChar)
|
||||
out = &logPoster{log: logger}
|
||||
} else {
|
||||
dg, err := discordgo.New("Bot " + token)
|
||||
if err != nil {
|
||||
logger.Error("discord session init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// REST-only: we send by channel ID, so no gateway Open()/intents needed.
|
||||
logger.Info("starting LIVE — posting to Discord", "ws", wsURL, "monitor", monitorChar)
|
||||
out = &discordPoster{
|
||||
dg: dg,
|
||||
common: envOr("COMMON_RARE_CHANNEL_ID", "1355328792184226014"),
|
||||
great: envOr("GREAT_RARE_CHANNEL_ID", "1353676584334131211"),
|
||||
aclog: envOr("ACLOG_CHANNEL_ID", "1349649482786275328"),
|
||||
sawato: envOr("SAWATOLIFE_CHANNEL_ID", "1387323032271327423"),
|
||||
iconsDir: envOr("ICONS_DIR", "icons"),
|
||||
log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
b := &bot{wsURL: wsURL, monitorChar: monitorChar, out: out, log: logger}
|
||||
go b.run(ctx)
|
||||
|
||||
<-ctx.Done()
|
||||
logger.Info("shutdown signal received")
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// Discord embed colors, matching discord.py's Color.gold()/Color.blue().
|
||||
const (
|
||||
colorGold = 0xf1c40f
|
||||
colorBlue = 0x3498db
|
||||
colorRed = 0xe74c3c
|
||||
)
|
||||
|
||||
type rareEvent struct {
|
||||
Name string
|
||||
CharacterName string
|
||||
Timestamp string
|
||||
EW, NS, Z *float64
|
||||
}
|
||||
|
||||
// poster abstracts where messages go: a real Discord session, or a dry-run
|
||||
// logger used for parallel validation without a bot token / live channels.
|
||||
type poster interface {
|
||||
postRare(ev rareEvent, tier string)
|
||||
postChat(charName, text, ts string)
|
||||
postVortex(speaker, text, ts string)
|
||||
postStatus(text string)
|
||||
}
|
||||
|
||||
// ---- dry-run (log-only) ----
|
||||
|
||||
type logPoster struct{ log *slog.Logger }
|
||||
|
||||
func (p *logPoster) postRare(ev rareEvent, tier string) {
|
||||
p.log.Info("DRY-RUN would post rare", "tier", tier, "channel", tier, "name", ev.Name, "character", ev.CharacterName)
|
||||
}
|
||||
func (p *logPoster) postChat(charName, text, ts string) {
|
||||
p.log.Info("DRY-RUN would relay chat", "character", charName, "text", text)
|
||||
}
|
||||
func (p *logPoster) postVortex(speaker, text, ts string) {
|
||||
p.log.Warn("DRY-RUN would post vortex warning", "speaker", speaker, "text", text)
|
||||
}
|
||||
func (p *logPoster) postStatus(text string) {
|
||||
p.log.Info("DRY-RUN would post status", "text", text)
|
||||
}
|
||||
|
||||
// ---- real Discord ----
|
||||
|
||||
type discordPoster struct {
|
||||
dg *discordgo.Session
|
||||
common string
|
||||
great string
|
||||
aclog string
|
||||
sawato string
|
||||
iconsDir string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func (p *discordPoster) postRare(ev rareEvent, tier string) {
|
||||
embed := buildRareEmbed(ev, tier)
|
||||
channel := p.common
|
||||
if tier == "great" {
|
||||
channel = p.great
|
||||
}
|
||||
if iconPath := p.iconPath(ev.Name); iconPath != "" {
|
||||
if f, err := os.Open(iconPath); err == nil {
|
||||
defer f.Close()
|
||||
fn := filepath.Base(iconPath)
|
||||
embed.Image = &discordgo.MessageEmbedImage{URL: "attachment://" + fn}
|
||||
if _, err := p.dg.ChannelMessageSendComplex(channel, &discordgo.MessageSend{
|
||||
Embed: embed,
|
||||
Files: []*discordgo.File{{Name: fn, Reader: f}},
|
||||
}); err != nil {
|
||||
p.log.Error("send rare embed (with icon) failed", "err", err, "channel", channel)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := p.dg.ChannelMessageSendEmbed(channel, embed); err != nil {
|
||||
p.log.Error("send rare embed failed", "err", err, "channel", channel)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *discordPoster) postChat(charName, text, ts string) {
|
||||
t := parseTime(ts)
|
||||
cleaned := strings.TrimPrefix(text, "Dunking Rares: ")
|
||||
msg := fmt.Sprintf("`%s` %s", t.Format("15:04:05"), cleaned)
|
||||
if _, err := p.dg.ChannelMessageSend(p.sawato, msg); err != nil {
|
||||
p.log.Error("send chat failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *discordPoster) postVortex(speaker, text, ts string) {
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "🌪️ VORTEX WARNING",
|
||||
Description: fmt.Sprintf("**%s**: %s", speaker, text),
|
||||
Color: colorRed,
|
||||
Timestamp: parseTime(ts).Format(time.RFC3339),
|
||||
}
|
||||
if _, err := p.dg.ChannelMessageSendEmbed(p.aclog, embed); err != nil {
|
||||
p.log.Error("send vortex failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *discordPoster) postStatus(text string) {
|
||||
if _, err := p.dg.ChannelMessageSend(p.aclog, text); err != nil {
|
||||
p.log.Error("send status failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *discordPoster) iconPath(rareName string) string {
|
||||
if p.iconsDir == "" {
|
||||
return ""
|
||||
}
|
||||
fn := strings.NewReplacer("'", "", " ", "_", "-", "_").Replace(rareName) + "_Icon.png"
|
||||
path := filepath.Join(p.iconsDir, fn)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildRareEmbed mirrors post_rare_to_discord's embed.
|
||||
func buildRareEmbed(ev rareEvent, tier string) *discordgo.MessageEmbed {
|
||||
title, color := "🔸 Common Rare Discovery", colorBlue
|
||||
if tier == "great" {
|
||||
title, color = "💎 Great Rare Discovery!", colorGold
|
||||
}
|
||||
t := parseTime(ev.Timestamp)
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: title,
|
||||
Description: fmt.Sprintf("**%s** has discovered the **%s**!", ev.CharacterName, ev.Name),
|
||||
Color: color,
|
||||
Timestamp: t.Format(time.RFC3339),
|
||||
}
|
||||
if ev.EW != nil && ev.NS != nil {
|
||||
loc := fmt.Sprintf("%.1fE, %.1fN", *ev.EW, *ev.NS)
|
||||
if ev.Z != nil {
|
||||
loc += fmt.Sprintf(", %.1fZ", *ev.Z)
|
||||
}
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{Name: "📍 Location", Value: loc, Inline: true})
|
||||
}
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||
Name: "⏰ Time", Value: t.UTC().Format("15:04:05") + " UTC", Inline: true,
|
||||
})
|
||||
return embed
|
||||
}
|
||||
|
||||
// parseTime accepts the plugin's ISO8601 (with or without 'Z'); falls back to now.
|
||||
func parseTime(ts string) time.Time {
|
||||
if ts != "" {
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.999999", "2006-01-02T15:04:05"} {
|
||||
if t, err := time.Parse(layout, strings.Replace(ts, "Z", "+00:00", 1)); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// bot consumes the tracker's /ws/live firehose (subscribed to rare+chat) and
|
||||
// routes events to a poster. It reconnects with exponential backoff, mirroring
|
||||
// monitor_websocket().
|
||||
type bot struct {
|
||||
wsURL string
|
||||
monitorChar string
|
||||
out poster
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func (b *bot) run(ctx context.Context) {
|
||||
backoff := time.Second
|
||||
const maxBackoff = 60 * time.Second
|
||||
for ctx.Err() == nil {
|
||||
err := b.connectAndConsume(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
b.log.Warn("ws disconnected; reconnecting", "err", err, "backoff", backoff.String())
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bot) connectAndConsume(ctx context.Context) error {
|
||||
b.log.Info("connecting to /ws/live", "url", b.wsURL)
|
||||
c, _, err := websocket.Dial(ctx, b.wsURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.CloseNow()
|
||||
c.SetReadLimit(8 << 20) // payloads (nearby_objects etc.) can be large; we only read rare/chat but the socket carries all
|
||||
|
||||
// Subscribe to just rare + chat (server-side filtering), like the Python bot.
|
||||
sub, _ := json.Marshal(map[string]any{"type": "subscribe", "message_types": []string{"rare", "chat"}})
|
||||
if err := c.Write(ctx, websocket.MessageText, sub); err != nil {
|
||||
return err
|
||||
}
|
||||
b.log.Info("subscribed", "message_types", []string{"rare", "chat"})
|
||||
b.out.postStatus("🔗 (go) WebSocket connection established")
|
||||
|
||||
// Keepalive pings, like ping_interval=20.
|
||||
pingCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go func() {
|
||||
t := time.NewTicker(20 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-pingCtx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
pc, cc := context.WithTimeout(pingCtx, 10*time.Second)
|
||||
_ = c.Ping(pc)
|
||||
cc()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
_, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.handleMessage(data)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bot) handleMessage(raw []byte) {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return // ignore invalid JSON, like the Python bot
|
||||
}
|
||||
switch asString(data["type"]) {
|
||||
case "rare":
|
||||
b.handleRare(data)
|
||||
case "chat":
|
||||
b.handleChat(data)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bot) handleRare(data map[string]any) {
|
||||
ev := rareEvent{
|
||||
Name: asStringOr(data["name"], "Unknown Rare"),
|
||||
CharacterName: asStringOr(data["character_name"], "Unknown Character"),
|
||||
Timestamp: asString(data["timestamp"]),
|
||||
EW: asFloatPtr(data["ew"]),
|
||||
NS: asFloatPtr(data["ns"]),
|
||||
Z: asFloatPtr(data["z"]),
|
||||
}
|
||||
tier := classify(ev.Name)
|
||||
b.log.Info("rare", "name", ev.Name, "character", ev.CharacterName, "tier", tier)
|
||||
b.out.postRare(ev, tier)
|
||||
}
|
||||
|
||||
func (b *bot) handleChat(data map[string]any) {
|
||||
charName := asString(data["character_name"])
|
||||
text := asString(data["text"])
|
||||
if charName != b.monitorChar {
|
||||
return
|
||||
}
|
||||
if strings.Contains(text, "m in whirlwind of vortexes") {
|
||||
b.out.postVortex(parseAllegianceSpeaker(text), text, asString(data["timestamp"]))
|
||||
return
|
||||
}
|
||||
b.out.postChat(charName, text, asString(data["timestamp"]))
|
||||
}
|
||||
|
||||
// parseAllegianceSpeaker extracts <name> from "[Allegiance] <name> says, ...".
|
||||
func parseAllegianceSpeaker(text string) string {
|
||||
const prefix = "[Allegiance] "
|
||||
if i := strings.Index(text, prefix); i >= 0 {
|
||||
rest := text[i+len(prefix):]
|
||||
if j := strings.Index(rest, " says,"); j >= 0 {
|
||||
return rest[:j]
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func asStringOr(v any, def string) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func asFloatPtr(v any) *float64 {
|
||||
if f, ok := v.(float64); ok {
|
||||
return &f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# Cutover override — flips the Go services from read-only parallel mode to
|
||||
# PRODUCTION write mode, reusing the existing production databases (no data
|
||||
# migration). Apply ON TOP of the base + go overrides:
|
||||
#
|
||||
# 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 discord-rare-monitor
|
||||
#
|
||||
# Reversible: re-up WITHOUT this file to return the Go services to read-only
|
||||
# parallel mode (and start the Python services back up for rollback).
|
||||
#
|
||||
# SKIP_SCHEMA_INIT=true makes the Go services trust the existing prod schema and
|
||||
# run NO DDL. The Go tracker writes prod `dereth`; inventory-go writes prod
|
||||
# `inventory_db`; the (still Python) rare/chat bot is repointed at the Go
|
||||
# tracker's /ws/live (proven posting path, fed by Go data).
|
||||
services:
|
||||
dereth-tracker-go:
|
||||
environment:
|
||||
READ_ONLY: "false"
|
||||
SKIP_SCHEMA_INIT: "true"
|
||||
SHARED_SECRET: "${SHARED_SECRET}"
|
||||
SHARED_SECRET_LEGACY: "${SHARED_SECRET_LEGACY:-}"
|
||||
DISCORD_ACLOG_WEBHOOK: "${DISCORD_ACLOG_WEBHOOK}"
|
||||
|
||||
inventory-go:
|
||||
environment:
|
||||
READ_ONLY: "false"
|
||||
SKIP_SCHEMA_INIT: "true"
|
||||
|
||||
discord-rare-monitor:
|
||||
environment:
|
||||
DERETH_TRACKER_WS_URL: "ws://dereth-tracker-go:8770/ws/live"
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
# Compose OVERRIDE that adds the Go services alongside the live Python stack.
|
||||
# It only ADDS containers; it never modifies the tracked docker-compose.yml or
|
||||
# any running Python service.
|
||||
#
|
||||
# Invoke from the repo root so the Compose project name resolves to
|
||||
# "mosswartoverlord" (same as the live stack) and the new container joins the
|
||||
# existing default network — letting it reach the `db` service by name:
|
||||
#
|
||||
# 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
|
||||
# docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml \
|
||||
# up -d --no-deps dereth-tracker-go
|
||||
#
|
||||
# --no-deps keeps Compose from touching the already-running `db` (and anything
|
||||
# else). The service is loopback-bound (127.0.0.1:8770); external reach is only
|
||||
# ever via the host nginx `location /go/` block (added separately).
|
||||
services:
|
||||
dereth-tracker-go:
|
||||
build:
|
||||
context: ./go-services/tracker-go
|
||||
args:
|
||||
BUILD_VERSION: ${BUILD_VERSION:-dev}
|
||||
image: dereth-tracker-go:local
|
||||
container_name: dereth-tracker-go
|
||||
ports:
|
||||
- "127.0.0.1:8770:8770"
|
||||
environment:
|
||||
PORT: "8770"
|
||||
# Read-only use of the same dereth TimescaleDB the Python tracker writes.
|
||||
DATABASE_URL: "postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/dereth"
|
||||
# Point at the Go inventory service so the /go/ read stack is fully Go
|
||||
# end-to-end (browser -> Go tracker -> Go inventory -> read-only prod DBs).
|
||||
# inventory-go is read-only against the production inventory_db.
|
||||
INVENTORY_SERVICE_URL: "http://inventory-go:8772"
|
||||
# Same signing key as the Python tracker so the same login cookie verifies
|
||||
# on both during the parallel run.
|
||||
SECRET_KEY: "${SECRET_KEY}"
|
||||
# Serve the (unchanged) frontend from the same static/ the Python tracker
|
||||
# serves — needed for the full cutover (login, index.html, assets, icons).
|
||||
STATIC_DIR: "/static"
|
||||
LOG_LEVEL: "INFO"
|
||||
volumes:
|
||||
- ./static:/static:ro
|
||||
# Issue board is a flat file the tracker writes; mount it read-write
|
||||
# (more specific than the :ro static mount above, so it wins).
|
||||
- ./static/openissues.json:/static/openissues.json
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Go port of discord-rare-monitor. Consumes the SAME Python /ws/live firehose
|
||||
# as the live Python bot. DRY-RUN by default (logs classifications, posts
|
||||
# nothing) so it can't double-post. To parallel-test for real, set a TEST
|
||||
# DISCORD_RARE_BOT_TOKEN + TEST channel IDs + DRY_RUN=0 here.
|
||||
discord-rare-monitor-go:
|
||||
build:
|
||||
context: ./go-services/discord-go
|
||||
args:
|
||||
BUILD_VERSION: ${BUILD_VERSION:-dev}
|
||||
container_name: discord-rare-monitor-go
|
||||
environment:
|
||||
DERETH_TRACKER_WS_URL: "ws://dereth-tracker:8765/ws/live"
|
||||
MONITOR_CHARACTER: "Dunking Rares"
|
||||
ICONS_DIR: "/icons"
|
||||
LOG_LEVEL: "INFO"
|
||||
# DISCORD_RARE_BOT_TOKEN: "" # set a TEST token to go live
|
||||
# DRY_RUN: "0" # required (with a token) to actually post
|
||||
# COMMON_RARE_CHANNEL_ID / GREAT_RARE_CHANNEL_ID / SAWATOLIFE_CHANNEL_ID /
|
||||
# ACLOG_CHANNEL_ID: set TEST channels before going live
|
||||
volumes:
|
||||
- ./discord-rare-monitor/icons:/icons:ro
|
||||
depends_on:
|
||||
- dereth-tracker
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---- Phase 2: shadow ingest (fully isolated; production never touched) ----
|
||||
|
||||
# A SEPARATE TimescaleDB the Go tracker owns for shadow ingest. Isolated
|
||||
# volume + loopback port; the production dereth DB is never written.
|
||||
dereth-go-db:
|
||||
image: timescale/timescaledb:2.19.3-pg14
|
||||
container_name: dereth-go-db
|
||||
ports:
|
||||
- "127.0.0.1:5434:5432"
|
||||
environment:
|
||||
POSTGRES_DB: "dereth_go"
|
||||
POSTGRES_USER: "postgres"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
volumes:
|
||||
- dereth-go-data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Shadow tracker instance: same image, but OWNS dereth-go-db (read-write) and
|
||||
# (once ingest lands) consumes the Python /ws/live firehose into it, so its
|
||||
# ingest output can be compared against production without writing to it.
|
||||
dereth-tracker-go-shadow:
|
||||
image: dereth-tracker-go:local
|
||||
container_name: dereth-tracker-go-shadow
|
||||
ports:
|
||||
- "127.0.0.1:8771:8771"
|
||||
environment:
|
||||
PORT: "8771"
|
||||
DATABASE_URL: "postgresql://postgres:${POSTGRES_PASSWORD}@dereth-go-db:5432/dereth_go"
|
||||
READ_ONLY: "false" # owns its DB; creates schema on boot
|
||||
INVENTORY_SERVICE_URL: "http://inventory-service:8000"
|
||||
SECRET_KEY: "${SECRET_KEY}"
|
||||
SHARED_SECRET: "${SHARED_SECRET}" # /ws/position plugin auth (cutover-ready)
|
||||
SHARED_SECRET_LEGACY: "${SHARED_SECRET_LEGACY:-}"
|
||||
# Replay the Python /ws/live firehose into the ingest handlers (shadow).
|
||||
SHADOW_INGEST_WS: "ws://dereth-tracker:8765/ws/live"
|
||||
LOG_LEVEL: "INFO"
|
||||
depends_on:
|
||||
- dereth-go-db
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Go port of inventory-service. Phase A: read side, READ-ONLY against the
|
||||
# production inventory_db, validated vs the Python service. Loopback :8772.
|
||||
inventory-go:
|
||||
build:
|
||||
context: ./go-services/inventory-go
|
||||
args:
|
||||
BUILD_VERSION: ${BUILD_VERSION:-dev}
|
||||
image: inventory-go:local
|
||||
container_name: inventory-go
|
||||
ports:
|
||||
- "127.0.0.1:8772:8772"
|
||||
environment:
|
||||
PORT: "8772"
|
||||
DATABASE_URL: "postgresql://inventory_user:${INVENTORY_DB_PASSWORD}@inventory-db:5432/inventory_db"
|
||||
READ_ONLY: "true"
|
||||
ENUM_DB_PATH: "/enums/comprehensive_enum_database_v2.json"
|
||||
LOG_LEVEL: "INFO"
|
||||
volumes:
|
||||
- ./inventory-service/comprehensive_enum_database_v2.json:/enums/comprehensive_enum_database_v2.json:ro
|
||||
depends_on:
|
||||
- inventory-db
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Phase C: isolated inventory DB the Go ingestion writes to (never production).
|
||||
inventory-go-db:
|
||||
image: postgres:14
|
||||
container_name: inventory-go-db
|
||||
ports:
|
||||
- "127.0.0.1:5435:5432"
|
||||
environment:
|
||||
POSTGRES_DB: "inventory_db"
|
||||
POSTGRES_USER: "inventory_user"
|
||||
POSTGRES_PASSWORD: "${INVENTORY_DB_PASSWORD}"
|
||||
volumes:
|
||||
- inventory-go-data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Read-write inventory-go instance: owns inventory-go-db, exposes the ingestion
|
||||
# endpoints. Used to validate ingestion (POST a character's items, compare the
|
||||
# resulting normalized rows to production) without touching the production DB.
|
||||
inventory-go-shadow:
|
||||
image: inventory-go:local
|
||||
container_name: inventory-go-shadow
|
||||
ports:
|
||||
- "127.0.0.1:8773:8773"
|
||||
environment:
|
||||
PORT: "8773"
|
||||
DATABASE_URL: "postgresql://inventory_user:${INVENTORY_DB_PASSWORD}@inventory-go-db:5432/inventory_db"
|
||||
READ_ONLY: "false"
|
||||
ENUM_DB_PATH: "/enums/comprehensive_enum_database_v2.json"
|
||||
LOG_LEVEL: "INFO"
|
||||
volumes:
|
||||
- ./inventory-service/comprehensive_enum_database_v2.json:/enums/comprehensive_enum_database_v2.json:ro
|
||||
depends_on:
|
||||
- inventory-go-db
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
dereth-go-data:
|
||||
inventory-go-data:
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
FROM golang:1.25-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN go mod tidy
|
||||
RUN go test ./...
|
||||
ARG BUILD_VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath -ldflags "-s -w -X main.buildVersion=${BUILD_VERSION}" -o /out/inventory-go .
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /out/inventory-go /inventory-go
|
||||
EXPOSE 8772
|
||||
ENTRYPOINT ["/inventory-go"]
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
module git.snakedesert.se/SawatoMosswartsEnjoyersClub/MosswartOverlord/go-services/inventory-go
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/jackc/pgx/v5 v5.10.0
|
||||
|
|
@ -1,315 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Ingestion endpoints — port of process_inventory / upsert_inventory_item /
|
||||
// delete_inventory_item. They write to THIS instance's own DB (ingest mode,
|
||||
// READ_ONLY=false), reusing the validated item-processor. Production is never
|
||||
// written: an isolated inventory-go-db backs the shadow instance.
|
||||
|
||||
func quoteCol(k string) string {
|
||||
if k == "unique" {
|
||||
return `"unique"`
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func buildInsert(table string, cols map[string]any, returningID bool) (string, []any) {
|
||||
keys := make([]string, 0, len(cols))
|
||||
for k := range cols {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
qc := make([]string, len(keys))
|
||||
ph := make([]string, len(keys))
|
||||
args := make([]any, len(keys))
|
||||
for i, k := range keys {
|
||||
qc[i] = quoteCol(k)
|
||||
ph[i] = "$" + strconv.Itoa(i+1)
|
||||
args[i] = cols[k]
|
||||
}
|
||||
sql := "INSERT INTO " + table + " (" + strings.Join(qc, ", ") + ") VALUES (" + strings.Join(ph, ", ") + ")"
|
||||
if returningID {
|
||||
sql += " RETURNING id"
|
||||
}
|
||||
return sql, args
|
||||
}
|
||||
|
||||
var childTables = []struct{ table, key string }{
|
||||
{"item_combat_stats", "combat"},
|
||||
{"item_requirements", "requirements"},
|
||||
{"item_enhancements", "enhancements"},
|
||||
{"item_ratings", "ratings"},
|
||||
}
|
||||
|
||||
// ingestItem processes one raw item and inserts it across the 7 tables.
|
||||
func (s *Server) ingestItem(ctx context.Context, tx pgx.Tx, charName string, ts time.Time, raw map[string]any) error {
|
||||
p := s.processItem(raw)
|
||||
items := p["items"].(map[string]any)
|
||||
items["character_name"] = charName
|
||||
items["timestamp"] = ts
|
||||
sql, args := buildInsert("items", items, true)
|
||||
var id int
|
||||
if err := tx.QueryRow(ctx, sql, args...).Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ct := range childTables {
|
||||
cols, _ := p[ct.key].(map[string]any)
|
||||
if cols == nil {
|
||||
continue // table skipped (all-sentinel)
|
||||
}
|
||||
cols["item_id"] = id
|
||||
csql, cargs := buildInsert(ct.table, cols, false)
|
||||
if _, err := tx.Exec(ctx, csql, cargs...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if rows, ok := p["spells"].([]map[string]any); ok {
|
||||
for _, sp := range rows {
|
||||
if _, err := tx.Exec(ctx,
|
||||
"INSERT INTO item_spells (item_id, spell_id, is_active) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING",
|
||||
id, sp["spell_id"], sp["is_active"]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
ivb, _ := json.Marshal(bag(raw, "IntValues"))
|
||||
dvb, _ := json.Marshal(bag(raw, "DoubleValues"))
|
||||
svb, _ := json.Marshal(bag(raw, "StringValues"))
|
||||
bvb, _ := json.Marshal(bag(raw, "BoolValues"))
|
||||
ojb, _ := json.Marshal(raw)
|
||||
_, err := tx.Exec(ctx,
|
||||
"INSERT INTO item_raw_data (item_id,int_values,double_values,string_values,bool_values,original_json) VALUES ($1,$2,$3,$4,$5,$6)",
|
||||
id, ivb, dvb, svb, bvb, ojb)
|
||||
return err
|
||||
}
|
||||
|
||||
// deleteCharItems removes a character's rows across all tables (children first).
|
||||
func deleteCharItems(ctx context.Context, tx pgx.Tx, charName string) error {
|
||||
var ids []int
|
||||
rows, err := tx.Query(ctx, "SELECT id FROM items WHERE character_name=$1", charName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
if len(ids) > 0 {
|
||||
for _, t := range []string{"item_raw_data", "item_combat_stats", "item_requirements", "item_enhancements", "item_ratings", "item_spells"} {
|
||||
if _, err := tx.Exec(ctx, "DELETE FROM "+t+" WHERE item_id = ANY($1)", ids); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(ctx, "DELETE FROM items WHERE character_name=$1", charName)
|
||||
return err
|
||||
}
|
||||
|
||||
func deleteOneItem(ctx context.Context, tx pgx.Tx, charName string, itemID int64) error {
|
||||
var id int
|
||||
err := tx.QueryRow(ctx, "SELECT id FROM items WHERE character_name=$1 AND item_id=$2", charName, itemID).Scan(&id)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range []string{"item_raw_data", "item_combat_stats", "item_requirements", "item_enhancements", "item_ratings", "item_spells"} {
|
||||
if _, err := tx.Exec(ctx, "DELETE FROM "+t+" WHERE item_id=$1", id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(ctx, "DELETE FROM items WHERE id=$1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// POST /process-inventory — full replacement of a character's inventory.
|
||||
func (s *Server) handleProcessInventory(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 64<<20))
|
||||
var inv struct {
|
||||
CharacterName string `json:"character_name"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
if json.Unmarshal(body, &inv) != nil || inv.CharacterName == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid payload"})
|
||||
return
|
||||
}
|
||||
ts := parseNaiveTime(inv.Timestamp)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
||||
defer cancel()
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
s.dbErr(w, "process-inventory begin", err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if err := deleteCharItems(ctx, tx, inv.CharacterName); err != nil {
|
||||
s.dbErr(w, "process-inventory delete", err)
|
||||
return
|
||||
}
|
||||
processed, errs := 0, 0
|
||||
for _, raw := range inv.Items {
|
||||
if raw["Id"] == nil && raw["id"] == nil {
|
||||
errs++
|
||||
continue
|
||||
}
|
||||
if err := s.ingestItem(ctx, tx, inv.CharacterName, ts, raw); err != nil {
|
||||
s.log.Error("ingest item failed", "err", err, "char", inv.CharacterName)
|
||||
errs++
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
s.dbErr(w, "process-inventory commit", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"processed_count": processed, "error_count": errs, "total_items": len(inv.Items)})
|
||||
}
|
||||
|
||||
// POST /inventory/{character_name}/item — single-item upsert.
|
||||
func (s *Server) handleUpsertItem(w http.ResponseWriter, r *http.Request) {
|
||||
char := r.PathValue("character_name")
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 16<<20))
|
||||
var raw map[string]any
|
||||
if json.Unmarshal(body, &raw) != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if raw["Id"] == nil && raw["id"] == nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "item missing Id"})
|
||||
return
|
||||
}
|
||||
itemID := int64(toFloat(firstNonNil(raw["Id"], raw["id"])))
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
s.dbErr(w, "upsert begin", err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if err := deleteOneItem(ctx, tx, char, itemID); err != nil {
|
||||
s.dbErr(w, "upsert delete", err)
|
||||
return
|
||||
}
|
||||
if err := s.ingestItem(ctx, tx, char, time.Now().UTC(), raw); err != nil {
|
||||
s.dbErr(w, "upsert insert", err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
s.dbErr(w, "upsert commit", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "item_id": itemID})
|
||||
}
|
||||
|
||||
// DELETE /inventory/{character_name}/item/{item_id}
|
||||
func (s *Server) handleDeleteItem(w http.ResponseWriter, r *http.Request) {
|
||||
char := r.PathValue("character_name")
|
||||
itemID, _ := strconv.ParseInt(r.PathValue("item_id"), 10, 64)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
s.dbErr(w, "delete begin", err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if err := deleteOneItem(ctx, tx, char, itemID); err != nil {
|
||||
s.dbErr(w, "delete", err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
s.dbErr(w, "delete commit", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "deleted", "item_id": itemID})
|
||||
}
|
||||
|
||||
// POST /admin/backfill-od — recompute od_rating for all weapon rows from stored
|
||||
// raw JSON. Internal-only (service is bound to loopback/compose). Idempotent.
|
||||
func (s *Server) handleBackfillOD(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT rd.item_id, rd.original_json FROM item_raw_data rd
|
||||
JOIN items i ON i.id = rd.item_id
|
||||
WHERE i.object_class IN (1,9,31)`)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
type upd struct {
|
||||
id int
|
||||
od *float64 // nil → clear stale OD (weapon no longer matches any rule)
|
||||
}
|
||||
var updates []upd
|
||||
scanned, rated := 0, 0
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var oj map[string]any
|
||||
if err := rows.Scan(&id, &oj); err != nil {
|
||||
rows.Close()
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
scanned++
|
||||
if od, ok := computeOD(oj); ok {
|
||||
v := od
|
||||
updates = append(updates, upd{id, &v})
|
||||
rated++
|
||||
} else {
|
||||
// No matching rule → ensure od_rating is NULL (clears any stale value
|
||||
// from a prior formula so the column never keeps a wrong number).
|
||||
updates = append(updates, upd{id, nil})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
updated := 0
|
||||
for _, u := range updates {
|
||||
if _, err := s.pool.Exec(ctx, "UPDATE items SET od_rating=$1 WHERE id=$2", u.od, u.id); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "updated": updated})
|
||||
return
|
||||
}
|
||||
updated++
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"scanned": scanned, "rated": rated, "updated": updated})
|
||||
}
|
||||
|
||||
func parseNaiveTime(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
s = strings.Replace(s, "Z", "+00:00", 1)
|
||||
for _, l := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.999999", "2006-01-02T15:04:05"} {
|
||||
if t, err := time.Parse(l, s); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func firstNonNil(a, b any) any {
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GET /inventory/{character_name} — full per-character inventory for the React
|
||||
// Inventory window. Port of inventory-service get_character_inventory +
|
||||
// enrich_db_item (main.py:2622 / 2338). The Go cutover omitted this endpoint
|
||||
// (it was assumed unused), but the React InventoryWindow fetches it, so its
|
||||
// absence (404) made the live inventory render empty.
|
||||
//
|
||||
// Returns {character_name, item_count, items:[...]} with the snake_case fields
|
||||
// the frontend normalizeItem consumes: placement via current_wielded_location /
|
||||
// container_id / items_capacity, the mana panel via current_mana / max_mana,
|
||||
// icon overlays, plus tooltip combat/requirement/enhancement/rating stats. Mana
|
||||
// and icon overlays are pulled straight from original_json IntValues (same keys
|
||||
// the plugin/search path use); the rest come from the normalized join tables.
|
||||
func (s *Server) handleCharacterInventory(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("character_name")
|
||||
limit := clampInt(qIntDefault(r.URL.Query(), "limit", 1000), 1, 5000)
|
||||
offset := qIntDefault(r.URL.Query(), "offset", 0)
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT
|
||||
i.item_id, i.name, i.icon, i.object_class, i.value, i.burden,
|
||||
i.current_wielded_location, i.container_id, i.items_capacity, i.stack_size,
|
||||
cs.max_damage, cs.armor_level, cs.damage_bonus, cs.attack_bonus,
|
||||
cs.melee_defense_bonus, cs.magic_defense_bonus,
|
||||
r.wield_level, r.skill_level, r.equip_skill, r.lore_requirement,
|
||||
e.material, e.imbue, e.item_set, e.tinks, e.workmanship,
|
||||
rt.damage_rating, rt.crit_rating, rt.crit_damage_rating, rt.heal_boost_rating,
|
||||
NULLIF((rd.original_json->'IntValues'->>'218103815')::int, 0) AS current_mana,
|
||||
NULLIF((rd.original_json->'IntValues'->>'218103814')::int, 0) AS max_mana,
|
||||
NULLIF((rd.original_json->'IntValues'->>'218103849')::int, 0) AS icon_overlay_id,
|
||||
NULLIF((rd.original_json->'IntValues'->>'218103850')::int, 0) AS icon_underlay_id
|
||||
FROM items i
|
||||
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
|
||||
LEFT JOIN item_requirements r ON i.id = r.item_id
|
||||
LEFT JOIN item_enhancements e ON i.id = e.item_id
|
||||
LEFT JOIN item_ratings rt ON i.id = rt.item_id
|
||||
LEFT JOIN item_raw_data rd ON i.id = rd.item_id
|
||||
WHERE i.character_name = $1
|
||||
ORDER BY i.name
|
||||
LIMIT $2 OFFSET $3`
|
||||
|
||||
rows, err := queryRowsAsMaps(r.Context(), s.pool, q, name, limit, offset)
|
||||
if err != nil {
|
||||
s.dbErr(w, "inventory/"+name, err)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, enrichInventoryRow(row))
|
||||
}
|
||||
|
||||
// Unlike the Python endpoint (404 on no rows), always return 200 with a
|
||||
// possibly-empty list — the window treats both as empty, and 200 avoids the
|
||||
// frontend's catch-all error path.
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"character_name": name,
|
||||
"item_count": len(items),
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
// enrichInventoryRow flattens a joined inventory row into the frontend item
|
||||
// shape: drops NULL columns and applies the material-name prefix to the item
|
||||
// name (enrich_db_item parity, e.g. "Pyreal" + "Chiran Helm" -> "Pyreal Chiran
|
||||
// Helm"), preserving the un-prefixed name in original_name.
|
||||
func enrichInventoryRow(row map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(row)+2)
|
||||
for k, v := range row {
|
||||
if v != nil {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if mat, ok := out["material"].(string); ok && mat != "" {
|
||||
out["material_name"] = mat
|
||||
if name, ok := out["name"].(string); ok && name != "" &&
|
||||
!strings.HasPrefix(strings.ToLower(name), strings.ToLower(mat)) {
|
||||
out["name"] = mat + " " + name
|
||||
out["original_name"] = name
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEnrichInventoryRow(t *testing.T) {
|
||||
// NULL columns are dropped.
|
||||
out := enrichInventoryRow(map[string]any{"name": "Helm", "armor_level": nil, "value": 100})
|
||||
if _, ok := out["armor_level"]; ok {
|
||||
t.Errorf("nil column armor_level should be dropped, got %v", out["armor_level"])
|
||||
}
|
||||
if out["value"] != 100 {
|
||||
t.Errorf("value = %v, want 100", out["value"])
|
||||
}
|
||||
|
||||
// Material prefix is applied and original_name preserved.
|
||||
out = enrichInventoryRow(map[string]any{"name": "Chiran Helm", "material": "Pyreal"})
|
||||
if out["name"] != "Pyreal Chiran Helm" {
|
||||
t.Errorf("name = %v, want %q", out["name"], "Pyreal Chiran Helm")
|
||||
}
|
||||
if out["original_name"] != "Chiran Helm" {
|
||||
t.Errorf("original_name = %v, want %q", out["original_name"], "Chiran Helm")
|
||||
}
|
||||
if out["material_name"] != "Pyreal" {
|
||||
t.Errorf("material_name = %v, want Pyreal", out["material_name"])
|
||||
}
|
||||
|
||||
// Already-prefixed name is not doubled.
|
||||
out = enrichInventoryRow(map[string]any{"name": "Pyreal Helm", "material": "Pyreal"})
|
||||
if out["name"] != "Pyreal Helm" {
|
||||
t.Errorf("name = %v, want no double prefix", out["name"])
|
||||
}
|
||||
if _, ok := out["original_name"]; ok {
|
||||
t.Errorf("original_name should be unset when no prefix applied")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
// Command inventory-go is a Go reimplementation of the MosswartOverlord
|
||||
// inventory-service (FastAPI), deployed in parallel for comparison.
|
||||
//
|
||||
// Phase A: read side. Connects READ-ONLY to the existing inventory_db and
|
||||
// reimplements the read endpoints, validated against the Python service on the
|
||||
// same data. The heavy item-processing ingestion and the suitbuilder solver
|
||||
// follow in later phases.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var buildVersion = "dev"
|
||||
|
||||
type Server struct {
|
||||
pool *pgxpool.Pool
|
||||
attributeSets map[string]string // AttributeSetInfo: set-id -> set name
|
||||
objectClasses map[int]string // ObjectClass: id -> name
|
||||
materials map[int]string // MaterialType: id -> name
|
||||
spells map[int]map[string]any // SpellTable: spell-id -> raw spell value object
|
||||
equipMaskMap map[int]string // EquipMask: mask -> technical name (exact lookup)
|
||||
equipMaskOrdered []equipMaskEntry // EquipMask in ascending-mask order (bit-flag decode)
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
addr := ":" + envOr("PORT", "8772")
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
enumPath := envOr("ENUM_DB_PATH", "comprehensive_enum_database_v2.json")
|
||||
readOnly := envOr("READ_ONLY", "true") != "false"
|
||||
|
||||
logger.Info("starting inventory-go", "version", buildVersion, "addr", addr, "read_only", readOnly)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
srv := &Server{log: logger, attributeSets: map[string]string{}, objectClasses: map[int]string{}, materials: map[int]string{}, spells: map[int]map[string]any{}}
|
||||
|
||||
if e, err := loadEnums(enumPath); err != nil {
|
||||
logger.Warn("could not load enum DB (set/class/material/spell names will be unknown)", "err", err, "path", enumPath)
|
||||
} else {
|
||||
srv.attributeSets = e.sets
|
||||
srv.objectClasses = e.objectClasses
|
||||
srv.materials = e.materials
|
||||
srv.spells = e.spells
|
||||
srv.equipMaskMap = e.equipMaskMap
|
||||
srv.equipMaskOrdered = e.equipMaskOrdered
|
||||
logger.Info("loaded enum DB", "sets", len(e.sets), "object_classes", len(e.objectClasses), "materials", len(e.materials), "spells", len(e.spells), "equip_masks", len(e.equipMaskOrdered))
|
||||
}
|
||||
|
||||
if dsn == "" {
|
||||
logger.Error("DATABASE_URL is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
connectCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
pool, err := newPool(connectCtx, dsn, readOnly)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Error("db pool init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
srv.pool = pool
|
||||
|
||||
// Ingest mode owns its DB: create the schema on first run. In cutover
|
||||
// (reusing the production inventory_db) SKIP_SCHEMA_INIT runs no DDL.
|
||||
if !readOnly && envOr("SKIP_SCHEMA_INIT", "false") != "true" {
|
||||
sctx, c := context.WithTimeout(ctx, 60*time.Second)
|
||||
initSchema(sctx, pool, logger)
|
||||
c()
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", srv.handleHealth)
|
||||
mux.HandleFunc("GET /sets/list", srv.handleSetsList)
|
||||
mux.HandleFunc("GET /characters/list", srv.handleCharactersList)
|
||||
mux.HandleFunc("GET /search/items", srv.handleSearchItems)
|
||||
mux.HandleFunc("GET /inventory/{character_name}", srv.handleCharacterInventory)
|
||||
mux.HandleFunc("POST /debug/process", srv.handleDebugProcess)
|
||||
// Ingestion (works in read-write mode; on the read-only prod instance these
|
||||
// fail the read-only transaction, which is the intended guard).
|
||||
mux.HandleFunc("POST /process-inventory", srv.handleProcessInventory)
|
||||
mux.HandleFunc("POST /inventory/{character_name}/item", srv.handleUpsertItem)
|
||||
mux.HandleFunc("DELETE /inventory/{character_name}/item/{item_id}", srv.handleDeleteItem)
|
||||
mux.HandleFunc("POST /admin/backfill-od", srv.handleBackfillOD)
|
||||
// Suitbuilder (port of suitbuilder.py router, mounted at /suitbuilder).
|
||||
mux.HandleFunc("POST /suitbuilder/search", srv.handleSuitSearch)
|
||||
mux.HandleFunc("GET /suitbuilder/characters", srv.handleSuitCharacters)
|
||||
mux.HandleFunc("GET /suitbuilder/sets", srv.handleSuitSets)
|
||||
|
||||
httpSrv := &http.Server{Addr: addr, Handler: withLogging(mux), ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() {
|
||||
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("http server failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
logger.Info("listening", "addr", addr)
|
||||
|
||||
<-ctx.Done()
|
||||
shutdownCtx, c := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer c()
|
||||
_ = httpSrv.Shutdown(shutdownCtx)
|
||||
logger.Info("stopped")
|
||||
}
|
||||
|
||||
// GET /health (main.py:2674)
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
dbOK := s.pool.Ping(ctx) == nil
|
||||
status := "healthy"
|
||||
if !dbOK {
|
||||
status = "degraded"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": status,
|
||||
"timestamp": pyISO(time.Now()),
|
||||
"database_connected": dbOK,
|
||||
"version": "1.0.0",
|
||||
})
|
||||
}
|
||||
|
||||
// GET /sets/list (main.py:2712)
|
||||
func (s *Server) handleSetsList(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
rows, err := queryRowsAsMaps(ctx, s.pool, `
|
||||
SELECT enh.item_set, COUNT(*) AS item_count
|
||||
FROM item_enhancements enh
|
||||
WHERE enh.item_set IS NOT NULL AND enh.item_set != ''
|
||||
GROUP BY enh.item_set
|
||||
ORDER BY item_count DESC, enh.item_set`)
|
||||
if err != nil {
|
||||
s.dbErr(w, "sets/list", err)
|
||||
return
|
||||
}
|
||||
sets := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
setID := toStr(row["item_set"])
|
||||
name, ok := s.attributeSets[setID]
|
||||
if !ok {
|
||||
name = "Unknown Set " + setID
|
||||
}
|
||||
sets = append(sets, map[string]any{"id": setID, "name": name, "item_count": row["item_count"]})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sets": sets})
|
||||
}
|
||||
|
||||
// GET /characters/list (main.py:4291)
|
||||
func (s *Server) handleCharactersList(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
rows, err := queryRowsAsMaps(ctx, s.pool, `
|
||||
SELECT character_name, COUNT(*) AS item_count, MAX(timestamp) AS last_updated
|
||||
FROM items GROUP BY character_name ORDER BY character_name`)
|
||||
if err != nil {
|
||||
s.dbErr(w, "characters/list", err)
|
||||
return
|
||||
}
|
||||
formatTimes(rows, "last_updated")
|
||||
chars := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
chars = append(chars, map[string]any{
|
||||
"character_name": row["character_name"],
|
||||
"item_count": row["item_count"],
|
||||
"last_updated": row["last_updated"],
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"characters": chars, "total_characters": len(chars)})
|
||||
}
|
||||
|
||||
func (s *Server) dbErr(w http.ResponseWriter, where string, err error) {
|
||||
s.log.Error("db query failed", "where", where, "err", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "Internal server error"})
|
||||
}
|
||||
|
||||
type enumMaps struct {
|
||||
sets map[string]string
|
||||
objectClasses map[int]string
|
||||
materials map[int]string
|
||||
spells map[int]map[string]any
|
||||
equipMaskMap map[int]string
|
||||
equipMaskOrdered []equipMaskEntry
|
||||
}
|
||||
|
||||
// loadEnums reads the comprehensive enum DB and extracts AttributeSetInfo
|
||||
// (set-id -> name), ObjectClass (id -> name), and MaterialType (id -> name),
|
||||
// mirroring load_comprehensive_enums (dictionaries first, then enums).
|
||||
func loadEnums(path string) (enumMaps, error) {
|
||||
var em enumMaps
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return em, err
|
||||
}
|
||||
type valmap struct {
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
var db struct {
|
||||
Dictionaries map[string]valmap `json:"dictionaries"`
|
||||
Enums map[string]valmap `json:"enums"`
|
||||
ObjectClasses valmap `json:"object_classes"`
|
||||
Spells struct {
|
||||
Values map[string]map[string]any `json:"values"`
|
||||
} `json:"spells"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &db); err != nil {
|
||||
return em, err
|
||||
}
|
||||
em.sets = map[string]string{}
|
||||
if d, ok := db.Dictionaries["AttributeSetInfo"]; ok && len(d.Values) > 0 {
|
||||
em.sets = d.Values
|
||||
} else if e, ok := db.Enums["AttributeSetInfo"]; ok {
|
||||
em.sets = e.Values
|
||||
}
|
||||
intMap := func(v valmap) map[int]string {
|
||||
m := map[int]string{}
|
||||
for k, val := range v.Values {
|
||||
if n, err := strconv.Atoi(k); err == nil {
|
||||
m[n] = val
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
em.objectClasses = intMap(db.ObjectClasses)
|
||||
em.materials = intMap(db.Enums["MaterialType"])
|
||||
// SpellTable: spell-id -> raw value object (translate_spell reads .name etc.).
|
||||
em.spells = map[int]map[string]any{}
|
||||
for k, v := range db.Spells.Values {
|
||||
if n, err := strconv.Atoi(k); err == nil {
|
||||
em.spells[n] = v
|
||||
}
|
||||
}
|
||||
// EquipMask: mask -> technical name. Skip EXPR: keys; order by ascending mask
|
||||
// (the JSON order) so multi-bit bit-flag decode joins parts deterministically.
|
||||
em.equipMaskMap = map[int]string{}
|
||||
for k, v := range db.Enums["EquipMask"].Values {
|
||||
if strings.HasPrefix(k, "EXPR:") {
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.Atoi(k); err == nil {
|
||||
em.equipMaskMap[n] = v
|
||||
em.equipMaskOrdered = append(em.equipMaskOrdered, equipMaskEntry{Mask: n, Name: v})
|
||||
}
|
||||
}
|
||||
sort.Slice(em.equipMaskOrdered, func(i, j int) bool { return em.equipMaskOrdered[i].Mask < em.equipMaskOrdered[j].Mask })
|
||||
return em, nil
|
||||
}
|
||||
|
||||
// translateSpell mirrors main.py translate_spell: returns the spell dict
|
||||
// (id + name/description/school/difficulty/duration/mana/family), defaulting
|
||||
// missing fields to "" and the name to Unknown_Spell_<id>.
|
||||
func (s *Server) translateSpell(id int) map[string]any {
|
||||
raw := s.spells[id]
|
||||
get := func(k string, def any) any {
|
||||
if raw != nil {
|
||||
if v, ok := raw[k]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
return map[string]any{
|
||||
"id": id,
|
||||
"name": get("name", fmt.Sprintf("Unknown_Spell_%d", id)),
|
||||
"description": get("description", ""),
|
||||
"school": get("school", ""),
|
||||
"difficulty": get("difficulty", ""),
|
||||
"duration": get("duration", ""),
|
||||
"mana": get("mana", ""),
|
||||
"family": get("family", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func withLogging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
slog.Info("http", "method", r.Method, "path", r.URL.Path, "dur_ms", time.Since(start).Milliseconds())
|
||||
})
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// Weapon OD = the user's VirindiTank Loot.utl classification (0–10, casters
|
||||
// 0–7), printed by Mag-Tools on ident. OD = clamp(stat − baseline, 1, cap) for
|
||||
// the first matching bucket, else null. Buckets/baselines transcribed from
|
||||
// Loot.utl (see design doc 2026-07-15-weapon-od-loot-profile).
|
||||
|
||||
type odBucket struct {
|
||||
objectClass int // 1 melee, 9 missile, 31 caster
|
||||
skill int // 218103840 for melee/missile, 159 for casters; 0 = any
|
||||
mastery int // IntValues 353; -1 = not checked
|
||||
nameAlts []string // OR of case-sensitive substrings; empty = no name gate
|
||||
statKey string // IntValues key (melee/missile) or ""
|
||||
dblStatKey string // DoubleValues key (casters) or ""
|
||||
baseline float64
|
||||
cap int
|
||||
}
|
||||
|
||||
// Ordered: name-gated buckets are effectively specific; first match wins.
|
||||
var odBuckets = []odBucket{
|
||||
// --- Melee (object_class 1, stat MaxDamage 218103842, cap 10) ---
|
||||
{1, 44, 3, []string{"Silifi", "Lugian", "War Axe", "Battle"}, "218103842", "", 74, 10}, // (H) Axe
|
||||
{1, 45, 3, []string{"Dolabra", "Ono", "Hand", "War Hammer"}, "218103842", "", 61, 10}, // (L) Axe
|
||||
{1, 46, 3, []string{"Hatchet", "Shou-ono", "Tungi", "Hammer"}, "218103842", "", 61, 10}, // (F) Axe
|
||||
{1, 44, 6, []string{"Stiletto", "Jambiya"}, "218103842", "", 38, 10}, // (H) Dagger MS
|
||||
{1, 44, 6, nil, "218103842", "", 71, 10}, // (H) Dagger
|
||||
{1, 45, 6, nil, "218103842", "", 58, 10}, // (L) Dagger (MS shares names=nil; see note)
|
||||
{1, 46, 6, []string{"Knife", "Lancet"}, "218103842", "", 28, 10}, // (F) Dagger MS
|
||||
{1, 46, 6, nil, "218103842", "", 58, 10}, // (F) Dagger
|
||||
{1, 44, 4, []string{"Mazule", "Mace", "Morning"}, "218103842", "", 69, 10}, // (H) Mace
|
||||
{1, 45, 4, []string{"Club", "Kasrullah"}, "218103842", "", 56, 10}, // (L) Mace
|
||||
{1, 46, 4, []string{"Board", "Tofun", "Dabus"}, "218103842", "", 56, 10}, // (F) Mace
|
||||
{1, 44, 5, []string{"Glaive", "Trident", "Partizan"}, "218103842", "", 72, 10}, // (H) Spear
|
||||
{1, 45, 5, []string{"Spear", "Yari"}, "218103842", "", 60, 10}, // (L) Spear
|
||||
{1, 44, 7, []string{"Nabut", "Stick"}, "218103842", "", 69, 10}, // (H) Staff
|
||||
{1, 45, 7, nil, "218103842", "", 57, 10}, // (L) Staff
|
||||
{1, 46, 7, []string{"Jo", "Bastone"}, "218103842", "", 57, 10}, // (F) Staff
|
||||
{1, 44, 2, []string{"Takuba", "Flamberge", "Ken", "Long", "Tachi"}, "218103842", "", 71, 10}, // (H) Sword
|
||||
{1, 45, 2, []string{"Dericost", "Broad", "Shamshir", "Kaskara", "Spada"}, "218103842", "", 58, 10}, // (L) Sword
|
||||
{1, 46, 2, []string{"Scimitar", "Yaoji", "Short", "Sabra", "Simi"}, "218103842", "", 58, 10}, // (F) Sword
|
||||
{1, 44, 1, []string{"Nekode", "Cestus"}, "218103842", "", 59, 10}, // (H) UA
|
||||
{1, 45, 1, []string{"Katar", "Knuckles"}, "218103842", "", 48, 10}, // (L) UA
|
||||
{1, 46, 1, []string{"Claw", "Wraps"}, "218103842", "", 48, 10}, // (F) UA
|
||||
{1, 41, -1, []string{"Nodachi", "Shashqa", "Spadone", "Greataxe", "Quadrelle", "Khanda-handled", "Tetsubo", "Star"}, "218103842", "", 45, 10}, // (2H) Cleaving
|
||||
{1, 41, -1, []string{"Assagai", "Pike", "Magari", "Corsesca"}, "218103842", "", 48, 10}, // (2H) Spear
|
||||
// --- Crossbow (object_class 9, mastery 9, stat 218103839, cap 10) ---
|
||||
{9, 47, 9, nil, "218103839", "", 77, 10},
|
||||
// --- Casters (object_class 31, stat elemVsMon 152, cap 7) ---
|
||||
{31, 34, -1, []string{"Baton", "Sceptre", "Staff"}, "", "152", 1.18, 7}, // War
|
||||
{31, 43, -1, []string{"Nether"}, "", "152", 1.18, 7}, // Void
|
||||
// Bows (mastery 8) and Thrown (mastery 10) are intentionally ABSENT → null.
|
||||
}
|
||||
|
||||
func nameMatches(name string, alts []string) bool {
|
||||
if len(alts) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, a := range alts {
|
||||
if strings.Contains(name, a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// computeOD returns the loot-profile OD (integer, as float64 for the DB column)
|
||||
// and ok=false when no bucket matches or the stat is at/below baseline.
|
||||
func computeOD(raw map[string]any) (float64, bool) {
|
||||
oc := rawI(raw, "ObjectClass", 0)
|
||||
iv := bag(raw, "IntValues")
|
||||
dv := bag(raw, "DoubleValues")
|
||||
name := rawS(raw, "Name")
|
||||
skillMelee := ivI(iv, "218103840", 0)
|
||||
skillCaster := ivI(iv, "159", 0)
|
||||
mastery := ivI(iv, "353", -999)
|
||||
|
||||
for _, b := range odBuckets {
|
||||
if b.objectClass != oc {
|
||||
continue
|
||||
}
|
||||
sk := skillMelee
|
||||
if b.objectClass == 31 {
|
||||
sk = skillCaster
|
||||
}
|
||||
if b.skill != 0 && b.skill != sk {
|
||||
continue
|
||||
}
|
||||
if b.mastery != -1 && b.mastery != mastery {
|
||||
continue
|
||||
}
|
||||
if !nameMatches(name, b.nameAlts) {
|
||||
continue
|
||||
}
|
||||
var stat float64
|
||||
if b.dblStatKey != "" {
|
||||
stat = dvF(dv, b.dblStatKey, 0)
|
||||
} else {
|
||||
stat = float64(ivI(iv, b.statKey, 0))
|
||||
}
|
||||
od := stat - b.baseline
|
||||
if b.dblStatKey != "" {
|
||||
od = od * 100 // caster elemVsMon: percentage points
|
||||
}
|
||||
odInt := int(od + 0.5) // round
|
||||
if odInt < 1 {
|
||||
return 0, false
|
||||
}
|
||||
if odInt > b.cap {
|
||||
odInt = b.cap
|
||||
}
|
||||
return float64(odInt), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOD_MeleeTetsubo(t *testing.T) {
|
||||
// 2H Cleaving baseline 45, name "Tetsubo", maxdmg 85 → clamp(85-45,1,10)=10.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Tetsubo",
|
||||
"IntValues": map[string]any{"218103840": 41.0, "353": 11.0, "218103842": 85.0}}
|
||||
if od, ok := computeOD(raw); !ok || od != 10 {
|
||||
t.Fatalf("Tetsubo OD=%v ok=%v want 10", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_MeleeMidTier(t *testing.T) {
|
||||
// Heavy Axe baseline 74, name "War Axe", maxdmg 79 → clamp(79-74,1,10)=5.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze War Axe",
|
||||
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 79.0}}
|
||||
if od, ok := computeOD(raw); !ok || od != 5 {
|
||||
t.Fatalf("War Axe OD=%v ok=%v want 5", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_MeleeBelowBaselineNull(t *testing.T) {
|
||||
// Heavy Axe baseline 74, maxdmg 74 → stat-baseline=0 (<1) → null.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze War Axe",
|
||||
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 74.0}}
|
||||
if _, ok := computeOD(raw); ok {
|
||||
t.Fatal("maxdmg == baseline must be null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_MeleeNameGateNull(t *testing.T) {
|
||||
// Heavy Axe bucket requires name in {Silifi,Lugian,War Axe,Battle}. A skill-44
|
||||
// mastery-3 item NOT matching any name and matching no other bucket → null.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze Frobnicator",
|
||||
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 84.0}}
|
||||
if _, ok := computeOD(raw); ok {
|
||||
t.Fatal("name not matching the Heavy Axe filter must be null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_Crossbow(t *testing.T) {
|
||||
// Xbow baseline 77, k839=89 → clamp(89-77,1,10)=10.
|
||||
raw := map[string]any{"ObjectClass": 9.0, "Name": "Fire Compound Crossbow",
|
||||
"IntValues": map[string]any{"218103840": 47.0, "353": 9.0, "218103839": 89.0}}
|
||||
if od, ok := computeOD(raw); !ok || od != 10 {
|
||||
t.Fatalf("Xbow OD=%v ok=%v want 10", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_WarCaster(t *testing.T) {
|
||||
// War caster: elemVsMon 1.40 → clamp(round((1.40-1.18)*100),1,7)=clamp(22,1,7)=7.
|
||||
raw := map[string]any{"ObjectClass": 31.0, "Name": "Diamond Frost Baton",
|
||||
"IntValues": map[string]any{"159": 34.0},
|
||||
"DoubleValues": map[string]any{"152": 1.40}}
|
||||
if od, ok := computeOD(raw); !ok || od != 7 {
|
||||
t.Fatalf("War caster OD=%v ok=%v want 7", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_VoidCasterTier(t *testing.T) {
|
||||
// Void caster: name must contain "Nether"; elemVsMon 1.22 → round((1.22-1.18)*100)=4.
|
||||
raw := map[string]any{"ObjectClass": 31.0, "Name": "Silver Nether Staff",
|
||||
"IntValues": map[string]any{"159": 43.0},
|
||||
"DoubleValues": map[string]any{"152": 1.22}}
|
||||
if od, ok := computeOD(raw); !ok || od != 4 {
|
||||
t.Fatalf("Void caster OD=%v ok=%v want 4", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_BowDeferredNull(t *testing.T) {
|
||||
// Bows (mastery 8) are deferred → always null in this pass.
|
||||
raw := map[string]any{"ObjectClass": 9.0, "Name": "Corsair's Arc",
|
||||
"IntValues": map[string]any{"218103840": 47.0, "353": 8.0, "218103839": 70.0}}
|
||||
if _, ok := computeOD(raw); ok {
|
||||
t.Fatal("bows are deferred → null")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,438 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Item-processor: a faithful port of inventory-service extract_item_properties +
|
||||
// the process_inventory column population. Given a raw item dict it produces the
|
||||
// normalized rows for the 7 tables, applying the exact per-table sentinel->NULL
|
||||
// rules. Validated against production's stored rows (read-only) via /debug/process.
|
||||
|
||||
// --- value-bag accessors (JSON object keys are strings) ---
|
||||
|
||||
func bag(raw map[string]any, name string) map[string]any {
|
||||
if m, ok := raw[name].(map[string]any); ok {
|
||||
return m
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
func ivI(iv map[string]any, key string, def int) int {
|
||||
if v, ok := iv[key]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
func dvF(dv map[string]any, key string, def float64) float64 {
|
||||
if v, ok := dv[key]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
func rawI(raw map[string]any, key string, def int) int {
|
||||
if v, ok := raw[key]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
func rawF(raw map[string]any, key string, def float64) float64 {
|
||||
if v, ok := raw[key]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
func rawS(raw map[string]any, key string) string {
|
||||
if s, ok := raw[key].(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func rawB(raw map[string]any, key string) bool {
|
||||
b, _ := raw[key].(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
// IV first, else top-level field, else default (e.g. max_damage).
|
||||
func ivElseTopI(iv, raw map[string]any, ivKey, topKey string, def int) int {
|
||||
if v, ok := iv[ivKey]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
return rawI(raw, topKey, def)
|
||||
}
|
||||
func dvElseTopF(dv, raw map[string]any, dvKey, topKey string, def float64) float64 {
|
||||
if v, ok := dv[dvKey]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return rawF(raw, topKey, def)
|
||||
}
|
||||
|
||||
// translateMaterial: materials[id] else "Unknown_Material_{id}".
|
||||
func (s *Server) translateMaterial(id int) string {
|
||||
if n, ok := s.materials[id]; ok {
|
||||
return n
|
||||
}
|
||||
return "Unknown_Material_" + strconv.Itoa(id)
|
||||
}
|
||||
|
||||
func toIntList(v any) []int {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]int, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
if f, ok := e.(float64); ok {
|
||||
out = append(out, int(f))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// processItem produces the normalized columns for all 7 tables, post-null.
|
||||
func (s *Server) processItem(raw map[string]any) map[string]any {
|
||||
iv := bag(raw, "IntValues")
|
||||
dv := bag(raw, "DoubleValues")
|
||||
|
||||
items := map[string]any{
|
||||
"item_id": rawValue(raw, "Id"),
|
||||
"name": rawS(raw, "Name"),
|
||||
"icon": rawI(raw, "Icon", 0),
|
||||
"object_class": rawI(raw, "ObjectClass", 0),
|
||||
"value": rawI(raw, "Value", 0),
|
||||
"burden": rawI(raw, "Burden", 0),
|
||||
"has_id_data": rawB(raw, "HasIdData"),
|
||||
"last_id_time": rawI(raw, "LastIdTime", 0),
|
||||
"current_wielded_location": ivI(iv, "10", 0),
|
||||
"container_id": rawI(raw, "ContainerId", 0),
|
||||
"slot": ivI(iv, "231735296", -1),
|
||||
"bonded": ivI(iv, "33", 0),
|
||||
"attuned": ivI(iv, "114", 0),
|
||||
"unique": ivI(iv, "279", 0) != 0,
|
||||
"stack_size": ivI(iv, "12", 1),
|
||||
"max_stack_size": ivI(iv, "11", 1),
|
||||
"items_capacity": nilNeg(ivI(iv, "6", -1)),
|
||||
"containers_capacity": nilNeg(ivI(iv, "7", -1)),
|
||||
"structure": nilNeg(ivI(iv, "92", -1)),
|
||||
"max_structure": nilNeg(ivI(iv, "91", -1)),
|
||||
"rare_id": nilNeg(ivI(iv, "17", -1)),
|
||||
"lifespan": nilNeg(ivI(iv, "267", -1)),
|
||||
"remaining_lifespan": nilNeg(ivI(iv, "268", -1)),
|
||||
}
|
||||
|
||||
if od, ok := computeOD(raw); ok {
|
||||
items["od_rating"] = od
|
||||
}
|
||||
|
||||
// combat (sentinel defaults), then base values merged.
|
||||
wt := ivI(iv, "218103835", -1)
|
||||
if wt > 100 {
|
||||
wt = 100
|
||||
}
|
||||
combat := map[string]any{
|
||||
"max_damage": ivElseTopI(iv, raw, "218103842", "MaxDamage", -1),
|
||||
"damage_type": ivI(iv, "218103832", -1),
|
||||
"damage_bonus": dvElseTopF(dv, raw, "167772174", "DamageBonus", -1.0),
|
||||
"elemental_damage_bonus": ivI(iv, "204", -1),
|
||||
"elemental_damage_vs_monsters": dvF(dv, "152", -1.0),
|
||||
"variance": dvF(dv, "167772171", -1.0),
|
||||
"cleaving": ivI(iv, "292", -1),
|
||||
"crit_damage_rating": ivI(iv, "314", -1),
|
||||
"damage_over_time": ivI(iv, "318", -1),
|
||||
"attack_bonus": dvElseTopF(dv, raw, "167772170", "AttackBonus", -1.0),
|
||||
"weapon_time": wt,
|
||||
"weapon_skill": ivI(iv, "218103840", -1),
|
||||
"armor_level": topElseIvI(raw, iv, "ArmorLevel", "28", -1),
|
||||
"melee_defense_bonus": dvF(dv, "29", -1.0),
|
||||
"missile_defense_bonus": dvF(dv, "149", -1.0),
|
||||
"magic_defense_bonus": dvF(dv, "150", -1.0),
|
||||
"resist_magic": ivI(iv, "36", -1),
|
||||
"crit_resist_rating": ivI(iv, "315", -1),
|
||||
"crit_damage_resist_rating": ivI(iv, "316", -1),
|
||||
"dot_resist_rating": ivI(iv, "350", -1),
|
||||
"life_resist_rating": ivI(iv, "351", -1),
|
||||
"nether_resist_rating": ivI(iv, "331", -1),
|
||||
"heal_over_time": ivI(iv, "312", -1),
|
||||
"healing_resist_rating": ivI(iv, "317", -1),
|
||||
"mana_conversion_bonus": dvF(dv, "144", -1.0),
|
||||
"pk_damage_rating": ivI(iv, "381", -1),
|
||||
"pk_damage_resist_rating": ivI(iv, "382", -1),
|
||||
"gear_pk_damage_rating": ivI(iv, "383", -1),
|
||||
"gear_pk_damage_resist_rating": ivI(iv, "384", -1),
|
||||
}
|
||||
s.mergeBaseValues(raw, combat)
|
||||
|
||||
requirements := map[string]any{
|
||||
"wield_level": rawI(raw, "WieldLevel", -1),
|
||||
"skill_level": rawI(raw, "SkillLevel", -1),
|
||||
"lore_requirement": rawI(raw, "LoreRequirement", -1),
|
||||
"equip_skill": rawValueStr(raw, "EquipSkill"),
|
||||
}
|
||||
|
||||
// material + item_set translated strings.
|
||||
var material any
|
||||
if m := rawS(raw, "Material"); m != "" {
|
||||
material = m
|
||||
} else if v, ok := iv["131"]; ok {
|
||||
if f, ok := v.(float64); ok && int(f) != 0 {
|
||||
name := s.translateMaterial(int(f))
|
||||
if !strings.HasPrefix(name, "Unknown_Material_") {
|
||||
material = name
|
||||
}
|
||||
}
|
||||
}
|
||||
var itemSet any
|
||||
if v, ok := iv["265"]; ok {
|
||||
if f, ok := v.(float64); ok && int(f) != 0 {
|
||||
id := strconv.Itoa(int(f))
|
||||
if n, ok := s.attributeSets[id]; ok {
|
||||
itemSet = n
|
||||
} else {
|
||||
itemSet = id
|
||||
}
|
||||
}
|
||||
}
|
||||
enhancements := map[string]any{
|
||||
"material": material,
|
||||
"imbue": rawValueStr(raw, "Imbue"),
|
||||
"tinks": rawI(raw, "Tinks", -1),
|
||||
"workmanship": rawF(raw, "Workmanship", -1.0),
|
||||
"num_times_tinkered": ivI(iv, "171", -1),
|
||||
"free_tinkers_bitfield": ivI(iv, "264", -1),
|
||||
"num_items_in_material": ivI(iv, "170", -1),
|
||||
"imbue_attempts": ivI(iv, "205", -1),
|
||||
"imbue_successes": ivI(iv, "206", -1),
|
||||
"imbued_effect2": ivI(iv, "303", -1),
|
||||
"imbued_effect3": ivI(iv, "304", -1),
|
||||
"imbued_effect4": ivI(iv, "305", -1),
|
||||
"imbued_effect5": ivI(iv, "306", -1),
|
||||
"imbue_stacking_bits": ivI(iv, "311", -1),
|
||||
"item_set": itemSet,
|
||||
"equipment_set_extra": ivI(iv, "321", -1),
|
||||
"aetheria_bitfield": ivI(iv, "322", -1),
|
||||
"heritage_specific_armor": ivI(iv, "324", -1),
|
||||
"shared_cooldown": ivI(iv, "280", -1),
|
||||
}
|
||||
|
||||
ratingKeys := map[string]string{
|
||||
"damage_rating": "307", "damage_resist_rating": "308", "crit_rating": "313",
|
||||
"crit_resist_rating": "315", "crit_damage_rating": "314", "crit_damage_resist_rating": "316",
|
||||
"heal_boost_rating": "323", "vitality_rating": "341", "healing_rating": "342",
|
||||
"weakness_rating": "329", "nether_over_time": "330", "healing_resist_rating": "317",
|
||||
"nether_resist_rating": "331", "dot_resist_rating": "350", "life_resist_rating": "351",
|
||||
"sneak_attack_rating": "356", "recklessness_rating": "357", "deception_rating": "358",
|
||||
"pk_damage_rating": "381", "pk_damage_resist_rating": "382", "gear_pk_damage_rating": "383",
|
||||
"gear_pk_damage_resist_rating": "384", "gear_damage": "370", "gear_damage_resist": "371",
|
||||
"gear_crit": "372", "gear_crit_resist": "373", "gear_crit_damage": "374",
|
||||
"gear_crit_damage_resist": "375", "gear_healing_boost": "376", "gear_max_health": "379",
|
||||
"gear_nether_resist": "377", "gear_life_resist": "378", "gear_overpower": "388",
|
||||
"gear_overpower_resist": "389",
|
||||
}
|
||||
ratings := map[string]any{}
|
||||
for col, k := range ratingKeys {
|
||||
ratings[col] = ivI(iv, k, -1)
|
||||
}
|
||||
|
||||
// spells: union of Spells + ActiveSpells, is_active = in ActiveSpells.
|
||||
spells := toIntList(raw["Spells"])
|
||||
active := toIntList(raw["ActiveSpells"])
|
||||
activeSet := map[int]bool{}
|
||||
for _, id := range active {
|
||||
activeSet[id] = true
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
var spellRows []map[string]any
|
||||
for _, id := range append(append([]int{}, spells...), active...) {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
spellRows = append(spellRows, map[string]any{"spell_id": id, "is_active": activeSet[id]})
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"items": items,
|
||||
"combat": nullify(combat, sentinelCombat),
|
||||
"requirements": nullify(requirements, sentinelReq),
|
||||
"enhancements": nullifyKeep(enhancements, sentinelEnh), // ALWAYS inserts a row
|
||||
"ratings": nullify(ratings, sentinelRating),
|
||||
"spells": spellRows,
|
||||
}
|
||||
}
|
||||
|
||||
func topElseIvI(raw, iv map[string]any, topKey, ivKey string, def int) int {
|
||||
if v, ok := raw[topKey]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
return ivI(iv, ivKey, def)
|
||||
}
|
||||
|
||||
func rawValue(raw map[string]any, key string) any {
|
||||
if v, ok := raw[key]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return int64(f)
|
||||
}
|
||||
return v
|
||||
}
|
||||
if v, ok := raw[strings.ToLower(key)]; ok { // Id -> id fallback
|
||||
if f, ok := v.(float64); ok {
|
||||
return int64(f)
|
||||
}
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func rawValueStr(raw map[string]any, key string) any {
|
||||
if s, ok := raw[key].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nilNeg(v int) any {
|
||||
if v == -1 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// per-table sentinel predicates: true => value should become NULL.
|
||||
func sentinelCombat(v any) bool { return isNeg1(v) || isNeg1f(v) }
|
||||
func sentinelReq(v any) bool { return isNeg1(v) || v == nil || v == "" }
|
||||
func sentinelEnh(v any) bool { return isNeg1(v) || isNeg1f(v) || v == nil || v == "" }
|
||||
func sentinelRating(v any) bool { return isNeg1(v) || isNeg1f(v) || v == nil }
|
||||
|
||||
func isNeg1(v any) bool { i, ok := v.(int); return ok && i == -1 }
|
||||
func isNeg1f(v any) bool { f, ok := v.(float64); return ok && f == -1.0 }
|
||||
|
||||
// nullify replaces sentinel values with nil. Returns nil for the whole table if
|
||||
// every value is sentinel (the per-table "skip insert" guard) — EXCEPT
|
||||
// enhancements, which always inserts; we keep its map even if all-null.
|
||||
func nullify(m map[string]any, isSentinel func(any) bool) map[string]any {
|
||||
any_ := false
|
||||
out := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
if isSentinel(v) {
|
||||
out[k] = nil
|
||||
} else {
|
||||
out[k] = v
|
||||
any_ = true
|
||||
}
|
||||
}
|
||||
if !any_ {
|
||||
return nil // combat/req/ratings: skip the insert when all-sentinel
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nullifyKeep is like nullify but ALWAYS returns the map (for item_enhancements,
|
||||
// which inserts a row even when every value is NULL).
|
||||
func nullifyKeep(m map[string]any, isSentinel func(any) bool) map[string]any {
|
||||
out := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
if isSentinel(v) {
|
||||
out[k] = nil
|
||||
} else {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeBaseValues reverses active-spell buffs into base_* columns (compute_base_values).
|
||||
type spellEffect struct {
|
||||
key int
|
||||
change, bonus float64
|
||||
}
|
||||
|
||||
var intEffects = map[int]spellEffect{
|
||||
1616: {218103842, 20, 0}, 2096: {218103842, 22, 0}, 5183: {218103842, 24, 0}, 4395: {218103842, 24, 0}, 3688: {218103842, 300, 0},
|
||||
2598: {218103842, 2, 2}, 2586: {218103842, 4, 4}, 4661: {218103842, 7, 7}, 6089: {218103842, 10, 10},
|
||||
1486: {28, 200, 0}, 2108: {28, 220, 0}, 4407: {28, 240, 0},
|
||||
2604: {28, 20, 20}, 2592: {28, 40, 40}, 4667: {28, 60, 60}, 6095: {28, 80, 80},
|
||||
}
|
||||
var doubleEffects = map[int]spellEffect{
|
||||
3258: {152, 0.06, 0}, 3259: {152, 0.07, 0}, 5182: {152, 0.08, 0}, 4414: {152, 0.08, 0}, 3735: {152, 0.15, 0},
|
||||
3251: {152, 0.01, 0.01}, 3250: {152, 0.03, 0.03}, 4670: {152, 0.05, 0.05}, 6098: {152, 0.07, 0.07},
|
||||
1592: {167772172, 0.15, 0}, 2106: {167772172, 0.17, 0}, 4405: {167772172, 0.20, 0},
|
||||
2603: {167772172, 0.03, 0.03}, 2591: {167772172, 0.05, 0.05}, 4666: {167772172, 0.07, 0.07}, 6094: {167772172, 0.09, 0.09},
|
||||
1605: {29, 0.15, 0}, 2101: {29, 0.17, 0}, 4400: {29, 0.20, 0}, 3699: {29, 0.25, 0},
|
||||
2600: {29, 0.03, 0.03}, 3985: {29, 0.04, 0.04}, 2588: {29, 0.05, 0.05}, 4663: {29, 0.07, 0.07}, 6091: {29, 0.09, 0.09},
|
||||
1480: {144, 1.60, 0}, 2117: {144, 1.70, 0}, 4418: {144, 1.80, 0},
|
||||
3201: {144, 1.05, 1.05}, 3199: {144, 1.10, 1.10}, 3202: {144, 1.15, 1.15}, 3200: {144, 1.20, 1.20}, 6086: {144, 1.25, 1.25}, 6087: {144, 1.30, 1.30},
|
||||
}
|
||||
|
||||
func (s *Server) mergeBaseValues(raw, combat map[string]any) {
|
||||
spells := toIntList(raw["Spells"])
|
||||
active := toIntList(raw["ActiveSpells"])
|
||||
for _, p := range []struct {
|
||||
prop string
|
||||
key int
|
||||
}{{"max_damage", 218103842}, {"armor_level", 28}} {
|
||||
val, ok := combat[p.prop].(int)
|
||||
if !ok || val == -1 {
|
||||
continue
|
||||
}
|
||||
for _, sid := range active {
|
||||
if e, ok := intEffects[sid]; ok && e.key == p.key {
|
||||
val -= int(e.change)
|
||||
}
|
||||
}
|
||||
for _, sid := range spells {
|
||||
if e, ok := intEffects[sid]; ok && e.key == p.key && e.bonus != 0 {
|
||||
val += int(e.bonus)
|
||||
}
|
||||
}
|
||||
combat["base_"+p.prop] = val
|
||||
}
|
||||
for _, p := range []struct {
|
||||
prop string
|
||||
key int
|
||||
}{{"attack_bonus", 167772172}, {"melee_defense_bonus", 29}, {"elemental_damage_vs_monsters", 152}, {"mana_conversion_bonus", 144}} {
|
||||
val, ok := combat[p.prop].(float64)
|
||||
if !ok || val == -1.0 {
|
||||
continue
|
||||
}
|
||||
for _, sid := range active {
|
||||
if e, ok := doubleEffects[sid]; ok && e.key == p.key {
|
||||
val -= e.change
|
||||
}
|
||||
}
|
||||
for _, sid := range spells {
|
||||
if e, ok := doubleEffects[sid]; ok && e.key == p.key && e.bonus != 0 {
|
||||
val += e.bonus
|
||||
}
|
||||
}
|
||||
combat["base_"+p.prop] = math.Round(val*10000) / 10000
|
||||
}
|
||||
}
|
||||
|
||||
// POST /debug/process — returns the normalized columns for a raw item JSON body
|
||||
// (loopback validation against production's stored rows; never writes).
|
||||
func (s *Server) handleDebugProcess(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 8<<20))
|
||||
var raw map[string]any
|
||||
if json.Unmarshal(body, &raw) != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.processItem(raw))
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// initSchema creates the normalized inventory schema on an ingest-owned database
|
||||
// (a faithful replica of inventory-service/database.py). Run only when this
|
||||
// instance owns its DB (READ_ONLY=false) — never against production. Idempotent;
|
||||
// logs and continues per statement.
|
||||
func initSchema(ctx context.Context, pool *pgxpool.Pool, log *slog.Logger) {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
character_name VARCHAR(50) NOT NULL,
|
||||
item_id BIGINT NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
icon INTEGER NOT NULL,
|
||||
object_class INTEGER NOT NULL,
|
||||
value INTEGER DEFAULT 0,
|
||||
burden INTEGER DEFAULT 0,
|
||||
current_wielded_location INTEGER DEFAULT 0,
|
||||
container_id BIGINT DEFAULT 0,
|
||||
slot INTEGER DEFAULT -1,
|
||||
bonded INTEGER DEFAULT 0,
|
||||
attuned INTEGER DEFAULT 0,
|
||||
"unique" BOOLEAN DEFAULT false,
|
||||
stack_size INTEGER DEFAULT 1,
|
||||
max_stack_size INTEGER DEFAULT 1,
|
||||
items_capacity INTEGER,
|
||||
containers_capacity INTEGER,
|
||||
structure INTEGER,
|
||||
max_structure INTEGER,
|
||||
rare_id INTEGER,
|
||||
lifespan INTEGER,
|
||||
remaining_lifespan INTEGER,
|
||||
od_rating DOUBLE PRECISION,
|
||||
has_id_data BOOLEAN DEFAULT false,
|
||||
last_id_time BIGINT DEFAULT 0,
|
||||
CONSTRAINT uq_char_item UNIQUE (character_name, item_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_items_character_name ON items (character_name)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_items_name ON items (name)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_items_object_class ON items (object_class)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_items_current_wielded_location ON items (current_wielded_location)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS item_combat_stats (
|
||||
item_id INTEGER PRIMARY KEY REFERENCES items(id),
|
||||
max_damage INTEGER, damage INTEGER, damage_type INTEGER, damage_bonus DOUBLE PRECISION,
|
||||
elemental_damage_bonus INTEGER, elemental_damage_vs_monsters DOUBLE PRECISION, variance DOUBLE PRECISION,
|
||||
cleaving INTEGER, crit_damage_rating INTEGER, damage_over_time INTEGER,
|
||||
attack_bonus DOUBLE PRECISION, weapon_time INTEGER, weapon_skill INTEGER,
|
||||
armor_level INTEGER, shield_value INTEGER, melee_defense_bonus DOUBLE PRECISION,
|
||||
missile_defense_bonus DOUBLE PRECISION, magic_defense_bonus DOUBLE PRECISION,
|
||||
resist_magic INTEGER, crit_resist_rating INTEGER, crit_damage_resist_rating INTEGER,
|
||||
dot_resist_rating INTEGER, life_resist_rating INTEGER, nether_resist_rating INTEGER,
|
||||
heal_over_time INTEGER, healing_resist_rating INTEGER, mana_conversion_bonus DOUBLE PRECISION,
|
||||
pk_damage_rating INTEGER, pk_damage_resist_rating INTEGER, gear_pk_damage_rating INTEGER,
|
||||
gear_pk_damage_resist_rating INTEGER,
|
||||
base_armor_level INTEGER, base_max_damage INTEGER, base_attack_bonus DOUBLE PRECISION,
|
||||
base_melee_defense_bonus DOUBLE PRECISION, base_elemental_damage_vs_monsters DOUBLE PRECISION,
|
||||
base_mana_conversion_bonus DOUBLE PRECISION
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_combat_armor ON item_combat_stats (armor_level)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS item_requirements (
|
||||
item_id INTEGER PRIMARY KEY REFERENCES items(id),
|
||||
wield_level INTEGER, wield_requirement INTEGER, skill_level INTEGER,
|
||||
lore_requirement INTEGER, equip_skill VARCHAR(50), mastery VARCHAR(50)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_req_level ON item_requirements (wield_level)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS item_enhancements (
|
||||
item_id INTEGER PRIMARY KEY REFERENCES items(id),
|
||||
material VARCHAR(50), imbue VARCHAR(50), tinks INTEGER, workmanship DOUBLE PRECISION,
|
||||
salvage_workmanship DOUBLE PRECISION, num_times_tinkered INTEGER DEFAULT 0,
|
||||
free_tinkers_bitfield INTEGER, num_items_in_material INTEGER,
|
||||
imbue_attempts INTEGER DEFAULT 0, imbue_successes INTEGER DEFAULT 0,
|
||||
imbued_effect2 INTEGER, imbued_effect3 INTEGER, imbued_effect4 INTEGER, imbued_effect5 INTEGER,
|
||||
imbue_stacking_bits INTEGER, item_set VARCHAR(100), equipment_set_extra INTEGER,
|
||||
aetheria_bitfield INTEGER, heritage_specific_armor INTEGER, shared_cooldown INTEGER
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_enh_material_set ON item_enhancements (material, item_set)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS item_ratings (
|
||||
item_id INTEGER PRIMARY KEY REFERENCES items(id),
|
||||
damage_rating INTEGER, damage_resist_rating INTEGER, crit_rating INTEGER,
|
||||
crit_resist_rating INTEGER, crit_damage_rating INTEGER, crit_damage_resist_rating INTEGER,
|
||||
heal_boost_rating INTEGER, vitality_rating INTEGER, healing_rating INTEGER,
|
||||
mana_conversion_rating INTEGER, weakness_rating INTEGER, nether_over_time INTEGER,
|
||||
healing_resist_rating INTEGER, nether_resist_rating INTEGER, dot_resist_rating INTEGER,
|
||||
life_resist_rating INTEGER, sneak_attack_rating INTEGER, recklessness_rating INTEGER,
|
||||
deception_rating INTEGER, pk_damage_rating INTEGER, pk_damage_resist_rating INTEGER,
|
||||
gear_pk_damage_rating INTEGER, gear_pk_damage_resist_rating INTEGER,
|
||||
gear_damage INTEGER, gear_damage_resist INTEGER, gear_crit INTEGER, gear_crit_resist INTEGER,
|
||||
gear_crit_damage INTEGER, gear_crit_damage_resist INTEGER, gear_healing_boost INTEGER,
|
||||
gear_max_health INTEGER, gear_nether_resist INTEGER, gear_life_resist INTEGER,
|
||||
gear_overpower INTEGER, gear_overpower_resist INTEGER, total_rating INTEGER
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS item_spells (
|
||||
item_id INTEGER REFERENCES items(id),
|
||||
spell_id INTEGER,
|
||||
is_active BOOLEAN DEFAULT false,
|
||||
PRIMARY KEY (item_id, spell_id)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS item_raw_data (
|
||||
item_id INTEGER PRIMARY KEY REFERENCES items(id),
|
||||
int_values JSONB, double_values JSONB, string_values JSONB, bool_values JSONB,
|
||||
original_json JSONB
|
||||
)`,
|
||||
}
|
||||
ok, failed := 0, 0
|
||||
for _, s := range stmts {
|
||||
if _, err := pool.Exec(ctx, s); err != nil {
|
||||
failed++
|
||||
log.Warn("schema statement failed (continuing)", "err", err)
|
||||
continue
|
||||
}
|
||||
ok++
|
||||
}
|
||||
log.Info("inventory schema init complete", "ok", ok, "failed", failed)
|
||||
}
|
||||
|
|
@ -1,728 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// /search/items — port of inventory-service main.py:2892. This slice implements
|
||||
// the search QUERY (the CTE + all SQL filters + sort + pagination + count) and
|
||||
// returns each row's direct DB columns plus the computed booleans. The deep
|
||||
// per-row translation (material_name, spells, slot_name, ...) from
|
||||
// extract_item_properties is layered on in a later slice; the filter/count logic
|
||||
// — "which items match" — is validated here against the Python service.
|
||||
|
||||
// cteSelect is the items_with_slots CTE body (everything up to FROM/JOINs). The
|
||||
// rating columns are extracted from the item_raw_data int_values JSONB exactly
|
||||
// as Python does (paired ids via GREATEST, singletons via COALESCE).
|
||||
const cteSelect = `
|
||||
SELECT DISTINCT
|
||||
i.id AS db_item_id, i.character_name, i.name, i.icon, i.object_class, i.value, i.burden,
|
||||
i.od_rating,
|
||||
i.current_wielded_location, i.bonded, i.attuned, i."unique", i.stack_size, i.max_stack_size,
|
||||
i.structure, i.max_structure, i.rare_id, i.timestamp AS last_updated,
|
||||
COALESCE(cs.max_damage, -1) AS max_damage,
|
||||
COALESCE(cs.armor_level, -1) AS armor_level,
|
||||
COALESCE(cs.attack_bonus, -1.0) AS attack_bonus,
|
||||
COALESCE(cs.melee_defense_bonus, -1.0) AS melee_defense_bonus,
|
||||
COALESCE(cs.weapon_time, -1) AS weapon_time,
|
||||
COALESCE(cs.base_armor_level, cs.armor_level, -1) AS base_armor_level,
|
||||
COALESCE(cs.base_max_damage, cs.max_damage, -1) AS base_max_damage,
|
||||
GREATEST(COALESCE((rd.int_values->>'314')::int, -1), COALESCE((rd.int_values->>'374')::int, -1)) AS crit_damage_rating,
|
||||
GREATEST(COALESCE((rd.int_values->>'307')::int, -1), COALESCE((rd.int_values->>'370')::int, -1)) AS damage_rating,
|
||||
GREATEST(COALESCE((rd.int_values->>'323')::int, -1), COALESCE((rd.int_values->>'376')::int, -1)) AS heal_boost_rating,
|
||||
COALESCE((rd.int_values->>'379')::int, -1) AS vitality_rating,
|
||||
GREATEST(COALESCE((rd.int_values->>'308')::int, -1), COALESCE((rd.int_values->>'371')::int, -1)) AS damage_resist_rating,
|
||||
COALESCE((rd.int_values->>'315')::int, -1) AS crit_resist_rating,
|
||||
GREATEST(COALESCE((rd.int_values->>'316')::int, -1), COALESCE((rd.int_values->>'375')::int, -1)) AS crit_damage_resist_rating,
|
||||
COALESCE((rd.int_values->>'317')::int, -1) AS healing_resist_rating,
|
||||
COALESCE((rd.int_values->>'331')::int, -1) AS nether_resist_rating,
|
||||
COALESCE((rd.int_values->>'342')::int, -1) AS healing_rating,
|
||||
COALESCE((rd.int_values->>'350')::int, -1) AS dot_resist_rating,
|
||||
COALESCE((rd.int_values->>'351')::int, -1) AS life_resist_rating,
|
||||
COALESCE((rd.int_values->>'356')::int, -1) AS sneak_attack_rating,
|
||||
COALESCE((rd.int_values->>'357')::int, -1) AS recklessness_rating,
|
||||
COALESCE((rd.int_values->>'358')::int, -1) AS deception_rating,
|
||||
COALESCE((rd.int_values->>'381')::int, -1) AS pk_damage_rating,
|
||||
COALESCE((rd.int_values->>'382')::int, -1) AS pk_damage_resist_rating,
|
||||
COALESCE((rd.int_values->>'383')::int, -1) AS gear_pk_damage_rating,
|
||||
COALESCE((rd.int_values->>'384')::int, -1) AS gear_pk_damage_resist_rating,
|
||||
COALESCE(req.wield_level, -1) AS wield_level,
|
||||
COALESCE(enh.material, '') AS material,
|
||||
COALESCE(enh.workmanship, -1.0) AS workmanship,
|
||||
COALESCE(enh.imbue, '') AS imbue,
|
||||
COALESCE(enh.tinks, -1) AS tinks,
|
||||
COALESCE(enh.item_set, '') AS item_set,
|
||||
COALESCE((rd.int_values->>'218103821')::int, 0) AS coverage_mask,
|
||||
COALESCE((rd.int_values->>'218103822')::int, 0) AS equippable_slots,
|
||||
CASE
|
||||
WHEN rd.original_json IS NOT NULL
|
||||
AND rd.original_json->'IntValues'->>'218103822' IS NOT NULL
|
||||
AND (rd.original_json->'IntValues'->>'218103822')::int > 0
|
||||
THEN
|
||||
CASE (rd.original_json->'IntValues'->>'218103822')::int
|
||||
WHEN 1 THEN 'Head' WHEN 2 THEN 'Neck' WHEN 4 THEN 'Shirt'
|
||||
WHEN 16 THEN 'Chest' WHEN 32 THEN 'Hands' WHEN 256 THEN 'Feet'
|
||||
WHEN 512 THEN 'Chest' WHEN 1024 THEN 'Abdomen' WHEN 2048 THEN 'Upper Arms'
|
||||
WHEN 4096 THEN 'Lower Arms' WHEN 8192 THEN 'Upper Legs' WHEN 16384 THEN 'Lower Legs'
|
||||
WHEN 33554432 THEN 'Shield'
|
||||
WHEN 15 THEN 'Chest, Abdomen, Upper Arms, Lower Arms'
|
||||
WHEN 30 THEN 'Shirt'
|
||||
WHEN 14336 THEN 'Chest, Abdomen, Upper Arms, Lower Arms'
|
||||
WHEN 25600 THEN 'Abdomen, Upper Legs, Lower Legs'
|
||||
ELSE CONCAT_WS(', ',
|
||||
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 1 = 1 THEN 'Head' END,
|
||||
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 512 = 512 THEN 'Chest' END,
|
||||
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 1024 = 1024 THEN 'Abdomen' END,
|
||||
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 2048 = 2048 THEN 'Upper Arms' END,
|
||||
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 4096 = 4096 THEN 'Lower Arms' END,
|
||||
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 32 = 32 THEN 'Hands' END,
|
||||
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 8192 = 8192 THEN 'Upper Legs' END,
|
||||
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 16384 = 16384 THEN 'Lower Legs' END,
|
||||
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 256 = 256 THEN 'Feet' END)
|
||||
END
|
||||
WHEN i.object_class = 4 THEN
|
||||
CASE
|
||||
WHEN i.current_wielded_location = 32768 THEN 'Neck'
|
||||
WHEN i.current_wielded_location = 262144 THEN 'Left Ring'
|
||||
WHEN i.current_wielded_location = 524288 THEN 'Right Ring'
|
||||
WHEN i.current_wielded_location = 786432 THEN 'Left Ring, Right Ring'
|
||||
WHEN i.current_wielded_location = 131072 THEN 'Left Wrist'
|
||||
WHEN i.current_wielded_location = 1048576 THEN 'Right Wrist'
|
||||
WHEN i.current_wielded_location = 1179648 THEN 'Left Wrist, Right Wrist'
|
||||
WHEN i.name ILIKE '%amulet%' OR i.name ILIKE '%necklace%' OR i.name ILIKE '%gorget%' THEN 'Neck'
|
||||
WHEN i.name ILIKE '%ring%' AND i.name NOT ILIKE '%keyring%' AND i.name NOT ILIKE '%signet%' THEN 'Left Ring, Right Ring'
|
||||
WHEN i.name ILIKE '%bracelet%' THEN 'Left Wrist, Right Wrist'
|
||||
WHEN i.name ILIKE '%trinket%' THEN 'Trinket'
|
||||
ELSE 'Jewelry'
|
||||
END
|
||||
WHEN i.object_class = 6 THEN 'Melee Weapon'
|
||||
WHEN i.object_class = 7 THEN 'Missile Weapon'
|
||||
WHEN i.object_class = 8 THEN 'Held'
|
||||
WHEN i.current_wielded_location = 67108864 THEN 'Two-Handed'
|
||||
WHEN i.name ILIKE '%cloak%' THEN 'Cloak'
|
||||
ELSE '-'
|
||||
END AS computed_slot_name,
|
||||
COALESCE((SELECT STRING_AGG(CAST(sp_inner.spell_id AS VARCHAR), ',' ORDER BY sp_inner.spell_id)
|
||||
FROM item_spells sp_inner WHERE sp_inner.item_id = i.id), '') AS computed_spell_names,
|
||||
-- Ordered passive Spells from the raw item (matches extract_item_properties:
|
||||
-- spell_names = [translate_spell(id) for id in original_json["Spells"]], in
|
||||
-- array order, with duplicates preserved). Internal; stripped after enrich.
|
||||
(SELECT STRING_AGG(elem, ',' ORDER BY ord)
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE WHEN jsonb_typeof(rd.original_json->'Spells') = 'array'
|
||||
THEN rd.original_json->'Spells' ELSE '[]'::jsonb END)
|
||||
WITH ORDINALITY AS t(elem, ord)) AS spell_ids_ordered
|
||||
FROM items i
|
||||
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
|
||||
LEFT JOIN item_requirements req ON i.id = req.item_id
|
||||
LEFT JOIN item_enhancements enh ON i.id = enh.item_id
|
||||
LEFT JOIN item_ratings rt ON i.id = rt.item_id
|
||||
LEFT JOIN item_raw_data rd ON i.id = rd.item_id`
|
||||
|
||||
var sortMapping = map[string]string{
|
||||
"name": "name", "character_name": "character_name", "value": "value",
|
||||
"damage": "max_damage", "armor": "armor_level", "armor_level": "armor_level",
|
||||
"workmanship": "workmanship", "level": "wield_level", "damage_rating": "damage_rating",
|
||||
"crit_damage_rating": "crit_damage_rating", "heal_boost_rating": "heal_boost_rating",
|
||||
"vitality_rating": "vitality_rating", "damage_resist_rating": "damage_resist_rating",
|
||||
"crit_damage_resist_rating": "crit_damage_resist_rating", "item_set": "item_set",
|
||||
"coverage": "coverage_mask", "item_type_name": "object_class",
|
||||
"last_updated": "last_updated", "spell_names": "computed_spell_names",
|
||||
"od": "od_rating",
|
||||
}
|
||||
|
||||
// argBuilder accumulates positional ($N) query args.
|
||||
type argBuilder struct{ args []any }
|
||||
|
||||
func (b *argBuilder) add(v any) string {
|
||||
b.args = append(b.args, v)
|
||||
return "$" + strconv.Itoa(len(b.args))
|
||||
}
|
||||
|
||||
func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := s.runSearch(r.Context(), r.URL.Query())
|
||||
if err != nil {
|
||||
s.dbErr(w, "search/items", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// runSearch executes /search/items and returns the response object (items +
|
||||
// pagination, or an {error,...} object for invalid params). Shared by the HTTP
|
||||
// handler and the suitbuilder solver's load_items, so both see identical rows.
|
||||
func (s *Server) runSearch(ctx context.Context, q url.Values) (map[string]any, error) {
|
||||
ab := &argBuilder{}
|
||||
var conds []string
|
||||
|
||||
// --- character (mutually exclusive cascade) ---
|
||||
if c := q.Get("character"); c != "" {
|
||||
conds = append(conds, "character_name = "+ab.add(c))
|
||||
} else if cs := q.Get("characters"); cs != "" {
|
||||
names := splitNonEmpty(cs)
|
||||
if len(names) == 0 {
|
||||
return map[string]any{"error": "Empty characters list provided", "items": []any{}, "total_count": 0}, nil
|
||||
}
|
||||
ph := make([]string, len(names))
|
||||
for i, n := range names {
|
||||
ph[i] = ab.add(n)
|
||||
}
|
||||
conds = append(conds, "character_name IN ("+strings.Join(ph, ", ")+")")
|
||||
} else if !qBool(q, "include_all_characters") {
|
||||
return map[string]any{"error": "Must specify character, characters, or set include_all_characters=true", "items": []any{}, "total_count": 0}, nil
|
||||
}
|
||||
|
||||
// --- text ---
|
||||
if t := q.Get("text"); t != "" {
|
||||
p := ab.add("%" + t + "%")
|
||||
conds = append(conds, "(CONCAT(COALESCE(material,''),' ',name) ILIKE "+p+" OR name ILIKE "+p+" OR COALESCE(material,'') ILIKE "+p+")")
|
||||
}
|
||||
|
||||
// --- category (mutually exclusive) ---
|
||||
switch {
|
||||
case qBool(q, "armor_only"):
|
||||
conds = append(conds, "(object_class = 2 AND COALESCE(armor_level,0) > 0)")
|
||||
case qBool(q, "jewelry_only"):
|
||||
conds = append(conds, "object_class = 4")
|
||||
case qBool(q, "weapon_only"):
|
||||
conds = append(conds, weaponOnlyClause(q))
|
||||
case qBool(q, "clothing_only"):
|
||||
conds = append(conds, "(object_class = 3 AND name NOT ILIKE '%cloak%' AND name NOT ILIKE '%robe%' AND name NOT ILIKE '%pallium%' AND name NOT ILIKE '%armet%' AND (name ILIKE '%shirt%' OR name ILIKE '%pants%' OR name ILIKE '%breeches%' OR name ILIKE '%baggy%' OR name ILIKE '%tunic%'))")
|
||||
}
|
||||
|
||||
// --- equipment status / slot ---
|
||||
switch q.Get("equipment_status") {
|
||||
case "equipped":
|
||||
conds = append(conds, "current_wielded_location > 0")
|
||||
case "unequipped":
|
||||
conds = append(conds, "current_wielded_location = 0")
|
||||
}
|
||||
if v, ok := qInt(q, "equipment_slot"); ok {
|
||||
conds = append(conds, "current_wielded_location = "+ab.add(v))
|
||||
}
|
||||
|
||||
// --- combat + all rating filters (column >= :param) ---
|
||||
geFilters := []struct{ param, col string }{
|
||||
{"min_damage", "max_damage"}, {"min_armor", "armor_level"},
|
||||
{"min_crit_damage_rating", "crit_damage_rating"}, {"min_damage_rating", "damage_rating"},
|
||||
{"min_heal_boost_rating", "heal_boost_rating"}, {"min_vitality_rating", "vitality_rating"},
|
||||
{"min_damage_resist_rating", "damage_resist_rating"}, {"min_crit_resist_rating", "crit_resist_rating"},
|
||||
{"min_crit_damage_resist_rating", "crit_damage_resist_rating"}, {"min_healing_resist_rating", "healing_resist_rating"},
|
||||
{"min_nether_resist_rating", "nether_resist_rating"}, {"min_healing_rating", "healing_rating"},
|
||||
{"min_dot_resist_rating", "dot_resist_rating"}, {"min_life_resist_rating", "life_resist_rating"},
|
||||
{"min_sneak_attack_rating", "sneak_attack_rating"}, {"min_recklessness_rating", "recklessness_rating"},
|
||||
{"min_deception_rating", "deception_rating"}, {"min_pk_damage_rating", "pk_damage_rating"},
|
||||
{"min_pk_damage_resist_rating", "pk_damage_resist_rating"}, {"min_gear_pk_damage_rating", "gear_pk_damage_rating"},
|
||||
{"min_gear_pk_damage_resist_rating", "gear_pk_damage_resist_rating"}, {"min_tinks", "tinks"},
|
||||
{"min_value", "value"}, {"min_workmanship", "workmanship"},
|
||||
{"min_od", "od_rating"},
|
||||
}
|
||||
for _, f := range geFilters {
|
||||
if v := q.Get(f.param); v != "" {
|
||||
if n, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
conds = append(conds, f.col+" >= "+ab.add(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
leFilters := []struct{ param, col string }{
|
||||
{"max_damage", "max_damage"}, {"max_armor", "armor_level"},
|
||||
{"max_value", "value"}, {"max_burden", "burden"},
|
||||
}
|
||||
for _, f := range leFilters {
|
||||
if v, ok := qInt(q, f.param); ok {
|
||||
conds = append(conds, f.col+" <= "+ab.add(v))
|
||||
}
|
||||
}
|
||||
if v := q.Get("min_attack_bonus"); v != "" {
|
||||
if n, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
conds = append(conds, "attack_bonus >= "+ab.add(n))
|
||||
}
|
||||
}
|
||||
if v := q.Get("max_od"); v != "" {
|
||||
if n, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
conds = append(conds, "od_rating <= "+ab.add(n))
|
||||
}
|
||||
}
|
||||
|
||||
// --- requirements (wield level) ---
|
||||
if v, ok := qInt(q, "max_level"); ok {
|
||||
conds = append(conds, "(wield_level <= "+ab.add(v)+" OR wield_level IS NULL)")
|
||||
}
|
||||
if v, ok := qInt(q, "min_level"); ok {
|
||||
conds = append(conds, "wield_level >= "+ab.add(v))
|
||||
}
|
||||
|
||||
// --- enhancements ---
|
||||
if m := q.Get("material"); m != "" {
|
||||
conds = append(conds, "material ILIKE "+ab.add("%"+m+"%"))
|
||||
}
|
||||
if v := q.Get("has_imbue"); v != "" {
|
||||
if qBool(q, "has_imbue") {
|
||||
conds = append(conds, "(imbue IS NOT NULL AND imbue != '')")
|
||||
} else {
|
||||
conds = append(conds, "(imbue IS NULL OR imbue = '')")
|
||||
}
|
||||
}
|
||||
|
||||
// --- item state ---
|
||||
if v := q.Get("bonded"); v != "" {
|
||||
conds = append(conds, ternary(qBool(q, "bonded"), "bonded > 0", "bonded = 0"))
|
||||
}
|
||||
if v := q.Get("attuned"); v != "" {
|
||||
conds = append(conds, ternary(qBool(q, "attuned"), "attuned > 0", "attuned = 0"))
|
||||
}
|
||||
if v := q.Get("unique"); v != "" {
|
||||
conds = append(conds, `"unique" = `+ab.add(qBool(q, "unique")))
|
||||
}
|
||||
if v := q.Get("is_rare"); v != "" {
|
||||
conds = append(conds, ternary(qBool(q, "is_rare"), "rare_id IS NOT NULL AND rare_id > 0", "(rare_id IS NULL OR rare_id <= 0)"))
|
||||
}
|
||||
if v, ok := qInt(q, "min_condition"); ok {
|
||||
conds = append(conds, "((structure * 100.0 / NULLIF(max_structure, 0)) >= "+ab.add(v)+" OR max_structure IS NULL)")
|
||||
}
|
||||
|
||||
// --- item_set / item_sets (translate id->name, bug-for-bug) ---
|
||||
if v := q.Get("item_set"); v != "" {
|
||||
conds = append(conds, "item_set = "+ab.add(s.translateSetID(v)))
|
||||
} else if v := q.Get("item_sets"); v != "" {
|
||||
ids := splitNonEmpty(v)
|
||||
if len(ids) != 1 {
|
||||
conds = append(conds, "1 = 0") // 0 or >1 set ids => impossible
|
||||
} else {
|
||||
conds = append(conds, "item_set = "+ab.add(s.translateSetID(ids[0])))
|
||||
}
|
||||
}
|
||||
|
||||
// --- slot_names (OR of per-slot approaches over computed_slot_name) ---
|
||||
if v := q.Get("slot_names"); v != "" {
|
||||
var slotClauses []string
|
||||
for _, name := range splitNonEmpty(v) {
|
||||
slotClauses = append(slotClauses, slotNameClause(name, ab))
|
||||
}
|
||||
if len(slotClauses) > 0 {
|
||||
conds = append(conds, "("+strings.Join(slotClauses, " OR ")+")")
|
||||
}
|
||||
}
|
||||
|
||||
// --- spell filters: has_spell / spell_contains / legendary_cantrips ---
|
||||
conds = append(conds, spellFilterConds(q, s.spells, ab)...)
|
||||
|
||||
where := ""
|
||||
if len(conds) > 0 {
|
||||
where = " WHERE " + strings.Join(conds, " AND ")
|
||||
}
|
||||
|
||||
// --- sort ---
|
||||
sortField, ok := sortMapping[q.Get("sort_by")]
|
||||
if !ok {
|
||||
sortField = "name"
|
||||
}
|
||||
dir, nulls := "ASC", "NULLS LAST"
|
||||
if strings.EqualFold(q.Get("sort_dir"), "desc") {
|
||||
dir, nulls = "DESC", "NULLS FIRST"
|
||||
}
|
||||
orderBy := fmt.Sprintf(" ORDER BY %s %s %s, character_name, db_item_id", sortField, dir, nulls)
|
||||
|
||||
// --- pagination ---
|
||||
page := clampInt(qIntDefault(q, "page", 1), 1, 1<<30)
|
||||
limit := clampInt(qIntDefault(q, "limit", 200), 1, 50000)
|
||||
offset := (page - 1) * limit
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Underwear filters (shirt_only/pants_only/underwear_only) are injected into
|
||||
// the CTE body itself (they filter on raw i./rd. columns), mirroring Python's
|
||||
// cte_where_clause insertion. Mutually exclusive, shirt > pants > underwear.
|
||||
cteBody := cteSelect
|
||||
if cw := underwearCTEWhere(q); cw != "" {
|
||||
cteBody += "\n" + cw
|
||||
}
|
||||
cte := "WITH items_with_slots AS (" + cteBody + ")\n"
|
||||
mainSQL := cte + "SELECT * FROM items_with_slots" + where + orderBy +
|
||||
" LIMIT " + ab.add(limit) + " OFFSET " + ab.add(offset)
|
||||
rows, err := queryRowsAsMaps(ctx, s.pool, mainSQL, ab.args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// count uses the SAME CTE (incl. the underwear injection) + conditions, so
|
||||
// total_count is always consistent with the returned items. Python builds a
|
||||
// SEPARATE count CTE (main.py:3747) that omits the underwear injection and
|
||||
// uses a simpler computed_slot_name, so its total_count is inconsistent with
|
||||
// its own items for underwear/slot_names filters (e.g. shirt_only reports the
|
||||
// whole table). We deliberately do NOT replicate that bug. Normal browse
|
||||
// filters apply to both CTEs identically, so those counts match Python.
|
||||
// LIMIT/OFFSET args are unused here.
|
||||
countSQL := cte + "SELECT COUNT(DISTINCT db_item_id) FROM items_with_slots" + where
|
||||
var totalCount int64
|
||||
if err := s.pool.QueryRow(ctx, countSQL, ab.args[:len(ab.args)-2]...).Scan(&totalCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := s.enrichRows(rows)
|
||||
|
||||
return map[string]any{
|
||||
"items": items,
|
||||
"total_count": totalCount,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"has_next": int64(page*limit) < totalCount,
|
||||
"has_previous": page > 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// enrichRows applies the direct-column transforms (computed booleans, condition,
|
||||
// timestamp), the material-name prefix, and the item_set name, then strips
|
||||
// internal columns. Deeper enrichment (spells, slot_name, weapon damage/mana,
|
||||
// rating fallbacks) is a later slice.
|
||||
func (s *Server) enrichRows(rows []map[string]any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
row["is_equipped"] = toInt64(row["current_wielded_location"]) > 0
|
||||
row["is_bonded"] = toInt64(row["bonded"]) > 0
|
||||
row["is_attuned"] = toInt64(row["attuned"]) > 0
|
||||
row["is_rare"] = toInt64(row["rare_id"]) > 0
|
||||
st, mx := row["structure"], row["max_structure"]
|
||||
if st != nil && mx != nil && toFloat(mx) != 0 {
|
||||
row["condition_percent"] = roundTo(toFloat(st)*100/toFloat(mx), 1)
|
||||
} else {
|
||||
row["condition_percent"] = nil
|
||||
}
|
||||
if t, ok := row["last_updated"].(time.Time); ok {
|
||||
row["last_updated"] = pyISO(t)
|
||||
}
|
||||
|
||||
// object_class_name — gem(11) context uses the ORIGINAL item name, so
|
||||
// compute before the material prefix below (translate_object_class).
|
||||
if oc := int(toInt64(row["object_class"])); oc != 0 {
|
||||
row["object_class_name"] = s.translateObjectClass(oc, toStr(row["name"]))
|
||||
}
|
||||
|
||||
// material_name + material prefix on name (material is already a
|
||||
// translated string in the DB; enrich_db_item:2371-2602).
|
||||
if mat := toStr(row["material"]); mat != "" {
|
||||
row["material_name"] = mat
|
||||
if name := toStr(row["name"]); name != "" &&
|
||||
!strings.HasPrefix(strings.ToLower(name), strings.ToLower(mat)) {
|
||||
row["original_name"] = name
|
||||
row["name"] = mat + " " + name
|
||||
}
|
||||
}
|
||||
// item_set_name (enrich_db_item:2551-2562).
|
||||
if iset := strings.TrimSpace(toStr(row["item_set"])); iset != "" {
|
||||
if n, ok := s.attributeSets[iset]; ok {
|
||||
row["item_set_name"] = n
|
||||
} else {
|
||||
row["item_set_name"] = "Set " + iset
|
||||
}
|
||||
}
|
||||
|
||||
// spells / spell_names from the ordered passive Spells array
|
||||
// (enrich_db_item:3942-3951; only set when the item has spells).
|
||||
if raw := toStr(row["spell_ids_ordered"]); raw != "" {
|
||||
parts := strings.Split(raw, ",")
|
||||
spells := make([]map[string]any, 0, len(parts))
|
||||
names := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
id, err := strconv.Atoi(strings.TrimSpace(p))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sp := s.translateSpell(id)
|
||||
spells = append(spells, sp)
|
||||
if n, _ := sp["name"].(string); n != "" {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
if len(spells) > 0 {
|
||||
row["spells"] = spells
|
||||
row["spell_names"] = names
|
||||
}
|
||||
}
|
||||
delete(row, "spell_ids_ordered")
|
||||
|
||||
// slot_name — sophisticated equipment-slot translation (main.py:3977-4033).
|
||||
// Load-bearing for the suitbuilder: jewelry has an empty computed_slot_name,
|
||||
// so load_items falls back to this to bucket rings/neck/wrists/trinket.
|
||||
eq := int(toInt64(row["equippable_slots"]))
|
||||
hasMat := toStr(row["material"]) != ""
|
||||
row["slot_name"] = s.computeSlotName(eq, int(toInt64(row["coverage_mask"])), hasMat)
|
||||
delete(row, "equippable_slots")
|
||||
|
||||
// Gear-total display ratings (main.py:4035-4072): damage_rating,
|
||||
// crit_damage_rating, heal_boost_rating only. The CTE already does
|
||||
// GREATEST(individual, gear-key 370/374/376), so the gear-positive rescue
|
||||
// branch is dead — the net rule is simply -1 -> null. The other three
|
||||
// solver ratings (damage_resist/crit_damage_resist/vitality) stay -1.
|
||||
for _, f := range []string{"damage_rating", "crit_damage_rating", "heal_boost_rating"} {
|
||||
if toInt64(row[f]) == -1 {
|
||||
row[f] = nil
|
||||
}
|
||||
}
|
||||
|
||||
delete(row, "db_item_id")
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// translateObjectClass mirrors translate_object_class: ObjectClass enum lookup,
|
||||
// with the context-aware Gem(11) classification by item name. The aetheria-by-
|
||||
// IntValues path (for gem-class items not named crystal/gem/mana stone) is not
|
||||
// reproduced here (it needs original_json) — a documented rare edge.
|
||||
func (s *Server) translateObjectClass(oc int, name string) string {
|
||||
base, ok := s.objectClasses[oc]
|
||||
if !ok {
|
||||
return fmt.Sprintf("Unknown_ObjectClass_%d", oc)
|
||||
}
|
||||
if base == "Gem" && oc == 11 {
|
||||
n := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.Contains(n, "mana stone"):
|
||||
return "Mana Stone"
|
||||
case strings.Contains(n, "crystal"):
|
||||
return "Crystal"
|
||||
case strings.Contains(n, "gem"):
|
||||
return "Gem"
|
||||
case strings.Contains(n, "aetheria"):
|
||||
return "Aetheria"
|
||||
}
|
||||
return "Gem"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// translateSetID mirrors translate_equipment_set_id (AttributeSetInfo lookup,
|
||||
// ID-string fallback).
|
||||
func (s *Server) translateSetID(setID string) string {
|
||||
if name, ok := s.attributeSets[setID]; ok {
|
||||
return name
|
||||
}
|
||||
return setID
|
||||
}
|
||||
|
||||
// underwearCTEWhere returns the WHERE clause injected into the search CTE for
|
||||
// the shirt_only / pants_only / underwear_only filters (main.py:3220-3251).
|
||||
// Coverage bits on key 218103821: UnderwearUpperLegs=2, UnderwearLowerLegs=4,
|
||||
// UnderwearChest=8, UnderwearAbdomen=16.
|
||||
func underwearCTEWhere(q map[string][]string) string {
|
||||
switch {
|
||||
case qBool(q, "shirt_only"):
|
||||
return `WHERE i.object_class = 3
|
||||
AND ((rd.int_values->>'218103821')::int & 8) > 0
|
||||
AND NOT ((rd.int_values->>'218103821')::int & 6) = 6
|
||||
AND i.name NOT ILIKE '%robe%'
|
||||
AND i.name NOT ILIKE '%cloak%'
|
||||
AND i.name NOT ILIKE '%pallium%'
|
||||
AND i.name NOT ILIKE '%armet%'
|
||||
AND i.name NOT ILIKE '%pants%'
|
||||
AND i.name NOT ILIKE '%breeches%'`
|
||||
case qBool(q, "pants_only"):
|
||||
return `WHERE i.object_class = 3
|
||||
AND ((rd.int_values->>'218103821')::int & 2) = 2
|
||||
AND i.name NOT ILIKE '%robe%'
|
||||
AND i.name NOT ILIKE '%cloak%'
|
||||
AND i.name NOT ILIKE '%pallium%'
|
||||
AND i.name NOT ILIKE '%armet%'`
|
||||
case qBool(q, "underwear_only"):
|
||||
return `WHERE i.object_class = 3
|
||||
AND ((rd.int_values->>'218103821')::int & 30) > 0
|
||||
AND i.name NOT ILIKE '%robe%'
|
||||
AND i.name NOT ILIKE '%cloak%'
|
||||
AND i.name NOT ILIKE '%pallium%'
|
||||
AND i.name NOT ILIKE '%armet%'`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func weaponTypeClause(wt string) string {
|
||||
meleeSkill := func(skill int) string {
|
||||
return fmt.Sprintf("(object_class = 1 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = %d))", skill)
|
||||
}
|
||||
missileMastery := func(mastery int) string {
|
||||
return fmt.Sprintf("(object_class = 9 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = 47 AND (wrd.int_values->>'353')::int = %d))", mastery)
|
||||
}
|
||||
casterSkill := func(skill int) string {
|
||||
return fmt.Sprintf("(object_class = 31 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'159')::int = %d))", skill)
|
||||
}
|
||||
switch strings.ToLower(wt) {
|
||||
case "heavy":
|
||||
return meleeSkill(44)
|
||||
case "light":
|
||||
return meleeSkill(45)
|
||||
case "finesse":
|
||||
return meleeSkill(46)
|
||||
case "two_handed":
|
||||
return meleeSkill(41)
|
||||
case "missile":
|
||||
return "(object_class = 9 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = 47))"
|
||||
case "bow":
|
||||
return missileMastery(8)
|
||||
case "crossbow":
|
||||
return missileMastery(9)
|
||||
case "thrown":
|
||||
return missileMastery(10)
|
||||
case "war":
|
||||
return casterSkill(34)
|
||||
case "void":
|
||||
return casterSkill(43)
|
||||
case "caster":
|
||||
return "object_class = 31"
|
||||
default:
|
||||
return "object_class IN (1, 9, 31)"
|
||||
}
|
||||
}
|
||||
|
||||
// weaponOnlyClause builds the WHERE fragment for weapon_only searches: an OR of
|
||||
// the per-type clauses named in weapon_types (CSV) plus the legacy single
|
||||
// weapon_type. Empty selection = all weapons. Types are deduped.
|
||||
func weaponOnlyClause(q url.Values) string {
|
||||
var types []string
|
||||
types = append(types, splitNonEmpty(q.Get("weapon_types"))...)
|
||||
if wt := strings.TrimSpace(q.Get("weapon_type")); wt != "" {
|
||||
types = append(types, wt)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var clauses []string
|
||||
for _, t := range types {
|
||||
key := strings.ToLower(strings.TrimSpace(t))
|
||||
if key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
clauses = append(clauses, weaponTypeClause(key))
|
||||
}
|
||||
if len(clauses) == 0 {
|
||||
return "object_class IN (1, 9, 31)"
|
||||
}
|
||||
if len(clauses) == 1 {
|
||||
return clauses[0]
|
||||
}
|
||||
return "(" + strings.Join(clauses, " OR ") + ")"
|
||||
}
|
||||
|
||||
func slotNameClause(name string, ab *argBuilder) string {
|
||||
switch strings.ToLower(name) {
|
||||
case "ring":
|
||||
return "((computed_slot_name ILIKE '%Ring%') OR (object_class = 4 AND name ILIKE '%ring%' AND name NOT ILIKE '%keyring%' AND name NOT ILIKE '%signet%'))"
|
||||
case "bracelet", "wrist":
|
||||
return "((computed_slot_name ILIKE '%Wrist%') OR (object_class = 4 AND name ILIKE '%bracelet%'))"
|
||||
case "neck":
|
||||
return "((computed_slot_name ILIKE " + ab.add("%neck%") + ") OR (object_class = 4 AND (name ILIKE '%amulet%' OR name ILIKE '%necklace%' OR name ILIKE '%gorget%')))"
|
||||
case "trinket":
|
||||
// Approach 5 (jewelry fallback) MUST exclude %bracelet% — without it the
|
||||
// Trinket fetch sweeps in bracelets, which then duplicate the Wrist buckets
|
||||
// (also fetched via slot_names=Bracelet) and the DFS re-emits suits.
|
||||
return "((computed_slot_name ILIKE " + ab.add("%trinket%") + ") OR (current_wielded_location = 67108864) OR (object_class = 4 AND (name ILIKE '%trinket%' OR name ILIKE '%compass%' OR name ILIKE '%goggles%')) OR (object_class = 11 AND name ILIKE '%trinket%') OR (object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%bracelet%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%'))"
|
||||
case "cloak":
|
||||
return "((computed_slot_name ILIKE " + ab.add("%cloak%") + ") OR (name ILIKE '%cloak%') OR (computed_slot_name = 'Cloak'))"
|
||||
default:
|
||||
return "(computed_slot_name ILIKE " + ab.add("%"+name+"%") + ")"
|
||||
}
|
||||
}
|
||||
|
||||
func splitNonEmpty(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func qBool(q map[string][]string, key string) bool {
|
||||
v := ""
|
||||
if vs, ok := q[key]; ok && len(vs) > 0 {
|
||||
v = vs[0]
|
||||
}
|
||||
switch strings.ToLower(v) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func qInt(q map[string][]string, key string) (int, bool) {
|
||||
if vs, ok := q[key]; ok && len(vs) > 0 && vs[0] != "" {
|
||||
if n, err := strconv.Atoi(vs[0]); err == nil {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func qIntDefault(q map[string][]string, key string, def int) int {
|
||||
if n, ok := qInt(q, key); ok {
|
||||
return n
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func ternary(c bool, a, b string) string {
|
||||
if c {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func clampInt(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func toInt64(v any) int64 {
|
||||
switch x := v.(type) {
|
||||
case int64:
|
||||
return x
|
||||
case int32:
|
||||
return int64(x)
|
||||
case int:
|
||||
return int64(x)
|
||||
case float64:
|
||||
return int64(x)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func toFloat(v any) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case float32:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return float64(x)
|
||||
case int32:
|
||||
return float64(x)
|
||||
case int:
|
||||
return float64(x)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func roundTo(v float64, places int) float64 {
|
||||
p := 1.0
|
||||
for i := 0; i < places; i++ {
|
||||
p *= 10
|
||||
}
|
||||
r := v * p
|
||||
if r < 0 {
|
||||
r -= 0.5
|
||||
} else {
|
||||
r += 0.5
|
||||
}
|
||||
return float64(int64(r)) / p
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWeaponTypeClause(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"heavy": {"object_class = 1", "'218103840')::int = 44"},
|
||||
"light": {"object_class = 1", "= 45"},
|
||||
"finesse": {"object_class = 1", "= 46"},
|
||||
"two_handed": {"object_class = 1", "= 41"},
|
||||
"missile": {"object_class = 9", "'218103840')::int = 47"},
|
||||
"bow": {"object_class = 9", "'218103840')::int = 47", "'353')::int = 8"},
|
||||
"crossbow": {"object_class = 9", "'353')::int = 9"},
|
||||
"thrown": {"object_class = 9", "'353')::int = 10"},
|
||||
"war": {"object_class = 31", "'159')::int = 34"},
|
||||
"void": {"object_class = 31", "'159')::int = 43"},
|
||||
"caster": {"object_class = 31"},
|
||||
}
|
||||
for wt, subs := range cases {
|
||||
got := weaponTypeClause(wt)
|
||||
for _, s := range subs {
|
||||
if !strings.Contains(got, s) {
|
||||
t.Errorf("weaponTypeClause(%q) = %q, missing %q", wt, got, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := weaponTypeClause("nonsense"); !strings.Contains(got, "object_class IN (1, 9, 31)") {
|
||||
t.Errorf("unknown type = %q, want all-weapons fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeaponOnlyClause(t *testing.T) {
|
||||
// No types → all weapons.
|
||||
if got := weaponOnlyClause(url.Values{}); !strings.Contains(got, "object_class IN (1, 9, 31)") {
|
||||
t.Errorf("empty = %q, want all weapons", got)
|
||||
}
|
||||
// Single type via weapon_types.
|
||||
got := weaponOnlyClause(url.Values{"weapon_types": {"heavy"}})
|
||||
if !strings.Contains(got, "= 44") || strings.Contains(got, " OR ") {
|
||||
t.Errorf("single = %q, want just heavy, no OR", got)
|
||||
}
|
||||
// Multiple → parenthesized OR, one clause per type.
|
||||
got = weaponOnlyClause(url.Values{"weapon_types": {"heavy,two_handed,war"}})
|
||||
if !strings.HasPrefix(got, "(") || !strings.HasSuffix(got, ")") ||
|
||||
strings.Count(got, " OR ") != 2 ||
|
||||
!strings.Contains(got, "= 44") || !strings.Contains(got, "= 41") || !strings.Contains(got, "= 34") {
|
||||
t.Errorf("multi = %q, want (heavy OR two_handed OR war)", got)
|
||||
}
|
||||
// Legacy weapon_type still honored, deduped against weapon_types.
|
||||
got = weaponOnlyClause(url.Values{"weapon_type": {"missile"}})
|
||||
if !strings.Contains(got, "object_class = 9") {
|
||||
t.Errorf("legacy weapon_type = %q, want missile", got)
|
||||
}
|
||||
got = weaponOnlyClause(url.Values{"weapon_types": {"heavy"}, "weapon_type": {"heavy"}})
|
||||
if strings.Contains(got, " OR ") {
|
||||
t.Errorf("dedup = %q, want single heavy clause", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/bits"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Port of main.py's sophisticated equipment-slot translation, used to emit the
|
||||
// `slot_name` field. This is load-bearing for the suitbuilder: jewelry items get
|
||||
// an empty computed_slot_name (their EquipMask isn't an armor-coverage value, so
|
||||
// the SQL CONCAT_WS yields ''), and load_items falls back to slot_name
|
||||
// (`computed_slot_name or slot_name`) to bucket them as Left Ring / Neck / etc.
|
||||
|
||||
// equipMaskEntry is one EquipMask enum row, kept in ascending-mask order so the
|
||||
// bit-flag decode joins parts deterministically (Left before Right).
|
||||
type equipMaskEntry struct {
|
||||
Mask int
|
||||
Name string
|
||||
}
|
||||
|
||||
// equipFriendly maps technical EquipMask names to display names
|
||||
// (translate_equipment_slot's name_mapping, identical in both branches).
|
||||
var equipFriendly = map[string]string{
|
||||
"HeadWear": "Head", "ChestWear": "Chest", "ChestArmor": "Chest",
|
||||
"AbdomenWear": "Abdomen", "AbdomenArmor": "Abdomen",
|
||||
"UpperArmWear": "Upper Arms", "UpperArmArmor": "Upper Arms",
|
||||
"LowerArmWear": "Lower Arms", "LowerArmArmor": "Lower Arms",
|
||||
"HandWear": "Hands", "UpperLegWear": "Upper Legs", "UpperLegArmor": "Upper Legs",
|
||||
"LowerLegWear": "Lower Legs", "LowerLegArmor": "Lower Legs", "FootWear": "Feet",
|
||||
"NeckWear": "Neck", "WristWearLeft": "Left Wrist", "WristWearRight": "Right Wrist",
|
||||
"FingerWearLeft": "Left Ring", "FingerWearRight": "Right Ring",
|
||||
"MeleeWeapon": "Melee Weapon", "Shield": "Shield", "MissileWeapon": "Missile Weapon",
|
||||
"MissileAmmo": "Ammo", "Held": "Held", "TwoHanded": "Two-Handed",
|
||||
"TrinketOne": "Trinket", "Cloak": "Cloak", "Robe": "Robe",
|
||||
}
|
||||
|
||||
var commonSlots = map[int]string{
|
||||
30: "Shirt",
|
||||
786432: "Left Ring, Right Ring",
|
||||
262144: "Left Ring",
|
||||
524288: "Right Ring",
|
||||
}
|
||||
|
||||
func friendlySlot(name string) string {
|
||||
if f, ok := equipFriendly[name]; ok {
|
||||
return f
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func isBodyArmorEquipMask(v int) bool { return v&0x00007F21 != 0 }
|
||||
func isBodyArmorCoverageMask(v int) bool { return v&0x0001FF00 != 0 }
|
||||
func totalBitsSet(v int) int { return bits.OnesCount(uint(uint32(v))) }
|
||||
|
||||
// getCoverageReductionOptions mirrors main.py:658.
|
||||
func getCoverageReductionOptions(coverage int) []int {
|
||||
const (
|
||||
oUpperArms = 4096
|
||||
oLowerArms = 8192
|
||||
oUpperLegs = 256
|
||||
oLowerLegs = 512
|
||||
oChest = 1024
|
||||
oAbdomen = 2048
|
||||
head = 16384
|
||||
hands = 32768
|
||||
feet = 65536
|
||||
)
|
||||
if totalBitsSet(coverage) <= 1 || !isBodyArmorCoverageMask(coverage) {
|
||||
return []int{coverage}
|
||||
}
|
||||
switch coverage {
|
||||
case oUpperArms | oLowerArms:
|
||||
return []int{oUpperArms, oLowerArms}
|
||||
case oUpperLegs | oLowerLegs:
|
||||
return []int{oUpperLegs, oLowerLegs}
|
||||
case oLowerLegs | feet:
|
||||
return []int{feet}
|
||||
case oChest | oAbdomen:
|
||||
return []int{oChest}
|
||||
case oChest | oAbdomen | oUpperArms:
|
||||
return []int{oChest}
|
||||
case oChest | oUpperArms | oLowerArms:
|
||||
return []int{oChest}
|
||||
case oChest | oUpperArms:
|
||||
return []int{oChest}
|
||||
case oAbdomen | oUpperLegs | oLowerLegs:
|
||||
return []int{oAbdomen, oUpperLegs, oLowerLegs}
|
||||
case oChest | oAbdomen | oUpperArms | oLowerArms:
|
||||
return []int{oChest}
|
||||
case oAbdomen | oUpperLegs:
|
||||
return []int{oAbdomen}
|
||||
}
|
||||
return []int{coverage}
|
||||
}
|
||||
|
||||
// coverageToEquipMask mirrors main.py:717.
|
||||
func coverageToEquipMask(coverage int) int {
|
||||
m := map[int]int{
|
||||
16384: 1, 1024: 512, 4096: 2048, 8192: 4096, 32768: 32,
|
||||
2048: 1024, 256: 8192, 512: 16384, 65536: 256,
|
||||
}
|
||||
if v, ok := m[coverage]; ok {
|
||||
return v
|
||||
}
|
||||
return coverage
|
||||
}
|
||||
|
||||
// getSophisticatedSlotOptions mirrors main.py:734.
|
||||
func getSophisticatedSlotOptions(equippableSlots, coverageValue int, hasMaterial bool) []int {
|
||||
const lowerLegWear, footWear = 128, 256
|
||||
if equippableSlots == (lowerLegWear | footWear) {
|
||||
return []int{footWear}
|
||||
}
|
||||
if isBodyArmorEquipMask(equippableSlots) && totalBitsSet(equippableSlots) > 1 {
|
||||
if !hasMaterial {
|
||||
return []int{equippableSlots}
|
||||
}
|
||||
var slotOpts []int
|
||||
for _, o := range getCoverageReductionOptions(coverageValue) {
|
||||
slotOpts = append(slotOpts, coverageToEquipMask(o))
|
||||
}
|
||||
if len(slotOpts) > 0 {
|
||||
return slotOpts
|
||||
}
|
||||
return []int{equippableSlots}
|
||||
}
|
||||
return []int{equippableSlots}
|
||||
}
|
||||
|
||||
// translateEquipmentSlot mirrors main.py:807.
|
||||
func (s *Server) translateEquipmentSlot(loc int) string {
|
||||
if loc == 0 {
|
||||
return "Inventory"
|
||||
}
|
||||
if name, ok := s.equipMaskMap[loc]; ok {
|
||||
return friendlySlot(name)
|
||||
}
|
||||
if cs, ok := commonSlots[loc]; ok {
|
||||
return cs
|
||||
}
|
||||
var parts []string
|
||||
for _, e := range s.equipMaskOrdered {
|
||||
if e.Mask > 0 && loc&e.Mask == e.Mask {
|
||||
parts = append(parts, friendlySlot(e.Name))
|
||||
}
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
if loc >= 268435456 {
|
||||
switch loc {
|
||||
case 268435456:
|
||||
return "Aetheria Blue"
|
||||
case 536870912:
|
||||
return "Aetheria Yellow"
|
||||
case 1073741824:
|
||||
return "Aetheria Red"
|
||||
default:
|
||||
return fmt.Sprintf("Special Slot (%d)", loc)
|
||||
}
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
// computeSlotName mirrors the slot_name block in search_items (main.py:3977-4033).
|
||||
func (s *Server) computeSlotName(equippableSlots, coverageValue int, hasMaterial bool) string {
|
||||
if equippableSlots <= 0 {
|
||||
return "-"
|
||||
}
|
||||
opts := getSophisticatedSlotOptions(equippableSlots, coverageValue, hasMaterial)
|
||||
var names []string
|
||||
for _, o := range opts {
|
||||
n := s.translateEquipmentSlot(o)
|
||||
if n != "" && !containsString(names, n) {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Spell-filter support for /search/items: implements the has_spell /
|
||||
// spell_contains / legendary_cantrips params the Go port had ignored
|
||||
// (design doc 2026-07-14; legacy inventory-service/main.py:3345-3452).
|
||||
// Names are matched against the in-memory enum-DB spell map; the resulting
|
||||
// IDs are filtered in SQL against the item_spells table.
|
||||
|
||||
// spellIDsContaining returns the IDs of all spells whose name contains q,
|
||||
// case-insensitively, in ascending order (deterministic SQL args).
|
||||
func spellIDsContaining(spells map[int]map[string]any, q string) []int {
|
||||
q = strings.ToLower(strings.TrimSpace(q))
|
||||
var ids []int
|
||||
for id, sp := range spells {
|
||||
name, _ := sp["name"].(string)
|
||||
if name != "" && strings.Contains(strings.ToLower(name), q) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Ints(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
// spellIDsForCantrip matches one cantrip display name using the legacy
|
||||
// flexible rule: spell name contains the label OR the label contains the
|
||||
// spell name (both case-insensitive).
|
||||
func spellIDsForCantrip(spells map[int]map[string]any, cantrip string) []int {
|
||||
c := strings.ToLower(strings.TrimSpace(cantrip))
|
||||
var ids []int
|
||||
for id, sp := range spells {
|
||||
name, _ := sp["name"].(string)
|
||||
n := strings.ToLower(name)
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(n, c) || strings.Contains(c, n) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Ints(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
// exactSpellID returns the lowest ID whose name equals q case-insensitively.
|
||||
func exactSpellID(spells map[int]map[string]any, q string) (int, bool) {
|
||||
q = strings.ToLower(strings.TrimSpace(q))
|
||||
best := -1
|
||||
for id, sp := range spells {
|
||||
name, _ := sp["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(name) == q && (best == -1 || id < best) {
|
||||
best = id
|
||||
}
|
||||
}
|
||||
return best, best != -1
|
||||
}
|
||||
|
||||
// spellExists renders the EXISTS clause for a set of spell IDs, binding each
|
||||
// ID as a positional arg. ids must be non-empty.
|
||||
func spellExists(ids []int, ab *argBuilder) string {
|
||||
ph := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
ph[i] = ab.add(id)
|
||||
}
|
||||
return "EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN (" + strings.Join(ph, ", ") + "))"
|
||||
}
|
||||
|
||||
// spellFilterConds builds the WHERE conditions for has_spell, spell_contains,
|
||||
// and legendary_cantrips. Cantrips use AND semantics — one EXISTS per checked
|
||||
// cantrip (NOT the legacy COUNT>=N trick, which can false-positive when one
|
||||
// label matches two spells on an item while another matches none). Any
|
||||
// no-match filter yields the impossible condition, matching legacy intent.
|
||||
func spellFilterConds(q url.Values, spells map[int]map[string]any, ab *argBuilder) []string {
|
||||
var conds []string
|
||||
impossible := func() { conds = append(conds, "1 = 0") }
|
||||
|
||||
if v := strings.TrimSpace(q.Get("has_spell")); v != "" {
|
||||
if id, ok := exactSpellID(spells, v); ok {
|
||||
conds = append(conds, spellExists([]int{id}, ab))
|
||||
} else {
|
||||
impossible()
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(q.Get("spell_contains")); v != "" {
|
||||
if ids := spellIDsContaining(spells, v); len(ids) > 0 {
|
||||
conds = append(conds, spellExists(ids, ab))
|
||||
} else {
|
||||
impossible()
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(q.Get("legendary_cantrips")); v != "" {
|
||||
for _, name := range splitNonEmpty(v) {
|
||||
if ids := spellIDsForCantrip(spells, name); len(ids) > 0 {
|
||||
conds = append(conds, spellExists(ids, ab))
|
||||
} else {
|
||||
impossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
return conds
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Minimal stand-in for the enum-DB spell map (Server.spells).
|
||||
var testSpells = map[int]map[string]any{
|
||||
1: {"name": "Legendary Invulnerability"},
|
||||
2: {"name": "Epic Invulnerability"},
|
||||
3: {"name": "Legendary Summoning Prowess"},
|
||||
4: {"name": "Strength Other VI"},
|
||||
5: {"name": "Summoning"}, // shorter name CONTAINED IN a cantrip label
|
||||
6: {"name": ""}, // malformed entry must never match
|
||||
}
|
||||
|
||||
func TestSpellIDsContaining(t *testing.T) {
|
||||
if got := spellIDsContaining(testSpells, "invulnerability"); !reflect.DeepEqual(got, []int{1, 2}) {
|
||||
t.Errorf("substring match = %v, want [1 2]", got)
|
||||
}
|
||||
if got := spellIDsContaining(testSpells, "INVULN"); !reflect.DeepEqual(got, []int{1, 2}) {
|
||||
t.Errorf("case-insensitive match = %v, want [1 2]", got)
|
||||
}
|
||||
if got := spellIDsContaining(testSpells, "frostbite"); got != nil {
|
||||
t.Errorf("no-match = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpellIDsForCantrip(t *testing.T) {
|
||||
// Forward direction: spell name contains the cantrip label.
|
||||
if got := spellIDsForCantrip(testSpells, "Invulnerability"); !reflect.DeepEqual(got, []int{1, 2}) {
|
||||
t.Errorf("forward contains = %v, want [1 2]", got)
|
||||
}
|
||||
// Both directions (legacy flexible rule): "Legendary Summoning Prowess"
|
||||
// matches spell 3 (equal) AND spell 5 ("Summoning" is contained in the label).
|
||||
if got := spellIDsForCantrip(testSpells, "Legendary Summoning Prowess"); !reflect.DeepEqual(got, []int{3, 5}) {
|
||||
t.Errorf("both-direction = %v, want [3 5]", got)
|
||||
}
|
||||
if got := spellIDsForCantrip(testSpells, "Piercing Bane"); got != nil {
|
||||
t.Errorf("no-match = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExactSpellID(t *testing.T) {
|
||||
if id, ok := exactSpellID(testSpells, "legendary invulnerability"); !ok || id != 1 {
|
||||
t.Errorf("exact case-insensitive = (%d,%v), want (1,true)", id, ok)
|
||||
}
|
||||
if _, ok := exactSpellID(testSpells, "Invulnerability"); ok {
|
||||
t.Error("partial name must not exact-match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpellFilterConds_Cantrips(t *testing.T) {
|
||||
ab := &argBuilder{}
|
||||
q := map[string][]string{"legendary_cantrips": {"Legendary Invulnerability, Legendary Summoning Prowess"}}
|
||||
conds := spellFilterConds(q, testSpells, ab)
|
||||
want := []string{
|
||||
"EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($1))",
|
||||
"EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($2, $3))",
|
||||
}
|
||||
if !reflect.DeepEqual(conds, want) {
|
||||
t.Errorf("conds = %v, want %v", conds, want)
|
||||
}
|
||||
// "Legendary Invulnerability" exact-contains only id 1; the Summoning
|
||||
// label matches 3 and 5. AND semantics = one EXISTS per cantrip.
|
||||
if wantArgs := []any{1, 3, 5}; !reflect.DeepEqual(ab.args, wantArgs) {
|
||||
t.Errorf("args = %v, want %v", ab.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpellFilterConds_NoMatchIsImpossible(t *testing.T) {
|
||||
ab := &argBuilder{}
|
||||
q := map[string][]string{"legendary_cantrips": {"Legendary Frostbite"}}
|
||||
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, []string{"1 = 0"}) {
|
||||
t.Errorf("unknown cantrip conds = %v, want [1 = 0]", conds)
|
||||
}
|
||||
ab = &argBuilder{}
|
||||
q = map[string][]string{"spell_contains": {"frostbite"}}
|
||||
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, []string{"1 = 0"}) {
|
||||
t.Errorf("unknown spell_contains conds = %v, want [1 = 0]", conds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpellFilterConds_ContainsAndHasSpell(t *testing.T) {
|
||||
ab := &argBuilder{}
|
||||
q := map[string][]string{"spell_contains": {"invulnerability"}}
|
||||
want := []string{"EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($1, $2))"}
|
||||
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, want) {
|
||||
t.Errorf("spell_contains conds = %v, want %v", conds, want)
|
||||
}
|
||||
|
||||
ab = &argBuilder{}
|
||||
q = map[string][]string{"has_spell": {"Epic Invulnerability"}}
|
||||
want = []string{"EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($1))"}
|
||||
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, want) {
|
||||
t.Errorf("has_spell conds = %v, want %v", conds, want)
|
||||
}
|
||||
if _, ok := exactSpellID(testSpells, "No Such Spell"); ok {
|
||||
t.Fatal("precondition")
|
||||
}
|
||||
ab = &argBuilder{}
|
||||
q = map[string][]string{"has_spell": {"No Such Spell"}}
|
||||
if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, []string{"1 = 0"}) {
|
||||
t.Errorf("unknown has_spell conds = %v, want [1 = 0]", conds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpellFilterConds_Empty(t *testing.T) {
|
||||
ab := &argBuilder{}
|
||||
if conds := spellFilterConds(map[string][]string{}, testSpells, ab); conds != nil {
|
||||
t.Errorf("no params => nil conds, got %v", conds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpellFilterConds_WhitespaceParamsIgnored(t *testing.T) {
|
||||
ab := &argBuilder{}
|
||||
q := map[string][]string{"spell_contains": {" "}, "has_spell": {" "}, "legendary_cantrips": {" , "}}
|
||||
if conds := spellFilterConds(q, testSpells, ab); conds != nil {
|
||||
t.Errorf("whitespace params => nil conds, got %v", conds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExactSpellID_EmptyNeverMatchesMalformed(t *testing.T) {
|
||||
if _, ok := exactSpellID(testSpells, ""); ok {
|
||||
t.Error("empty query must not match the malformed empty-name entry")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// newPool creates a pgx pool. When readOnly (the default in parallel mode), every
|
||||
// connection is forced into read-only transactions so the Go service can never
|
||||
// mutate the production inventory_db it shares with the Python service.
|
||||
func newPool(ctx context.Context, dsn string, readOnly bool) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse DATABASE_URL: %w", err)
|
||||
}
|
||||
cfg.MaxConns = 10
|
||||
cfg.MaxConnIdleTime = 5 * time.Minute
|
||||
if readOnly {
|
||||
cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
|
||||
_, err := conn.Exec(ctx, "SET default_transaction_read_only = on")
|
||||
return err
|
||||
}
|
||||
}
|
||||
return pgxpool.NewWithConfig(ctx, cfg)
|
||||
}
|
||||
|
||||
func queryRowsAsMaps(ctx context.Context, pool *pgxpool.Pool, sql string, args ...any) ([]map[string]any, error) {
|
||||
rows, err := pool.Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := pgx.CollectRows(rows, pgx.RowToMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []map[string]any{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pyISO mirrors Python datetime.isoformat() for a UTC value (matches FastAPI's
|
||||
// jsonable_encoder). Note the inventory-service stores naive datetimes (no tz),
|
||||
// so isoformat has no offset — we format without one.
|
||||
func pyISO(t time.Time) string {
|
||||
t = t.UTC()
|
||||
if t.Nanosecond() == 0 {
|
||||
return t.Format("2006-01-02T15:04:05")
|
||||
}
|
||||
return t.Format("2006-01-02T15:04:05") + fmt.Sprintf(".%06d", t.Nanosecond()/1000)
|
||||
}
|
||||
|
||||
func formatTimes(rows []map[string]any, keys ...string) {
|
||||
for _, m := range rows {
|
||||
for _, k := range keys {
|
||||
if t, ok := m[k].(time.Time); ok {
|
||||
m[k] = pyISO(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toStr(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("json encode failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Suitbuilder endpoints — port of suitbuilder.py's router (mounted at
|
||||
// /suitbuilder in the Python service). The live UI hits /inv/suitbuilder/* on
|
||||
// the tracker, which proxies here; we expose the same contract for parallel
|
||||
// validation.
|
||||
|
||||
// POST /suitbuilder/search — streams SSE events (event: <type>\ndata: <json>\n\n).
|
||||
func (s *Server) handleSuitSearch(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
// Pydantic defaults applied before decode; json.Unmarshal only overwrites
|
||||
// fields present in the body.
|
||||
c := SearchConstraints{IncludeEquipped: true, IncludeInventory: true, MaxResults: 50, SearchTimeout: 300}
|
||||
if err := json.Unmarshal(body, &c); err != nil {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"detail": "invalid SearchConstraints"})
|
||||
return
|
||||
}
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "streaming unsupported"})
|
||||
return
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream")
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "keep-alive")
|
||||
h.Set("Access-Control-Allow-Origin", "*")
|
||||
h.Set("Access-Control-Allow-Headers", "Cache-Control")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
var mu sync.Mutex
|
||||
emit := func(event string, data map[string]any) {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
b, _ = json.Marshal(map[string]any{"message": "Serialization error: " + err.Error()})
|
||||
event = "error"
|
||||
}
|
||||
mu.Lock()
|
||||
fmt.Fprintf(w, "event: %s\n", event)
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
flusher.Flush()
|
||||
mu.Unlock()
|
||||
}
|
||||
cancelled := func() bool {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
sv := newSolver(s, c, emit, cancelled)
|
||||
sv.Search(r.Context())
|
||||
}
|
||||
|
||||
// GET /suitbuilder/characters (suitbuilder.py:2085).
|
||||
func (s *Server) handleSuitCharacters(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
rows, err := queryRowsAsMaps(ctx, s.pool, `SELECT DISTINCT character_name FROM items ORDER BY character_name`)
|
||||
if err != nil {
|
||||
s.dbErr(w, "suitbuilder/characters", err)
|
||||
return
|
||||
}
|
||||
chars := make([]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
chars = append(chars, row["character_name"])
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"characters": chars})
|
||||
}
|
||||
|
||||
// GET /suitbuilder/sets (suitbuilder.py:2195) — the hardcoded set list.
|
||||
func (s *Server) handleSuitSets(w http.ResponseWriter, r *http.Request) {
|
||||
order := []int{14, 16, 13, 21, 40, 41, 46, 47, 48, 15, 19, 20, 22, 24, 26, 29}
|
||||
sets := make([]map[string]any, 0, len(order))
|
||||
for _, id := range order {
|
||||
sets = append(sets, map[string]any{"id": id, "name": setNames[id]})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sets": sets})
|
||||
}
|
||||
|
|
@ -1,592 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"math/bits"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Port of inventory-service/suitbuilder.py data model. This is the LIVE solver
|
||||
// (mounted at /suitbuilder/search; main.py's /optimize/suits is legacy/unused).
|
||||
// Every sort carries (character_name, name) tiebreakers so results are
|
||||
// deterministic and reproducible, exactly as the Python source documents.
|
||||
|
||||
// --- Equipment set name<->id maps (suitbuilder.py SET_NAMES / _convert_set_name_to_id) ---
|
||||
|
||||
var setNames = map[int]string{
|
||||
14: "Adept's", 16: "Defender's", 13: "Soldier's", 21: "Wise",
|
||||
40: "Heroic Protector", 41: "Heroic Destroyer", 46: "Relic Alduressa",
|
||||
47: "Ancient Relic", 48: "Noble Relic", 15: "Archer's", 19: "Hearty",
|
||||
20: "Dexterous", 22: "Swift", 24: "Reinforced", 26: "Flame Proof",
|
||||
29: "Lightning Proof",
|
||||
}
|
||||
|
||||
// nameToSetID is the reverse map used by load_items to turn the search's
|
||||
// item_set field into a numeric set id (note the " Set" suffix, verbatim).
|
||||
var nameToSetID = map[string]int{
|
||||
"Adept's Set": 14, "Defender's Set": 16, "Soldier's Set": 13, "Wise Set": 21,
|
||||
"Heroic Protector Set": 40, "Heroic Destroyer Set": 41, "Relic Alduressa Set": 46,
|
||||
"Ancient Relic Set": 47, "Noble Relic Set": 48, "Archer's Set": 15,
|
||||
"Hearty Set": 19, "Dexterous Set": 20, "Swift Set": 22, "Reinforced Set": 24,
|
||||
"Flame Proof Set": 26, "Lightning Proof Set": 29,
|
||||
}
|
||||
|
||||
// getSetName mirrors suitbuilder.get_set_name (None/0 -> "").
|
||||
func getSetName(setID int) string {
|
||||
if setID == 0 {
|
||||
return ""
|
||||
}
|
||||
if n, ok := setNames[setID]; ok {
|
||||
return n
|
||||
}
|
||||
return fmt.Sprintf("Set %d", setID)
|
||||
}
|
||||
|
||||
func convertSetNameToID(setName string) int { return nameToSetID[setName] }
|
||||
|
||||
// --- CoverageMask (suitbuilder.py:81) ---
|
||||
|
||||
const (
|
||||
covUnderwearUpperLegs = 0x00000002
|
||||
covUnderwearLowerLegs = 0x00000004
|
||||
covUnderwearChest = 0x00000008
|
||||
covUnderwearAbdomen = 0x00000010
|
||||
covUnderwearUpperArms = 0x00000020
|
||||
covUnderwearLowerArms = 0x00000040
|
||||
covOuterUpperLegs = 0x00000100
|
||||
covOuterLowerLegs = 0x00000200
|
||||
covOuterChest = 0x00000400
|
||||
covOuterAbdomen = 0x00000800
|
||||
covOuterUpperArms = 0x00001000
|
||||
covOuterLowerArms = 0x00002000
|
||||
covHead = 0x00004000
|
||||
covHands = 0x00008000
|
||||
covFeet = 0x00010000
|
||||
|
||||
// Aliases matching slot names (suitbuilder.py:110-115).
|
||||
covChest = covOuterChest
|
||||
covAbdomen = covOuterAbdomen
|
||||
covUpperArms = covOuterUpperArms
|
||||
covLowerArms = covOuterLowerArms
|
||||
covUpperLegs = covOuterUpperLegs
|
||||
covLowerLegs = covOuterLowerLegs
|
||||
|
||||
magRobePattern = 0x00013F00
|
||||
)
|
||||
|
||||
// coverageReductionOptions mirrors CoverageMask.reduction_options().
|
||||
func coverageReductionOptions(v int) []int {
|
||||
if bits.OnesCount(uint(v)) <= 1 {
|
||||
return nil
|
||||
}
|
||||
if coverageIsRobe(v) {
|
||||
return nil
|
||||
}
|
||||
switch v {
|
||||
case covUpperArms | covLowerArms:
|
||||
return []int{covUpperArms, covLowerArms}
|
||||
case covUpperLegs | covLowerLegs:
|
||||
return []int{covUpperLegs, covLowerLegs}
|
||||
case covLowerLegs | covFeet:
|
||||
return []int{covFeet}
|
||||
case covChest | covAbdomen:
|
||||
return []int{covChest}
|
||||
case covChest | covAbdomen | covUpperArms:
|
||||
return []int{covChest}
|
||||
case covChest | covUpperArms | covLowerArms:
|
||||
return []int{covChest}
|
||||
case covChest | covUpperArms:
|
||||
return []int{covChest}
|
||||
case covAbdomen | covUpperLegs | covLowerLegs:
|
||||
return []int{covAbdomen, covUpperLegs, covLowerLegs}
|
||||
case covChest | covAbdomen | covUpperArms | covLowerArms:
|
||||
return []int{covChest}
|
||||
case covAbdomen | covUpperLegs:
|
||||
return []int{covAbdomen}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// coverageIsRobe mirrors CoverageMask.is_robe() (exact pattern == component
|
||||
// pattern == 0x13F00; otherwise the 6+ coverage-areas fallback).
|
||||
func coverageIsRobe(v int) bool {
|
||||
if v == magRobePattern {
|
||||
return true
|
||||
}
|
||||
return bits.OnesCount(uint(v)) >= 6
|
||||
}
|
||||
|
||||
// coverageToSlotName mirrors CoverageMask.to_slot_name() (single coverage only).
|
||||
func coverageToSlotName(v int) string {
|
||||
switch v {
|
||||
case covHead:
|
||||
return "Head"
|
||||
case covChest:
|
||||
return "Chest"
|
||||
case covUpperArms:
|
||||
return "Upper Arms"
|
||||
case covLowerArms:
|
||||
return "Lower Arms"
|
||||
case covHands:
|
||||
return "Hands"
|
||||
case covAbdomen:
|
||||
return "Abdomen"
|
||||
case covUpperLegs:
|
||||
return "Upper Legs"
|
||||
case covLowerLegs:
|
||||
return "Lower Legs"
|
||||
case covFeet:
|
||||
return "Feet"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- SuitItem (suitbuilder.py:221) ---
|
||||
|
||||
type SuitItem struct {
|
||||
ID string // unique per (character,name); used for uniqueness checks
|
||||
Name string
|
||||
CharacterName string
|
||||
Slot string
|
||||
Coverage int // 0 == None
|
||||
HasCoverage bool
|
||||
SetID int // 0 == None
|
||||
ArmorLevel int
|
||||
Ratings map[string]int
|
||||
SpellBitmap uint64
|
||||
SpellNames []string
|
||||
IsLocked bool
|
||||
Material string
|
||||
}
|
||||
|
||||
func (it *SuitItem) ratingsSum() int {
|
||||
s := 0
|
||||
for _, v := range it.Ratings {
|
||||
s += v
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (it *SuitItem) ratingsSumExcept(skip string) int {
|
||||
s := 0
|
||||
for k, v := range it.Ratings {
|
||||
if k != skip {
|
||||
s += v
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (it *SuitItem) clone(slot string, name string, coverage int, hasCov bool) *SuitItem {
|
||||
r := make(map[string]int, len(it.Ratings))
|
||||
for k, v := range it.Ratings {
|
||||
r[k] = v
|
||||
}
|
||||
sn := make([]string, len(it.SpellNames))
|
||||
copy(sn, it.SpellNames)
|
||||
return &SuitItem{
|
||||
ID: it.ID, Name: name, CharacterName: it.CharacterName, Slot: slot,
|
||||
Coverage: coverage, HasCoverage: hasCov, SetID: it.SetID, ArmorLevel: it.ArmorLevel,
|
||||
Ratings: r, SpellBitmap: it.SpellBitmap, SpellNames: sn, IsLocked: it.IsLocked,
|
||||
Material: it.Material,
|
||||
}
|
||||
}
|
||||
|
||||
// --- ItemBucket (suitbuilder.py:247) ---
|
||||
|
||||
type ItemBucket struct {
|
||||
Slot string
|
||||
Items []*SuitItem
|
||||
IsArmor bool
|
||||
}
|
||||
|
||||
var clothingSortSlots = map[string]bool{"Shirt": true, "Pants": true}
|
||||
|
||||
// sortItems mirrors ItemBucket.sort_items() (reverse=True over the key tuple,
|
||||
// stable so equal keys keep prior order).
|
||||
func (b *ItemBucket) sortItems() {
|
||||
items := b.Items
|
||||
if _, isClothing := clothingSortSlots[b.Slot]; isClothing {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return descTuple(
|
||||
cmpInt(items[i].Ratings["damage_rating"], items[j].Ratings["damage_rating"]),
|
||||
cmpInt(len(items[i].SpellNames), len(items[j].SpellNames)),
|
||||
cmpInt(items[i].ratingsSumExcept("damage_rating"), items[j].ratingsSumExcept("damage_rating")),
|
||||
cmpStr(items[i].CharacterName, items[j].CharacterName),
|
||||
cmpStr(items[i].Name, items[j].Name),
|
||||
)
|
||||
})
|
||||
} else if b.IsArmor {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return descTuple(
|
||||
cmpInt(items[i].ArmorLevel, items[j].ArmorLevel),
|
||||
cmpInt(items[i].Ratings["crit_damage_rating"], items[j].Ratings["crit_damage_rating"]),
|
||||
cmpInt(len(items[i].SpellNames), len(items[j].SpellNames)),
|
||||
cmpInt(items[i].ratingsSum(), items[j].ratingsSum()),
|
||||
cmpStr(items[i].CharacterName, items[j].CharacterName),
|
||||
cmpStr(items[i].Name, items[j].Name),
|
||||
)
|
||||
})
|
||||
} else {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return descTuple(
|
||||
cmpInt(len(items[i].SpellNames), len(items[j].SpellNames)),
|
||||
cmpInt(items[i].ratingsSum(), items[j].ratingsSum()),
|
||||
cmpStr(items[i].CharacterName, items[j].CharacterName),
|
||||
cmpStr(items[i].Name, items[j].Name),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// descTuple returns true if the left tuple sorts before the right under Python's
|
||||
// reverse=True (i.e. the larger tuple comes first). cmp* return -1/0/1.
|
||||
func descTuple(cmps ...int) bool {
|
||||
for _, c := range cmps {
|
||||
if c != 0 {
|
||||
return c > 0 // larger first
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cmpInt(a, b int) int {
|
||||
switch {
|
||||
case a < b:
|
||||
return -1
|
||||
case a > b:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmpStr(a, b string) int { return strings.Compare(a, b) }
|
||||
|
||||
// --- SpellBitmapIndex (suitbuilder.py:299) ---
|
||||
|
||||
type SpellBitmapIndex struct {
|
||||
spellToBit map[string]uint64
|
||||
order []struct {
|
||||
bit uint64
|
||||
name string
|
||||
}
|
||||
nextBit uint
|
||||
}
|
||||
|
||||
func newSpellBitmapIndex() *SpellBitmapIndex {
|
||||
return &SpellBitmapIndex{spellToBit: map[string]uint64{}}
|
||||
}
|
||||
|
||||
func (s *SpellBitmapIndex) registerSpell(name string) uint64 {
|
||||
if b, ok := s.spellToBit[name]; ok {
|
||||
return b
|
||||
}
|
||||
var bit uint64
|
||||
if s.nextBit < 64 {
|
||||
bit = uint64(1) << s.nextBit
|
||||
} // >=64: bit stays 0 (only non-required spells ever reach here; required
|
||||
// spells are registered first, so their low bits are always exact).
|
||||
s.spellToBit[name] = bit
|
||||
s.order = append(s.order, struct {
|
||||
bit uint64
|
||||
name string
|
||||
}{bit, name})
|
||||
s.nextBit++
|
||||
return bit
|
||||
}
|
||||
|
||||
func (s *SpellBitmapIndex) getBitmap(spells []string) uint64 {
|
||||
var m uint64
|
||||
for _, sp := range spells {
|
||||
m |= s.registerSpell(sp)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *SpellBitmapIndex) getSpellNames(bitmap uint64) []string {
|
||||
var out []string
|
||||
for _, e := range s.order {
|
||||
if e.bit != 0 && bitmap&e.bit != 0 {
|
||||
out = append(out, e.name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- SuitState (suitbuilder.py:342) ---
|
||||
|
||||
type SuitState struct {
|
||||
Items map[string]*SuitItem
|
||||
SpellBitmap uint64
|
||||
SetCounts map[int]int
|
||||
TotalArmor int
|
||||
TotalRatings map[string]int
|
||||
Occupied map[string]bool
|
||||
}
|
||||
|
||||
func newSuitState() *SuitState {
|
||||
return &SuitState{
|
||||
Items: map[string]*SuitItem{}, SetCounts: map[int]int{},
|
||||
TotalRatings: map[string]int{}, Occupied: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (st *SuitState) push(it *SuitItem) {
|
||||
st.Items[it.Slot] = it
|
||||
st.Occupied[it.Slot] = true
|
||||
st.SpellBitmap |= it.SpellBitmap
|
||||
if it.SetID != 0 {
|
||||
st.SetCounts[it.SetID]++
|
||||
}
|
||||
st.TotalArmor += it.ArmorLevel
|
||||
for k, v := range it.Ratings {
|
||||
st.TotalRatings[k] += v
|
||||
}
|
||||
}
|
||||
|
||||
func (st *SuitState) pop(slot string) {
|
||||
it, ok := st.Items[slot]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
delete(st.Items, slot)
|
||||
delete(st.Occupied, slot)
|
||||
// Rebuild spell bitmap (overlaps prevent simple subtraction).
|
||||
st.SpellBitmap = 0
|
||||
for _, r := range st.Items {
|
||||
st.SpellBitmap |= r.SpellBitmap
|
||||
}
|
||||
if it.SetID != 0 {
|
||||
st.SetCounts[it.SetID]--
|
||||
if st.SetCounts[it.SetID] == 0 {
|
||||
delete(st.SetCounts, it.SetID)
|
||||
}
|
||||
}
|
||||
st.TotalArmor -= it.ArmorLevel
|
||||
for k, v := range it.Ratings {
|
||||
if _, present := st.TotalRatings[k]; present {
|
||||
st.TotalRatings[k] -= v
|
||||
if st.TotalRatings[k] <= 0 {
|
||||
delete(st.TotalRatings, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ScoringWeights / SearchConstraints (suitbuilder.py:409,426) ---
|
||||
|
||||
type ScoringWeights struct {
|
||||
ArmorSetComplete int
|
||||
MissingSetPenalty int
|
||||
CritDamage1 int
|
||||
CritDamage2 int
|
||||
DamageRating1 int
|
||||
DamageRating2 int
|
||||
DamageRating3 int
|
||||
}
|
||||
|
||||
func defaultScoringWeights() ScoringWeights {
|
||||
return ScoringWeights{
|
||||
ArmorSetComplete: 1000, MissingSetPenalty: -200,
|
||||
CritDamage1: 10, CritDamage2: 20,
|
||||
DamageRating1: 10, DamageRating2: 20, DamageRating3: 30,
|
||||
}
|
||||
}
|
||||
|
||||
type LockedSlotInfo struct {
|
||||
SetID int `json:"set_id"`
|
||||
Spells []string `json:"spells"`
|
||||
}
|
||||
|
||||
type SearchConstraints struct {
|
||||
Characters []string `json:"characters"`
|
||||
PrimarySet int `json:"primary_set"`
|
||||
SecondarySet int `json:"secondary_set"`
|
||||
RequiredSpells []string `json:"required_spells"`
|
||||
LockedSlots map[string]LockedSlotInfo `json:"locked_slots"`
|
||||
IncludeEquipped bool `json:"include_equipped"`
|
||||
IncludeInventory bool `json:"include_inventory"`
|
||||
MinArmor *int `json:"min_armor"`
|
||||
MaxArmor *int `json:"max_armor"`
|
||||
AllowedCritDamage []int `json:"allowed_crit_damage"`
|
||||
MinDamageRating *int `json:"min_damage_rating"`
|
||||
MaxDamageRating *int `json:"max_damage_rating"`
|
||||
ScoringWeights *ScoringWeights `json:"scoring_weights"`
|
||||
MaxResults int `json:"max_results"`
|
||||
SearchTimeout int `json:"search_timeout"`
|
||||
}
|
||||
|
||||
// --- CompletedSuit (suitbuilder.py:446) ---
|
||||
|
||||
type CompletedSuit struct {
|
||||
Items map[string]*SuitItem
|
||||
Score int
|
||||
TotalArmor int
|
||||
TotalRatings map[string]int
|
||||
SetCounts map[int]int
|
||||
FulfilledSpells []string
|
||||
MissingSpells []string
|
||||
}
|
||||
|
||||
func fnvInt(s string) int {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(s))
|
||||
return int(h.Sum32())
|
||||
}
|
||||
|
||||
// toDict mirrors CompletedSuit.to_dict(). The opaque id fields are derived
|
||||
// deterministically (Python uses salted hash(); we use FNV) — never compared in
|
||||
// validation.
|
||||
func (c *CompletedSuit) toDict() map[string]any {
|
||||
transferByChar := map[string][]string{}
|
||||
totalItems := 0
|
||||
// Slots iterated in Python dict order; use a sorted-slot order for stable
|
||||
// transfer instructions (instructions are display-only).
|
||||
for _, it := range c.Items {
|
||||
transferByChar[it.CharacterName] = append(transferByChar[it.CharacterName], it.Name)
|
||||
totalItems++
|
||||
}
|
||||
chars := make([]string, 0, len(transferByChar))
|
||||
for ch := range transferByChar {
|
||||
chars = append(chars, ch)
|
||||
}
|
||||
sort.Strings(chars)
|
||||
instructions := []string{}
|
||||
step := 1
|
||||
for _, ch := range chars {
|
||||
for _, name := range transferByChar[ch] {
|
||||
instructions = append(instructions, fmt.Sprintf("%d. Transfer %s from %s to new character", step, name, ch))
|
||||
step++
|
||||
}
|
||||
}
|
||||
instructions = append(instructions, fmt.Sprintf("%d. Equip all transferred items on new character", step))
|
||||
|
||||
slotKeys := make([]string, 0, len(c.Items))
|
||||
for slot := range c.Items {
|
||||
slotKeys = append(slotKeys, slot)
|
||||
}
|
||||
sort.Strings(slotKeys)
|
||||
itemsOut := map[string]any{}
|
||||
for _, slot := range slotKeys {
|
||||
it := c.Items[slot]
|
||||
var setIDOut any
|
||||
if it.SetID != 0 {
|
||||
setIDOut = it.SetID
|
||||
}
|
||||
itemsOut[slot] = map[string]any{
|
||||
"id": fnvInt(it.ID),
|
||||
"name": it.Name,
|
||||
"source_character": it.CharacterName,
|
||||
"armor_level": it.ArmorLevel,
|
||||
"ratings": it.Ratings,
|
||||
"spells": it.SpellNames,
|
||||
"set_id": setIDOut,
|
||||
"set_name": getSetName(it.SetID),
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"id": fnvInt(strings.Join(slotKeys, "|")),
|
||||
"score": c.Score,
|
||||
"items": itemsOut,
|
||||
"stats": map[string]any{
|
||||
"total_armor": c.TotalArmor,
|
||||
"total_crit_damage": c.TotalRatings["crit_damage_rating"],
|
||||
"total_damage_rating": c.TotalRatings["damage_rating"],
|
||||
"primary_set_count": 0,
|
||||
"secondary_set_count": 0,
|
||||
"spell_coverage": len(c.FulfilledSpells),
|
||||
},
|
||||
"missing": c.MissingSpells,
|
||||
"notes": []any{},
|
||||
"transfer_summary": map[string]any{
|
||||
"total_items": totalItems,
|
||||
"from_characters": transferByChar,
|
||||
},
|
||||
"instructions": instructions,
|
||||
}
|
||||
}
|
||||
|
||||
// --- ItemPreFilter (suitbuilder.py:519) ---
|
||||
|
||||
func removeSurpassedItems(items []*SuitItem) []*SuitItem {
|
||||
out := make([]*SuitItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
surpassed := false
|
||||
for _, cmp := range items {
|
||||
if cmp == it {
|
||||
continue
|
||||
}
|
||||
if isSurpassedBy(it, cmp) {
|
||||
surpassed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !surpassed {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isSurpassedBy(item, cmp *SuitItem) bool {
|
||||
if item.Slot != cmp.Slot {
|
||||
return false
|
||||
}
|
||||
if item.SetID != cmp.SetID {
|
||||
return false
|
||||
}
|
||||
if !spellsSurpassOrEqual(cmp.SpellNames, item.SpellNames) {
|
||||
return false
|
||||
}
|
||||
betterInSomething := false
|
||||
for _, key := range []string{"crit_damage_rating", "damage_rating"} {
|
||||
ir := item.Ratings[key]
|
||||
cr := cmp.Ratings[key]
|
||||
if cr > ir {
|
||||
betterInSomething = true
|
||||
} else if ir > cr {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if item.ArmorLevel > 0 && cmp.ArmorLevel > 0 {
|
||||
if cmp.ArmorLevel > item.ArmorLevel {
|
||||
betterInSomething = true
|
||||
} else if item.ArmorLevel > cmp.ArmorLevel {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return betterInSomething
|
||||
}
|
||||
|
||||
func spellsSurpassOrEqual(spells1, spells2 []string) bool {
|
||||
for _, s2 := range spells2 {
|
||||
found := false
|
||||
for _, s1 := range spells1 {
|
||||
if s1 == s2 || spellSurpasses(s1, s2) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func spellSurpasses(s1, s2 string) bool {
|
||||
if strings.Contains(s1, "Legendary") && (strings.Contains(s2, "Epic") || strings.Contains(s2, "Major")) {
|
||||
b1 := strings.ReplaceAll(s1, "Legendary ", "")
|
||||
b2 := strings.ReplaceAll(strings.ReplaceAll(s2, "Epic ", ""), "Major ", "")
|
||||
return b1 == b2
|
||||
}
|
||||
if strings.Contains(s1, "Epic") && strings.Contains(s2, "Major") {
|
||||
b1 := strings.ReplaceAll(s1, "Epic ", "")
|
||||
b2 := strings.ReplaceAll(s2, "Major ", "")
|
||||
return b1 == b2
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,870 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Solver is the Go port of suitbuilder.py ConstraintSatisfactionSolver (the live
|
||||
// /suitbuilder/search DFS). It streams events via emit; cancellation is checked
|
||||
// through cancelled (the request context).
|
||||
|
||||
type Solver struct {
|
||||
s *Server
|
||||
c SearchConstraints
|
||||
spellIndex *SpellBitmapIndex
|
||||
bestSuits []*CompletedSuit
|
||||
evaluated int
|
||||
weights ScoringWeights
|
||||
|
||||
neededSpellBitmap uint64
|
||||
bestSuitItemCount int
|
||||
highestArmorCount int
|
||||
armorBucketsItems int
|
||||
allowedCD map[int]bool // nil == no CD filter (default / all tiers)
|
||||
|
||||
lockedSetCounts map[int]int
|
||||
lockedSpells map[string]bool
|
||||
effPrimary int
|
||||
effSecondary int
|
||||
|
||||
start time.Time
|
||||
emit func(event string, data map[string]any)
|
||||
cancelled func() bool
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func newSolver(s *Server, c SearchConstraints, emit func(string, map[string]any), cancelled func() bool) *Solver {
|
||||
w := defaultScoringWeights()
|
||||
if c.ScoringWeights != nil {
|
||||
w = *c.ScoringWeights
|
||||
}
|
||||
if c.MaxResults == 0 {
|
||||
c.MaxResults = 50
|
||||
}
|
||||
sv := &Solver{
|
||||
s: s, c: c, spellIndex: newSpellBitmapIndex(), weights: w,
|
||||
lockedSetCounts: map[int]int{}, lockedSpells: map[string]bool{},
|
||||
effPrimary: 5, effSecondary: 4, start: time.Now(),
|
||||
emit: emit, cancelled: cancelled,
|
||||
}
|
||||
// Required spells register first, so they always get the low bits.
|
||||
sv.neededSpellBitmap = sv.spellIndex.getBitmap(c.RequiredSpells)
|
||||
sv.allowedCD = allowedCritSet(c.AllowedCritDamage)
|
||||
return sv
|
||||
}
|
||||
|
||||
var armorSlotSet = map[string]bool{
|
||||
"Head": true, "Chest": true, "Upper Arms": true, "Lower Arms": true,
|
||||
"Hands": true, "Abdomen": true, "Upper Legs": true, "Lower Legs": true, "Feet": true,
|
||||
}
|
||||
var jewelrySlotSet = map[string]bool{
|
||||
"Neck": true, "Left Ring": true, "Right Ring": true,
|
||||
"Left Wrist": true, "Right Wrist": true, "Trinket": true,
|
||||
}
|
||||
|
||||
func (sv *Solver) elapsed() float64 { return time.Since(sv.start).Seconds() }
|
||||
|
||||
// Search drives the 5-phase pipeline, emitting events as it goes.
|
||||
func (sv *Solver) Search(ctx context.Context) {
|
||||
sv.emit("phase", map[string]any{"phase": "loading", "message": "Loading items from database...", "phase_number": 1, "total_phases": 5})
|
||||
|
||||
items, err := sv.loadItems(ctx)
|
||||
if err != nil {
|
||||
sv.emit("error", map[string]any{"message": err.Error()})
|
||||
return
|
||||
}
|
||||
sv.emit("phase", map[string]any{"phase": "loaded", "message": "Loaded items", "items_count": len(items), "phase_number": 1, "total_phases": 5})
|
||||
sv.emit("log", map[string]any{"level": "info", "message": "Loaded items from characters", "timestamp": sv.elapsed()})
|
||||
if len(items) == 0 {
|
||||
sv.emit("error", map[string]any{"message": "No items found for specified characters"})
|
||||
return
|
||||
}
|
||||
|
||||
sv.emit("phase", map[string]any{"phase": "buckets", "message": "Creating equipment buckets...", "phase_number": 2, "total_phases": 5})
|
||||
buckets := sv.createBuckets(items)
|
||||
summary := map[string]any{}
|
||||
for _, b := range buckets {
|
||||
summary[b.Slot] = len(b.Items)
|
||||
}
|
||||
sv.emit("phase", map[string]any{"phase": "buckets_done", "message": "Created buckets", "bucket_count": len(buckets), "bucket_summary": summary, "phase_number": 2, "total_phases": 5})
|
||||
|
||||
sv.emit("phase", map[string]any{"phase": "reducing", "message": "Applying armor reduction rules...", "phase_number": 3, "total_phases": 5})
|
||||
buckets = sv.applyReductionOptions(buckets)
|
||||
|
||||
sv.emit("phase", map[string]any{"phase": "sorting", "message": "Optimizing search order...", "phase_number": 4, "total_phases": 5})
|
||||
buckets = sv.sortBuckets(buckets)
|
||||
|
||||
// Locked slots: drop those buckets, accumulate locked set/spell contributions.
|
||||
if len(sv.c.LockedSlots) > 0 {
|
||||
locked := map[string]bool{}
|
||||
for slot := range sv.c.LockedSlots {
|
||||
locked[slot] = true
|
||||
}
|
||||
kept := buckets[:0]
|
||||
for _, b := range buckets {
|
||||
if !locked[b.Slot] {
|
||||
kept = append(kept, b)
|
||||
}
|
||||
}
|
||||
buckets = kept
|
||||
for _, info := range sv.c.LockedSlots {
|
||||
if info.SetID != 0 {
|
||||
sv.lockedSetCounts[info.SetID]++
|
||||
}
|
||||
for _, sp := range info.Spells {
|
||||
sv.lockedSpells[sp] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
sv.effPrimary, sv.effSecondary = 5, 4
|
||||
if sv.c.PrimarySet != 0 {
|
||||
sv.effPrimary = max0(5 - sv.lockedSetCounts[sv.c.PrimarySet])
|
||||
}
|
||||
if sv.c.SecondarySet != 0 {
|
||||
sv.effSecondary = max0(4 - sv.lockedSetCounts[sv.c.SecondarySet])
|
||||
}
|
||||
|
||||
sv.emit("phase", map[string]any{"phase": "searching", "message": "Searching for optimal suits...", "total_buckets": len(buckets), "phase_number": 5, "total_phases": 5})
|
||||
sv.emit("log", map[string]any{"level": "info", "message": "Starting search", "timestamp": sv.elapsed()})
|
||||
|
||||
sv.recursiveSearch(buckets, 0, newSuitState())
|
||||
|
||||
sv.emit("complete", map[string]any{"suits_found": len(sv.bestSuits), "duration": round1(sv.elapsed())})
|
||||
}
|
||||
|
||||
// loadItems mirrors suitbuilder.load_items: fetch via the in-process search with
|
||||
// the exact same filter param sets, convert to SuitItem, register spell bitmaps,
|
||||
// pre-filter, and sort into armor+jewelry+clothing order.
|
||||
func (sv *Solver) loadItems(ctx context.Context) ([]*SuitItem, error) {
|
||||
s := sv.s
|
||||
primaryName, secondaryName := "", ""
|
||||
if sv.c.PrimarySet != 0 {
|
||||
primaryName = s.translateSetID(strconv.Itoa(sv.c.PrimarySet))
|
||||
}
|
||||
if sv.c.SecondarySet != 0 {
|
||||
secondaryName = s.translateSetID(strconv.Itoa(sv.c.SecondarySet))
|
||||
}
|
||||
equipmentStatus := ""
|
||||
if sv.c.IncludeEquipped && sv.c.IncludeInventory {
|
||||
equipmentStatus = ""
|
||||
} else if sv.c.IncludeEquipped {
|
||||
equipmentStatus = "equipped"
|
||||
} else if sv.c.IncludeInventory {
|
||||
equipmentStatus = "unequipped"
|
||||
}
|
||||
|
||||
var apiItems []map[string]any
|
||||
fetch := func(extra map[string]string) error {
|
||||
q := url.Values{}
|
||||
if len(sv.c.Characters) > 0 {
|
||||
q.Set("characters", strings.Join(sv.c.Characters, ","))
|
||||
} else {
|
||||
q.Set("include_all_characters", "true")
|
||||
}
|
||||
if equipmentStatus != "" {
|
||||
q.Set("equipment_status", equipmentStatus)
|
||||
}
|
||||
q.Set("limit", "1000")
|
||||
for k, v := range extra {
|
||||
q.Set(k, v)
|
||||
}
|
||||
res, err := s.runSearch(ctx, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if items, ok := res["items"].([]map[string]any); ok {
|
||||
apiItems = append(apiItems, items...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if primaryName != "" {
|
||||
if err := fetch(map[string]string{"item_set": primaryName}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if secondaryName != "" {
|
||||
if err := fetch(map[string]string{"item_set": secondaryName}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Clothing: DR3 shirts/pants only.
|
||||
_ = fetch(map[string]string{"shirt_only": "true", "min_damage_rating": "3"})
|
||||
_ = fetch(map[string]string{"pants_only": "true", "min_damage_rating": "3"})
|
||||
// Jewelry: one fetch per type via slot_names.
|
||||
for _, slot := range []string{"Ring", "Bracelet", "Neck", "Trinket"} {
|
||||
_ = fetch(map[string]string{"jewelry_only": "true", "slot_names": slot})
|
||||
}
|
||||
|
||||
items := make([]*SuitItem, 0, len(apiItems))
|
||||
for _, api := range apiItems {
|
||||
name := toStr(api["name"])
|
||||
char := toStr(api["character_name"])
|
||||
coverageVal := int(toInt64(api["coverage_mask"]))
|
||||
slot := toStr(api["computed_slot_name"])
|
||||
if slot == "" {
|
||||
slot = toStr(api["slot_name"])
|
||||
}
|
||||
if slot == "" {
|
||||
slot = "Unknown"
|
||||
}
|
||||
if int(toInt64(api["object_class"])) == 3 {
|
||||
switch coverageVal {
|
||||
case 104:
|
||||
slot = "Shirt"
|
||||
case 19, 22:
|
||||
slot = "Pants"
|
||||
}
|
||||
}
|
||||
rg := func(k string) int {
|
||||
v := api[k]
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return int(toInt64(v))
|
||||
}
|
||||
var spellNames []string
|
||||
if sn, ok := api["spell_names"].([]string); ok {
|
||||
spellNames = sn
|
||||
}
|
||||
it := &SuitItem{
|
||||
ID: char + "_" + name,
|
||||
Name: name,
|
||||
CharacterName: char,
|
||||
Slot: slot,
|
||||
Coverage: coverageVal,
|
||||
HasCoverage: coverageVal != 0,
|
||||
SetID: convertSetNameToID(toStr(api["item_set"])),
|
||||
ArmorLevel: int(toInt64(api["armor_level"])),
|
||||
Ratings: map[string]int{
|
||||
"crit_damage_rating": rg("crit_damage_rating"),
|
||||
"damage_rating": rg("damage_rating"),
|
||||
"damage_resist_rating": rg("damage_resist_rating"),
|
||||
"crit_damage_resist_rating": rg("crit_damage_resist_rating"),
|
||||
"heal_boost_rating": rg("heal_boost_rating"),
|
||||
"vitality_rating": rg("vitality_rating"),
|
||||
},
|
||||
SpellNames: spellNames,
|
||||
Material: toStr(api["material_name"]),
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
|
||||
for _, it := range items {
|
||||
if len(it.SpellNames) > 0 {
|
||||
it.SpellBitmap = sv.spellIndex.getBitmap(it.SpellNames)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
filtered := removeSurpassedItems(items)
|
||||
|
||||
jewelryFallback := map[string]bool{"Ring": true, "Bracelet": true, "Jewelry": true, "Necklace": true, "Amulet": true}
|
||||
matches := func(slot string, set, fallback map[string]bool) bool {
|
||||
if set[slot] {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(slot, ", ") {
|
||||
for _, p := range strings.Split(slot, ", ") {
|
||||
if set[strings.TrimSpace(p)] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if fallback != nil && fallback[slot] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
var armor, jewelry, clothing []*SuitItem
|
||||
for _, it := range filtered {
|
||||
if matches(it.Slot, armorSlotSet, nil) {
|
||||
armor = append(armor, it)
|
||||
}
|
||||
if matches(it.Slot, jewelrySlotSet, jewelryFallback) {
|
||||
jewelry = append(jewelry, it)
|
||||
}
|
||||
if matches(it.Slot, clothingSortSlots, nil) {
|
||||
clothing = append(clothing, it)
|
||||
}
|
||||
}
|
||||
sortBySpellThenName := func(list []*SuitItem) {
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
return descTuple(
|
||||
cmpInt(len(list[i].SpellNames), len(list[j].SpellNames)),
|
||||
cmpStr(list[i].CharacterName, list[j].CharacterName),
|
||||
cmpStr(list[i].Name, list[j].Name),
|
||||
)
|
||||
})
|
||||
}
|
||||
sortBySpellThenName(armor)
|
||||
sortBySpellThenName(jewelry)
|
||||
sort.SliceStable(clothing, func(i, j int) bool {
|
||||
return descTuple(
|
||||
cmpInt(clothing[i].Ratings["damage_rating"], clothing[j].Ratings["damage_rating"]),
|
||||
cmpStr(clothing[i].CharacterName, clothing[j].CharacterName),
|
||||
cmpStr(clothing[i].Name, clothing[j].Name),
|
||||
)
|
||||
})
|
||||
|
||||
out := make([]*SuitItem, 0, len(armor)+len(jewelry)+len(clothing))
|
||||
out = append(out, armor...)
|
||||
out = append(out, jewelry...)
|
||||
out = append(out, clothing...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var allSlots = []string{
|
||||
"Head", "Chest", "Upper Arms", "Lower Arms", "Hands",
|
||||
"Abdomen", "Upper Legs", "Lower Legs", "Feet",
|
||||
"Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket",
|
||||
"Shirt", "Pants",
|
||||
}
|
||||
|
||||
func (sv *Solver) createBuckets(items []*SuitItem) []*ItemBucket {
|
||||
slotItems := map[string][]*SuitItem{}
|
||||
inSlots := map[string]bool{}
|
||||
for _, slot := range allSlots {
|
||||
slotItems[slot] = nil
|
||||
inSlots[slot] = true
|
||||
}
|
||||
genericJewelry := map[string][]string{
|
||||
"Ring": {"Left Ring", "Right Ring"},
|
||||
"Bracelet": {"Left Wrist", "Right Wrist"},
|
||||
"Jewelry": {"Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"},
|
||||
"Necklace": {"Neck"},
|
||||
"Amulet": {"Neck"},
|
||||
}
|
||||
for _, it := range items {
|
||||
if inSlots[it.Slot] {
|
||||
slotItems[it.Slot] = append(slotItems[it.Slot], it)
|
||||
} else if strings.Contains(it.Slot, ", ") {
|
||||
for _, p := range strings.Split(it.Slot, ", ") {
|
||||
p = strings.TrimSpace(p)
|
||||
if inSlots[p] {
|
||||
slotItems[p] = append(slotItems[p], it.clone(p, it.Name, it.Coverage, it.HasCoverage))
|
||||
}
|
||||
}
|
||||
} else if targets, ok := genericJewelry[it.Slot]; ok {
|
||||
for _, t := range targets {
|
||||
slotItems[t] = append(slotItems[t], it.clone(t, it.Name, it.Coverage, it.HasCoverage))
|
||||
}
|
||||
} else {
|
||||
lower := strings.ToLower(it.Slot)
|
||||
for _, known := range allSlots {
|
||||
if strings.Contains(lower, strings.ToLower(known)) {
|
||||
slotItems[known] = append(slotItems[known], it.clone(known, it.Name, it.Coverage, it.HasCoverage))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buckets := make([]*ItemBucket, 0, len(allSlots))
|
||||
for _, slot := range allSlots {
|
||||
b := &ItemBucket{Slot: slot, Items: slotItems[slot], IsArmor: armorSlotSet[slot]}
|
||||
b.sortItems()
|
||||
buckets = append(buckets, b)
|
||||
}
|
||||
// armor first, then item count ascending (overridden by sortBuckets, but the
|
||||
// stable item order set here feeds the later stable re-sorts).
|
||||
sort.SliceStable(buckets, func(i, j int) bool {
|
||||
ai, aj := boolToInt(!buckets[i].IsArmor), boolToInt(!buckets[j].IsArmor)
|
||||
if ai != aj {
|
||||
return ai < aj
|
||||
}
|
||||
return len(buckets[i].Items) < len(buckets[j].Items)
|
||||
})
|
||||
sv.armorBucketsItems = 0
|
||||
for _, b := range buckets {
|
||||
if b.IsArmor && len(b.Items) > 0 {
|
||||
sv.armorBucketsItems++
|
||||
}
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
func (sv *Solver) applyReductionOptions(buckets []*ItemBucket) []*ItemBucket {
|
||||
var newBuckets []*ItemBucket
|
||||
findBucket := func(slot string) *ItemBucket {
|
||||
for _, b := range newBuckets {
|
||||
if b.Slot == slot {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, bucket := range buckets {
|
||||
if !bucket.IsArmor {
|
||||
newBuckets = append(newBuckets, bucket)
|
||||
continue
|
||||
}
|
||||
var original, reducible []*SuitItem
|
||||
for _, it := range bucket.Items {
|
||||
if it.HasCoverage && it.Material != "" && len(coverageReductionOptions(it.Coverage)) > 0 {
|
||||
reducible = append(reducible, it)
|
||||
} else {
|
||||
original = append(original, it)
|
||||
}
|
||||
}
|
||||
if len(original) > 0 || len(reducible) == 0 {
|
||||
nb := &ItemBucket{Slot: bucket.Slot, Items: original, IsArmor: bucket.IsArmor}
|
||||
nb.sortItems()
|
||||
newBuckets = append(newBuckets, nb)
|
||||
}
|
||||
for _, it := range reducible {
|
||||
for _, rc := range coverageReductionOptions(it.Coverage) {
|
||||
reducedSlot := coverageToSlotName(rc)
|
||||
if reducedSlot == "" {
|
||||
continue
|
||||
}
|
||||
reduced := it.clone(reducedSlot, it.Name+" (tailored to "+reducedSlot+")", rc, true)
|
||||
target := findBucket(reducedSlot)
|
||||
if target == nil {
|
||||
target = &ItemBucket{Slot: reducedSlot, IsArmor: true}
|
||||
newBuckets = append(newBuckets, target)
|
||||
}
|
||||
target.Items = append(target.Items, reduced)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, b := range newBuckets {
|
||||
b.sortItems()
|
||||
}
|
||||
return newBuckets
|
||||
}
|
||||
|
||||
var coreArmorPriority = []string{"Chest", "Head", "Hands", "Feet", "Upper Arms", "Lower Arms", "Abdomen", "Upper Legs", "Lower Legs"}
|
||||
var jewelryPriority = []string{"Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"}
|
||||
var clothingPriority = []string{"Shirt", "Pants"}
|
||||
|
||||
func (sv *Solver) sortBuckets(buckets []*ItemBucket) []*ItemBucket {
|
||||
for _, bucket := range buckets {
|
||||
items := bucket.Items
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
pi, pj := sv.setPriority(items[i].SetID), sv.setPriority(items[j].SetID)
|
||||
if pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
if c := cmpInt(items[i].Ratings["crit_damage_rating"], items[j].Ratings["crit_damage_rating"]); c != 0 {
|
||||
return c > 0
|
||||
}
|
||||
if c := cmpInt(items[i].Ratings["damage_rating"], items[j].Ratings["damage_rating"]); c != 0 {
|
||||
return c > 0
|
||||
}
|
||||
return items[i].ArmorLevel > items[j].ArmorLevel
|
||||
})
|
||||
}
|
||||
sort.SliceStable(buckets, func(i, j int) bool {
|
||||
gi, ii := bucketPriority(buckets[i].Slot)
|
||||
gj, ij := bucketPriority(buckets[j].Slot)
|
||||
if gi != gj {
|
||||
return gi < gj
|
||||
}
|
||||
if ii != ij {
|
||||
return ii < ij
|
||||
}
|
||||
return len(buckets[i].Items) < len(buckets[j].Items)
|
||||
})
|
||||
return buckets
|
||||
}
|
||||
|
||||
func (sv *Solver) setPriority(setID int) int {
|
||||
if setID != 0 && setID == sv.c.PrimarySet {
|
||||
return 0
|
||||
}
|
||||
if setID != 0 && setID == sv.c.SecondarySet {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func bucketPriority(slot string) (int, int) {
|
||||
for i, s := range coreArmorPriority {
|
||||
if s == slot {
|
||||
return 0, i
|
||||
}
|
||||
}
|
||||
for i, s := range jewelryPriority {
|
||||
if s == slot {
|
||||
return 1, i
|
||||
}
|
||||
}
|
||||
for i, s := range clothingPriority {
|
||||
if s == slot {
|
||||
return 2, i
|
||||
}
|
||||
}
|
||||
return 3, 0
|
||||
}
|
||||
|
||||
func (sv *Solver) recursiveSearch(buckets []*ItemBucket, idx int, state *SuitState) {
|
||||
if sv.stopped {
|
||||
return
|
||||
}
|
||||
if sv.cancelled != nil && sv.cancelled() {
|
||||
sv.stopped = true
|
||||
return
|
||||
}
|
||||
|
||||
if sv.highestArmorCount > 0 {
|
||||
currentCount := len(state.Items)
|
||||
remaining := sv.armorBucketsItems - minInt(idx, sv.armorBucketsItems)
|
||||
minRequired := sv.highestArmorCount - remaining
|
||||
if currentCount+1 < minRequired {
|
||||
return
|
||||
}
|
||||
}
|
||||
remainingBuckets := len(buckets) - idx
|
||||
maxPossible := len(state.Items) + remainingBuckets
|
||||
if sv.bestSuitItemCount > 0 && maxPossible < sv.bestSuitItemCount {
|
||||
return
|
||||
}
|
||||
|
||||
if idx >= len(buckets) {
|
||||
suit := sv.finalizeSuit(state)
|
||||
if suit != nil && sv.isBetterThanExisting(suit) {
|
||||
sv.bestSuits = append(sv.bestSuits, suit)
|
||||
if len(suit.Items) > sv.bestSuitItemCount {
|
||||
sv.bestSuitItemCount = len(suit.Items)
|
||||
}
|
||||
armorPieces := 0
|
||||
for slot := range suit.Items {
|
||||
if armorSlotSet[slot] {
|
||||
armorPieces++
|
||||
}
|
||||
}
|
||||
if armorPieces > sv.highestArmorCount {
|
||||
sv.highestArmorCount = armorPieces
|
||||
}
|
||||
sort.SliceStable(sv.bestSuits, func(i, j int) bool { return sv.bestSuits[i].Score > sv.bestSuits[j].Score })
|
||||
if len(sv.bestSuits) > sv.c.MaxResults {
|
||||
sv.bestSuits = sv.bestSuits[:sv.c.MaxResults]
|
||||
}
|
||||
sv.emit("suit", sv.suitData(suit))
|
||||
sv.emit("log", map[string]any{"level": "success", "message": "Found suit", "timestamp": sv.elapsed()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
sv.evaluated++
|
||||
if sv.evaluated%100 == 0 {
|
||||
if sv.cancelled != nil && sv.cancelled() {
|
||||
sv.stopped = true
|
||||
return
|
||||
}
|
||||
bestScore := 0
|
||||
if len(sv.bestSuits) > 0 {
|
||||
bestScore = sv.bestSuits[0].Score
|
||||
}
|
||||
var curBucket any
|
||||
if idx < len(buckets) {
|
||||
curBucket = buckets[idx].Slot
|
||||
}
|
||||
el := sv.elapsed()
|
||||
rate := 0.0
|
||||
if el > 0 {
|
||||
rate = round1(float64(sv.evaluated) / el)
|
||||
}
|
||||
sv.emit("progress", map[string]any{
|
||||
"evaluated": sv.evaluated, "found": len(sv.bestSuits), "current_depth": idx,
|
||||
"total_buckets": len(buckets), "current_items": len(state.Items), "elapsed": el,
|
||||
"rate": rate, "current_bucket": curBucket, "best_score": bestScore,
|
||||
})
|
||||
if sv.evaluated%500 == 0 {
|
||||
sv.emit("log", map[string]any{"level": "info", "message": "Evaluating combinations", "timestamp": el})
|
||||
}
|
||||
}
|
||||
|
||||
bucket := buckets[idx]
|
||||
accepted := 0
|
||||
for _, it := range bucket.Items {
|
||||
if sv.canAddItem(it, state) {
|
||||
accepted++
|
||||
state.push(it)
|
||||
sv.recursiveSearch(buckets, idx+1, state)
|
||||
state.pop(it.Slot)
|
||||
if sv.stopped {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if accepted == 0 {
|
||||
sv.recursiveSearch(buckets, idx+1, state)
|
||||
}
|
||||
}
|
||||
|
||||
func (sv *Solver) canAddItem(it *SuitItem, state *SuitState) bool {
|
||||
if state.Occupied[it.Slot] {
|
||||
return false
|
||||
}
|
||||
for _, ex := range state.Items {
|
||||
if ex.ID == it.ID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if it.SetID != 0 {
|
||||
current := state.SetCounts[it.SetID]
|
||||
if it.SetID == sv.c.PrimarySet {
|
||||
if current >= sv.effPrimary {
|
||||
return false
|
||||
}
|
||||
} else if it.SetID == sv.c.SecondarySet {
|
||||
if current >= sv.effSecondary {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if jewelrySlotSet[it.Slot] {
|
||||
if !sv.jewelryContributesRequiredSpell(it, state) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if it.Slot == "Shirt" || it.Slot == "Pants" {
|
||||
// clothing allowed without set id
|
||||
} else if jewelrySlotSet[it.Slot] {
|
||||
if !sv.jewelryContributesRequiredSpell(it, state) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(sv.c.RequiredSpells) > 0 && len(it.SpellNames) > 0 {
|
||||
if !sv.canGetBeneficialSpellFrom(it, state) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (sv *Solver) canGetBeneficialSpellFrom(it *SuitItem, state *SuitState) bool {
|
||||
if len(it.SpellNames) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(sv.c.RequiredSpells) == 0 {
|
||||
return true
|
||||
}
|
||||
newBeneficial := it.SpellBitmap & sv.neededSpellBitmap & ^state.SpellBitmap
|
||||
return newBeneficial != 0
|
||||
}
|
||||
|
||||
func (sv *Solver) jewelryContributesRequiredSpell(it *SuitItem, state *SuitState) bool {
|
||||
if len(sv.c.RequiredSpells) == 0 {
|
||||
return false
|
||||
}
|
||||
if len(it.SpellNames) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, sp := range it.SpellNames {
|
||||
bit := sv.spellIndex.getBitmap([]string{sp})
|
||||
if bit&sv.neededSpellBitmap != 0 && state.SpellBitmap&bit == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (sv *Solver) finalizeSuit(state *SuitState) *CompletedSuit {
|
||||
if len(state.Items) == 0 {
|
||||
return nil
|
||||
}
|
||||
score := sv.calculateScore(state)
|
||||
var fulfilled, missing []string
|
||||
if len(sv.c.RequiredSpells) > 0 {
|
||||
fulfilled = sv.spellIndex.getSpellNames(state.SpellBitmap & sv.neededSpellBitmap)
|
||||
missing = sv.spellIndex.getSpellNames(sv.neededSpellBitmap & ^state.SpellBitmap)
|
||||
if len(sv.lockedSpells) > 0 {
|
||||
for sp := range sv.lockedSpells {
|
||||
missing = removeString(missing, sp)
|
||||
if !containsString(fulfilled, sp) {
|
||||
fulfilled = append(fulfilled, sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items := make(map[string]*SuitItem, len(state.Items))
|
||||
for k, v := range state.Items {
|
||||
items[k] = v
|
||||
}
|
||||
ratings := map[string]int{}
|
||||
for k, v := range state.TotalRatings {
|
||||
ratings[k] = v
|
||||
}
|
||||
setCounts := map[int]int{}
|
||||
for k, v := range state.SetCounts {
|
||||
setCounts[k] = v
|
||||
}
|
||||
return &CompletedSuit{
|
||||
Items: items, Score: score, TotalArmor: state.TotalArmor,
|
||||
TotalRatings: ratings, SetCounts: setCounts,
|
||||
FulfilledSpells: fulfilled, MissingSpells: missing,
|
||||
}
|
||||
}
|
||||
|
||||
func (sv *Solver) calculateScore(state *SuitState) int {
|
||||
score := 0
|
||||
w := sv.weights
|
||||
foundPrimary, foundSecondary := 0, 0
|
||||
if sv.c.PrimarySet != 0 {
|
||||
foundPrimary = state.SetCounts[sv.c.PrimarySet]
|
||||
}
|
||||
if sv.c.SecondarySet != 0 {
|
||||
foundSecondary = state.SetCounts[sv.c.SecondarySet]
|
||||
}
|
||||
if foundPrimary >= sv.effPrimary {
|
||||
score += w.ArmorSetComplete
|
||||
if foundPrimary > sv.effPrimary {
|
||||
score -= (foundPrimary - sv.effPrimary) * 500
|
||||
}
|
||||
} else if sv.c.PrimarySet != 0 && foundPrimary > 0 {
|
||||
score += (sv.effPrimary - foundPrimary) * w.MissingSetPenalty
|
||||
}
|
||||
if foundSecondary >= sv.effSecondary {
|
||||
score += w.ArmorSetComplete
|
||||
if foundSecondary > sv.effSecondary {
|
||||
score -= (foundSecondary - sv.effSecondary) * 500
|
||||
}
|
||||
} else if sv.c.SecondarySet != 0 && foundSecondary > 0 {
|
||||
score += (sv.effSecondary - foundSecondary) * w.MissingSetPenalty
|
||||
}
|
||||
for _, it := range state.Items {
|
||||
switch it.Ratings["crit_damage_rating"] {
|
||||
case 1:
|
||||
score += w.CritDamage1
|
||||
case 2:
|
||||
score += w.CritDamage2
|
||||
}
|
||||
}
|
||||
for _, it := range state.Items {
|
||||
if it.Slot == "Shirt" || it.Slot == "Pants" {
|
||||
switch it.Ratings["damage_rating"] {
|
||||
case 1:
|
||||
score += w.DamageRating1
|
||||
case 2:
|
||||
score += w.DamageRating2
|
||||
case 3:
|
||||
score += w.DamageRating3
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(sv.c.RequiredSpells) > 0 {
|
||||
score += popcount(state.SpellBitmap&sv.neededSpellBitmap) * 100
|
||||
}
|
||||
score += len(state.Items) * 5
|
||||
// Python uses floor division (//); total_armor can be negative because
|
||||
// non-armor items carry armor_level = -1. Go's / truncates toward zero, so a
|
||||
// slightly-negative total would be +1 too high.
|
||||
score += floorDiv(state.TotalArmor, 100)
|
||||
if score < 0 {
|
||||
return 0
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func (sv *Solver) isBetterThanExisting(suit *CompletedSuit) bool {
|
||||
if len(sv.bestSuits) < sv.c.MaxResults {
|
||||
return true
|
||||
}
|
||||
lowest := sv.bestSuits[len(sv.bestSuits)-1]
|
||||
if len(suit.Items) > len(lowest.Items) {
|
||||
return true
|
||||
}
|
||||
return suit.Score > lowest.Score
|
||||
}
|
||||
|
||||
// suitData builds the streamed suit payload (CompletedSuit.to_dict plus the
|
||||
// constraint-derived stats overrides from recursive_search).
|
||||
func (sv *Solver) suitData(suit *CompletedSuit) map[string]any {
|
||||
d := suit.toDict()
|
||||
stats := d["stats"].(map[string]any)
|
||||
primaryCount, secondaryCount := 0, 0
|
||||
if sv.c.PrimarySet != 0 {
|
||||
primaryCount = suit.SetCounts[sv.c.PrimarySet] + sv.lockedSetCounts[sv.c.PrimarySet]
|
||||
}
|
||||
if sv.c.SecondarySet != 0 {
|
||||
secondaryCount = suit.SetCounts[sv.c.SecondarySet] + sv.lockedSetCounts[sv.c.SecondarySet]
|
||||
}
|
||||
var primaryName, secondaryName any
|
||||
if sv.c.PrimarySet != 0 {
|
||||
primaryName = sv.s.translateSetID(strconv.Itoa(sv.c.PrimarySet))
|
||||
}
|
||||
if sv.c.SecondarySet != 0 {
|
||||
secondaryName = sv.s.translateSetID(strconv.Itoa(sv.c.SecondarySet))
|
||||
}
|
||||
stats["primary_set_count"] = primaryCount
|
||||
stats["secondary_set_count"] = secondaryCount
|
||||
stats["primary_set"] = primaryName
|
||||
stats["secondary_set"] = secondaryName
|
||||
stats["locked_slots"] = len(sv.c.LockedSlots)
|
||||
stats["primary_locked"] = sv.lockedSetCounts[sv.c.PrimarySet]
|
||||
stats["secondary_locked"] = sv.lockedSetCounts[sv.c.SecondarySet]
|
||||
return d
|
||||
}
|
||||
|
||||
// --- small helpers ---
|
||||
|
||||
func max0(v int) int {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// floorDiv matches Python's // (floor toward -inf), unlike Go's / (toward zero).
|
||||
func floorDiv(a, b int) int {
|
||||
q := a / b
|
||||
if a%b != 0 && (a < 0) != (b < 0) {
|
||||
q--
|
||||
}
|
||||
return q
|
||||
}
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func popcount(v uint64) int {
|
||||
c := 0
|
||||
for v != 0 {
|
||||
v &= v - 1
|
||||
c++
|
||||
}
|
||||
return c
|
||||
}
|
||||
func round1(v float64) float64 {
|
||||
return float64(int64(v*10+0.5)) / 10
|
||||
}
|
||||
func containsString(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func removeString(list []string, s string) []string {
|
||||
for i, x := range list {
|
||||
if x == s {
|
||||
return append(list[:i], list[i+1:]...)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
# Parallel-run nginx wiring for the Go tracker (dereth-tracker-go, 127.0.0.1:8770).
|
||||
#
|
||||
# Deploying needs root (the agent cannot sudo). Apply on the host:
|
||||
#
|
||||
# 1) Add the upstream to the http{} block of /etc/nginx/nginx.conf, next to the
|
||||
# existing `tracker` and `grafana` upstreams (around line 55):
|
||||
#
|
||||
# upstream tracker_go { server 127.0.0.1:8770; }
|
||||
#
|
||||
# 2) Insert the `location /go/` block below into the server{} block of
|
||||
# /etc/nginx/sites-enabled/overlord (anywhere in server{}; nginx matches the
|
||||
# longer /go/ prefix before /, so order doesn't matter). Mirror it into the
|
||||
# repo copy nginx/overlord.conf too — but note the live file has DRIFTED from
|
||||
# the repo copy, so reconcile by hand rather than cp-overwriting.
|
||||
#
|
||||
# 3) sudo nginx -t && sudo nginx -s reload
|
||||
#
|
||||
# After reload:
|
||||
# https://overlord.snakedesert.se/go/health -> 200 (public)
|
||||
# https://overlord.snakedesert.se/go/api-version -> 200 (logged-in) / 401 (no cookie)
|
||||
# https://overlord.snakedesert.se/go/live -> matches /live (same login cookie)
|
||||
#
|
||||
# The Go service is auth-gated identically to Python (session cookie + internal
|
||||
# trust), and X-Forwarded-For below is REQUIRED — without it the Go service would
|
||||
# treat all internet traffic as internal-trust and skip auth (security invariant).
|
||||
|
||||
location /go/ {
|
||||
proxy_pass http://tracker_go/; # trailing slash strips the /go/ prefix
|
||||
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; # REQUIRED — security invariant
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
# Go will serve long-lived browser WebSockets in a later phase; match the
|
||||
# /websocket/ and / blocks so idle sockets aren't cut at nginx's default 60s.
|
||||
proxy_read_timeout 1d;
|
||||
proxy_send_timeout 1d;
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
# Multi-stage build: compile a static Go binary, ship it on distroless.
|
||||
# No host Go toolchain required — everything happens inside the build stage.
|
||||
FROM golang:1.25-bookworm AS build
|
||||
WORKDIR /src
|
||||
|
||||
# No local Go toolchain is available to maintain go.sum, so resolve and lock
|
||||
# dependencies inside the build (network is available here). `go mod tidy`
|
||||
# reads the imports from the source and writes go.mod/go.sum, then we build.
|
||||
COPY . .
|
||||
RUN go mod tidy
|
||||
RUN go test ./...
|
||||
ARG BUILD_VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath \
|
||||
-ldflags "-s -w -X main.buildVersion=${BUILD_VERSION}" \
|
||||
-o /out/tracker-go .
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /out/tracker-go /tracker-go
|
||||
EXPOSE 8770
|
||||
ENTRYPOINT ["/tracker-go"]
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// trimFloat formats a vitae value without a trailing ".0" for whole numbers.
|
||||
func trimFloat(f float64) string {
|
||||
if f == float64(int64(f)) {
|
||||
return strconv.FormatInt(int64(f), 10)
|
||||
}
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// aclogPoster posts death + idle alerts to the #aclog Discord webhook, porting
|
||||
// main.py's _send_discord_aclog / death detection / _idle_detection_loop. nil
|
||||
// when DISCORD_ACLOG_WEBHOOK is unset (or in shadow mode).
|
||||
type aclogPoster struct {
|
||||
webhook string
|
||||
client *http.Client
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
deathAlerted map[string]time.Time // char -> last death alert (max 1 / 5min)
|
||||
idleSince map[string]time.Time // char -> first detected idle
|
||||
idleAlerted map[string]bool // char -> already alerted this idle period
|
||||
}
|
||||
|
||||
func newACLogPoster(webhook string, log *slog.Logger) *aclogPoster {
|
||||
return &aclogPoster{
|
||||
webhook: webhook,
|
||||
client: &http.Client{Timeout: 5 * time.Second},
|
||||
log: log,
|
||||
deathAlerted: map[string]time.Time{},
|
||||
idleSince: map[string]time.Time{},
|
||||
idleAlerted: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *aclogPoster) post(message string) {
|
||||
if a == nil || a.webhook == "" {
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"content": message})
|
||||
resp, err := a.client.Post(a.webhook, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
a.log.Debug("discord webhook failed", "err", err)
|
||||
return
|
||||
}
|
||||
drain(resp)
|
||||
}
|
||||
|
||||
// maybeDeath fires a death alert when vitae crosses 0 -> >0, capped at 1 per
|
||||
// 5 minutes per character (main.py:3419).
|
||||
func (a *aclogPoster) maybeDeath(name string, vitae float64) {
|
||||
if a == nil || a.webhook == "" {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
last, ok := a.deathAlerted[name]
|
||||
if ok && time.Since(last) <= 5*time.Minute {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
a.deathAlerted[name] = time.Now()
|
||||
a.mu.Unlock()
|
||||
go a.post(fmt.Sprintf("☠️ **%s** died! (vitae: %s%%)", name, trimFloat(vitae)))
|
||||
}
|
||||
|
||||
// runIdleLoop polls online players every 60s and alerts on idle (main.py:2694).
|
||||
func (a *aclogPoster) runIdleLoop(ctx context.Context, pool *pgxpool.Pool) {
|
||||
if a == nil || a.webhook == "" {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-time.After(30 * time.Second): // let telemetry arrive first
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(60 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
a.checkIdleOnce(ctx, pool)
|
||||
select {
|
||||
case <-t.C:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *aclogPoster) checkIdleOnce(ctx context.Context, pool *pgxpool.Pool) {
|
||||
qctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
rows, err := pool.Query(qctx, `
|
||||
SELECT DISTINCT ON (character_name) character_name, COALESCE(vt_state,''), COALESCE(kills_per_hour, 0)
|
||||
FROM telemetry_events
|
||||
WHERE COALESCE(received_at, timestamp) > now() - interval '30 seconds'
|
||||
ORDER BY character_name, timestamp DESC`)
|
||||
if err != nil {
|
||||
a.log.Debug("idle query failed", "err", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
now := time.Now()
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for rows.Next() {
|
||||
var name, vtState string
|
||||
var kph float64
|
||||
if rows.Scan(&name, &vtState, &kph) != nil {
|
||||
continue
|
||||
}
|
||||
s := strings.ToLower(vtState)
|
||||
kphi := int(kph)
|
||||
isIdle := s == "default" || s == "idle" || s == "" || ((s == "combat" || s == "hunt") && kphi == 0)
|
||||
if isIdle {
|
||||
if _, seen := a.idleSince[name]; !seen {
|
||||
a.idleSince[name] = now
|
||||
} else if !a.idleAlerted[name] && now.Sub(a.idleSince[name]) >= 5*time.Minute {
|
||||
a.idleAlerted[name] = true
|
||||
idleMins := int(now.Sub(a.idleSince[name]).Minutes())
|
||||
stateText := vtState
|
||||
if stateText == "" {
|
||||
stateText = "idle"
|
||||
}
|
||||
go a.post(fmt.Sprintf("⚠️ **%s** appears idle for %dmin (state: %s, KPH: %d)", name, idleMins, stateText, kphi))
|
||||
}
|
||||
} else {
|
||||
delete(a.idleAlerted, name)
|
||||
delete(a.idleSince, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type userCtxKey struct{}
|
||||
|
||||
func withUser(ctx context.Context, u *sessionUser) context.Context {
|
||||
return context.WithValue(ctx, userCtxKey{}, u)
|
||||
}
|
||||
|
||||
// currentUser returns the authenticated user for the request, or nil (e.g.
|
||||
// internal-trust loopback requests carry no user identity).
|
||||
func currentUser(r *http.Request) *sessionUser {
|
||||
u, _ := r.Context().Value(userCtxKey{}).(*sessionUser)
|
||||
return u
|
||||
}
|
||||
|
||||
// requireAdmin writes 403 and returns false unless the request is an admin
|
||||
// (main.py _require_admin).
|
||||
func requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
if u := currentUser(r); u != nil && u.IsAdmin {
|
||||
return true
|
||||
}
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{"detail": "Admin access required"})
|
||||
return false
|
||||
}
|
||||
|
||||
// Session-cookie verification compatible with the Python service's
|
||||
// itsdangerous URLSafeTimedSerializer(SECRET_KEY) (itsdangerous 2.2):
|
||||
// - HMAC-SHA1 signature
|
||||
// - django-concat key derivation: sha1(salt + b"signer" + secret_key)
|
||||
// - salt "itsdangerous", separator ".", Unix-epoch timestamp
|
||||
// - payload = urlsafe-base64(no pad) of compact JSON {"u":username,"a":is_admin},
|
||||
// optionally zlib-compressed with a leading "." marker
|
||||
// Reusing the same SECRET_KEY means a login on the Python service authenticates
|
||||
// on the Go service during the parallel run.
|
||||
|
||||
const sessionMaxAge = 30 * 24 * 3600 // SESSION_MAX_AGE seconds (30 days)
|
||||
|
||||
type sessionUser struct {
|
||||
Username string
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
func deriveSignerKey(secretKey string) []byte {
|
||||
h := sha1.New()
|
||||
h.Write([]byte("itsdangerous")) // salt
|
||||
h.Write([]byte("signer"))
|
||||
h.Write([]byte(secretKey))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// verifySessionCookie returns the user encoded in a valid, unexpired token, or
|
||||
// nil. Constant-time signature comparison; never partially trusts a bad token.
|
||||
func verifySessionCookie(secretKey, token string) *sessionUser {
|
||||
if secretKey == "" || token == "" {
|
||||
return nil
|
||||
}
|
||||
// signature is everything after the final separator.
|
||||
i := strings.LastIndexByte(token, '.')
|
||||
if i <= 0 {
|
||||
return nil
|
||||
}
|
||||
signed := token[:i] // payload + "." + timestamp
|
||||
sig, err := base64.RawURLEncoding.DecodeString(token[i+1:])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
mac := hmac.New(sha1.New, deriveSignerKey(secretKey))
|
||||
mac.Write([]byte(signed))
|
||||
if !hmac.Equal(sig, mac.Sum(nil)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// timestamp is after the second-to-last separator; payload precedes it
|
||||
// (the payload may itself start with "." when zlib-compressed).
|
||||
j := strings.LastIndexByte(signed, '.')
|
||||
if j < 0 {
|
||||
return nil
|
||||
}
|
||||
tsBytes, err := base64.RawURLEncoding.DecodeString(signed[j+1:])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var ts int64
|
||||
for _, b := range tsBytes {
|
||||
ts = ts<<8 | int64(b)
|
||||
}
|
||||
if time.Now().Unix()-ts > sessionMaxAge {
|
||||
return nil // expired
|
||||
}
|
||||
|
||||
payload, err := decodeItsdangerousPayload(signed[:j])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var data struct {
|
||||
U string `json:"u"`
|
||||
A bool `json:"a"`
|
||||
}
|
||||
if json.Unmarshal(payload, &data) != nil {
|
||||
return nil
|
||||
}
|
||||
return &sessionUser{Username: data.U, IsAdmin: data.A}
|
||||
}
|
||||
|
||||
// issueSessionCookie produces an itsdangerous URLSafeTimedSerializer token
|
||||
// compatible with the Python service (so Go-issued cookies verify on Python and
|
||||
// vice-versa). Inverse of verifySessionCookie.
|
||||
func issueSessionCookie(secretKey string, u sessionUser) string {
|
||||
payload, _ := json.Marshal(struct {
|
||||
U string `json:"u"`
|
||||
A bool `json:"a"`
|
||||
}{u.Username, u.IsAdmin})
|
||||
payloadPart := encodeItsdangerousPayload(payload)
|
||||
tsPart := base64.RawURLEncoding.EncodeToString(int64ToBytes(time.Now().Unix()))
|
||||
signed := payloadPart + "." + tsPart
|
||||
mac := hmac.New(sha1.New, deriveSignerKey(secretKey))
|
||||
mac.Write([]byte(signed))
|
||||
return signed + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// encodeItsdangerousPayload mirrors URLSafeSerializerBase.dump_payload: zlib-
|
||||
// compress only when it actually saves more than one byte (it won't for our tiny
|
||||
// payload), then urlsafe-base64 (no pad), with a "." marker if compressed.
|
||||
func encodeItsdangerousPayload(jsonBytes []byte) string {
|
||||
var buf bytes.Buffer
|
||||
zw := zlib.NewWriter(&buf)
|
||||
_, _ = zw.Write(jsonBytes)
|
||||
_ = zw.Close()
|
||||
if compressed := buf.Bytes(); len(compressed) < len(jsonBytes)-1 {
|
||||
return "." + base64.RawURLEncoding.EncodeToString(compressed)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(jsonBytes)
|
||||
}
|
||||
|
||||
// int64ToBytes encodes a non-negative int as minimal big-endian bytes, matching
|
||||
// itsdangerous int_to_bytes (verifySessionCookie reads it back the same way).
|
||||
func int64ToBytes(n int64) []byte {
|
||||
if n == 0 {
|
||||
return []byte{0}
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte(n & 0xff)}, b...)
|
||||
n >>= 8
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func decodeItsdangerousPayload(p string) ([]byte, error) {
|
||||
compressed := strings.HasPrefix(p, ".")
|
||||
if compressed {
|
||||
p = p[1:]
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !compressed {
|
||||
return raw, nil
|
||||
}
|
||||
zr, err := zlib.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer zr.Close()
|
||||
return io.ReadAll(zr)
|
||||
}
|
||||
|
||||
// authMiddleware replicates main.py's AuthMiddleware: public paths pass through;
|
||||
// private-source + no X-Forwarded-For is internal-trust (skip auth); otherwise a
|
||||
// valid session cookie is required.
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isPublicPath(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Internal trust: only when the peer is private AND nginx did not add
|
||||
// X-Forwarded-For (nginx sets XFF on all proxied internet traffic).
|
||||
if r.Header.Get("X-Forwarded-For") == "" && isPrivateAddr(clientIP(r)) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if c, err := r.Cookie("session"); err == nil {
|
||||
if u := verifySessionCookie(s.secretKey, c.Value); u != nil {
|
||||
next.ServeHTTP(w, r.WithContext(withUser(r.Context(), u)))
|
||||
return
|
||||
}
|
||||
}
|
||||
if strings.Contains(r.Header.Get("Accept"), "text/html") {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Not authenticated"})
|
||||
})
|
||||
}
|
||||
|
||||
func isPublicPath(p string) bool {
|
||||
switch p {
|
||||
case "/login", "/logout", "/login.html", "/login-style.css", "/health":
|
||||
return true
|
||||
}
|
||||
// WS endpoints authenticate inside their own handlers.
|
||||
return strings.HasPrefix(p, "/icons/") || strings.HasPrefix(p, "/ws/")
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func isPrivateAddr(ip string) bool {
|
||||
a := net.ParseIP(ip)
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
return a.IsLoopback() || a.IsPrivate()
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// GET /character-stats/{name} — latest full stats. Phase 1 reads the DB
|
||||
// (character_stats is authoritative); the live_character_stats overlay is an
|
||||
// ingest-only freshness layer we don't have yet. (main.py:4137)
|
||||
func (s *Server) handleCharacterStats(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
// Live overlay first (ingest mode), like Python's live_character_stats check.
|
||||
if s.ingestor != nil {
|
||||
if v, ok := s.ingestor.getCharacterStats(name); ok {
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx, cancel := reqCtx(r)
|
||||
defer cancel()
|
||||
|
||||
row, err := queryRowAsMap(ctx, s.pool, `SELECT * FROM character_stats WHERE character_name = $1`, name)
|
||||
if err != nil {
|
||||
s.dbErr(w, "character-stats", err)
|
||||
return
|
||||
}
|
||||
if row == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "No stats available for this character"})
|
||||
return
|
||||
}
|
||||
// Merge stats_data JSONB up to the top level, matching the frontend contract.
|
||||
sd := asJSONMap(row["stats_data"])
|
||||
delete(row, "stats_data")
|
||||
formatTimes([]map[string]any{row}, "timestamp")
|
||||
for k, v := range sd {
|
||||
row[k] = v
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// GET /combat-stats/{character_name} — lifetime combat blob. Phase 1: DB only,
|
||||
// so session is always null. (main.py:1819)
|
||||
func (s *Server) handleCombatStatsOne(w http.ResponseWriter, r *http.Request) {
|
||||
cn := r.PathValue("character_name")
|
||||
if s.ingestor != nil {
|
||||
if live, ok := s.ingestor.getCombatStats(cn); ok {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"character_name": cn, "session": live["session"], "lifetime": live["lifetime"],
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx, cancel := reqCtx(r)
|
||||
defer cancel()
|
||||
|
||||
row, err := queryRowAsMap(ctx, s.pool, `SELECT stats_data FROM combat_stats WHERE character_name = $1`, cn)
|
||||
if err != nil {
|
||||
s.dbErr(w, "combat-stats/one", err)
|
||||
return
|
||||
}
|
||||
if row == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"character_name": cn, "session": nil, "lifetime": nil})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"character_name": cn,
|
||||
"session": nil,
|
||||
"lifetime": decodeJSONValue(row["stats_data"]),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /combat-stats — all characters' lifetime combat blobs. Phase 1: DB only. (main.py:1850)
|
||||
func (s *Server) handleCombatStatsAll(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := reqCtx(r)
|
||||
defer cancel()
|
||||
|
||||
results := make([]map[string]any, 0)
|
||||
seen := map[string]bool{}
|
||||
if s.ingestor != nil { // live overlay first, like Python
|
||||
for char, live := range s.ingestor.allCombatStats() {
|
||||
seen[char] = true
|
||||
results = append(results, map[string]any{
|
||||
"character_name": char, "session": live["session"], "lifetime": live["lifetime"],
|
||||
})
|
||||
}
|
||||
}
|
||||
rows, err := queryRowsAsMaps(ctx, s.pool, `SELECT character_name, stats_data FROM combat_stats`)
|
||||
if err != nil {
|
||||
s.dbErr(w, "combat-stats/all", err)
|
||||
return
|
||||
}
|
||||
for _, row := range rows {
|
||||
if seen[toStr(row["character_name"])] {
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]any{
|
||||
"character_name": row["character_name"],
|
||||
"session": nil,
|
||||
"lifetime": decodeJSONValue(row["stats_data"]),
|
||||
})
|
||||
}
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return toStr(results[i]["character_name"]) < toStr(results[j]["character_name"])
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"stats": results})
|
||||
}
|
||||
|
||||
func toStr(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// runCombatMergeCLI folds a JSON array of cumulative session snapshots (stdin)
|
||||
// through combatSessionDelta + combatMergeIntoLifetime and prints the resulting
|
||||
// lifetime, mirroring exactly what the combat_stats handler accumulates. Used to
|
||||
// diff the Go accumulator against the Python functions on identical input.
|
||||
func runCombatMergeCLI() {
|
||||
raw, _ := io.ReadAll(os.Stdin)
|
||||
var snapshots []map[string]any
|
||||
if err := json.Unmarshal(raw, &snapshots); err != nil {
|
||||
os.Stderr.WriteString("combat-merge: invalid JSON: " + err.Error() + "\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
lifetime := map[string]any{}
|
||||
var last map[string]any
|
||||
for _, s := range snapshots {
|
||||
var delta map[string]any
|
||||
if last != nil {
|
||||
delta = combatSessionDelta(s, last)
|
||||
} else {
|
||||
delta = s
|
||||
}
|
||||
lifetime = combatMergeIntoLifetime(lifetime, delta)
|
||||
last = s
|
||||
}
|
||||
out, _ := json.Marshal(lifetime)
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// Combat stats accumulation — a faithful port of main.py's
|
||||
// _combat_session_delta / _combat_merge_into_lifetime (incl. the documented
|
||||
// quirk that offense/defense use the latest snapshot rather than a true delta).
|
||||
// JSON numbers decode to float64; Go marshals whole floats without a decimal,
|
||||
// so the stored JSONB matches Python's integer output.
|
||||
|
||||
func num(v any) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case int:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return float64(x)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func asMap(v any) map[string]any {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
return m
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
func combatSessionDelta(newS, oldS map[string]any) map[string]any {
|
||||
delta := map[string]any{
|
||||
"total_damage_given": num(newS["total_damage_given"]) - num(oldS["total_damage_given"]),
|
||||
"total_damage_received": num(newS["total_damage_received"]) - num(oldS["total_damage_received"]),
|
||||
"total_kills": num(newS["total_kills"]) - num(oldS["total_kills"]),
|
||||
"total_aetheria_surges": num(newS["total_aetheria_surges"]) - num(oldS["total_aetheria_surges"]),
|
||||
"total_cloak_surges": num(newS["total_cloak_surges"]) - num(oldS["total_cloak_surges"]),
|
||||
"monsters": map[string]any{},
|
||||
}
|
||||
newMonsters := asMap(newS["monsters"])
|
||||
oldMonsters := asMap(oldS["monsters"])
|
||||
dMonsters := delta["monsters"].(map[string]any)
|
||||
for name, nmv := range newMonsters {
|
||||
nm := asMap(nmv)
|
||||
om := asMap(oldMonsters[name])
|
||||
dm := map[string]any{
|
||||
"name": name,
|
||||
"kill_count": num(nm["kill_count"]) - num(om["kill_count"]),
|
||||
"damage_given": num(nm["damage_given"]) - num(om["damage_given"]),
|
||||
"damage_received": num(nm["damage_received"]) - num(om["damage_received"]),
|
||||
"aetheria_surges": num(nm["aetheria_surges"]) - num(om["aetheria_surges"]),
|
||||
"cloak_surges": num(nm["cloak_surges"]) - num(om["cloak_surges"]),
|
||||
"offense": asMap(nm["offense"]), // latest snapshot, per main.py
|
||||
"defense": asMap(nm["defense"]),
|
||||
}
|
||||
if num(dm["kill_count"]) > 0 || num(dm["damage_given"]) > 0 || num(dm["damage_received"]) > 0 {
|
||||
dMonsters[name] = dm
|
||||
}
|
||||
}
|
||||
return delta
|
||||
}
|
||||
|
||||
func combatMergeIntoLifetime(lifetime, delta map[string]any) map[string]any {
|
||||
if len(lifetime) == 0 {
|
||||
lifetime = map[string]any{
|
||||
"total_damage_given": float64(0), "total_damage_received": float64(0),
|
||||
"total_kills": float64(0), "total_aetheria_surges": float64(0),
|
||||
"total_cloak_surges": float64(0), "monsters": map[string]any{},
|
||||
}
|
||||
}
|
||||
lifetime["total_damage_given"] = num(lifetime["total_damage_given"]) + num(delta["total_damage_given"])
|
||||
lifetime["total_damage_received"] = num(lifetime["total_damage_received"]) + num(delta["total_damage_received"])
|
||||
lifetime["total_kills"] = num(lifetime["total_kills"]) + num(delta["total_kills"])
|
||||
lifetime["total_aetheria_surges"] = num(lifetime["total_aetheria_surges"]) + num(delta["total_aetheria_surges"])
|
||||
lifetime["total_cloak_surges"] = num(lifetime["total_cloak_surges"]) + num(delta["total_cloak_surges"])
|
||||
|
||||
ltMonsters := asMap(lifetime["monsters"])
|
||||
lifetime["monsters"] = ltMonsters
|
||||
for name, dmv := range asMap(delta["monsters"]) {
|
||||
dm := asMap(dmv)
|
||||
lmv, ok := ltMonsters[name]
|
||||
if !ok {
|
||||
lmv = map[string]any{
|
||||
"name": name, "kill_count": float64(0), "damage_given": float64(0),
|
||||
"damage_received": float64(0), "aetheria_surges": float64(0),
|
||||
"cloak_surges": float64(0), "offense": map[string]any{}, "defense": map[string]any{},
|
||||
}
|
||||
ltMonsters[name] = lmv
|
||||
}
|
||||
lm := asMap(lmv)
|
||||
lm["kill_count"] = num(lm["kill_count"]) + num(dm["kill_count"])
|
||||
lm["damage_given"] = num(lm["damage_given"]) + num(dm["damage_given"])
|
||||
lm["damage_received"] = num(lm["damage_received"]) + num(dm["damage_received"])
|
||||
lm["aetheria_surges"] = num(lm["aetheria_surges"]) + num(dm["aetheria_surges"])
|
||||
lm["cloak_surges"] = num(lm["cloak_surges"]) + num(dm["cloak_surges"])
|
||||
for _, side := range []string{"offense", "defense"} {
|
||||
deltaSide := asMap(dm[side])
|
||||
if len(deltaSide) == 0 {
|
||||
continue
|
||||
}
|
||||
ltSide := asMap(lm[side])
|
||||
lm[side] = ltSide
|
||||
for atkType, byElV := range deltaSide {
|
||||
ltByEl := asMap(ltSide[atkType])
|
||||
ltSide[atkType] = ltByEl
|
||||
for el, statsV := range asMap(byElV) {
|
||||
stats := asMap(statsV)
|
||||
ltS := asMap(ltByEl[el])
|
||||
if len(ltS) == 0 {
|
||||
ltS = map[string]any{
|
||||
"total_attacks": float64(0), "failed_attacks": float64(0), "crits": float64(0),
|
||||
"total_normal_damage": float64(0), "max_normal_damage": float64(0),
|
||||
"total_crit_damage": float64(0), "max_crit_damage": float64(0),
|
||||
}
|
||||
}
|
||||
ltByEl[el] = ltS
|
||||
ltS["total_attacks"] = num(ltS["total_attacks"]) + num(stats["total_attacks"])
|
||||
ltS["failed_attacks"] = num(ltS["failed_attacks"]) + num(stats["failed_attacks"])
|
||||
ltS["crits"] = num(ltS["crits"]) + num(stats["crits"])
|
||||
ltS["total_normal_damage"] = num(ltS["total_normal_damage"]) + num(stats["total_normal_damage"])
|
||||
ltS["max_normal_damage"] = maxF(num(ltS["max_normal_damage"]), num(stats["max_normal_damage"]))
|
||||
ltS["total_crit_damage"] = num(ltS["total_crit_damage"]) + num(stats["total_crit_damage"])
|
||||
ltS["max_crit_damage"] = maxF(num(ltS["max_crit_damage"]), num(stats["max_crit_damage"]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return lifetime
|
||||
}
|
||||
|
||||
func maxF(a, b float64) float64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// handleCombatStats mirrors main.py:3305: compute the session delta vs the last
|
||||
// snapshot, merge into the (DB-backed) lifetime, persist lifetime + the session
|
||||
// snapshot (delete-then-insert), and update the live overlay.
|
||||
func (i *Ingestor) handleCombatStats(ctx context.Context, data map[string]any) {
|
||||
char := toStr(data["character_name"])
|
||||
if char == "" {
|
||||
return
|
||||
}
|
||||
sessionV, hasSession := data["session"]
|
||||
sessionID := toStr(data["session_id"])
|
||||
|
||||
if hasSession && sessionV != nil {
|
||||
sessionData := asMap(sessionV)
|
||||
prevKey := char + ":" + sessionID
|
||||
|
||||
i.mu.Lock()
|
||||
prev, hadPrev := i.combatLastSession[prevKey]
|
||||
i.combatLastSession[prevKey] = sessionData
|
||||
i.mu.Unlock()
|
||||
|
||||
var delta map[string]any
|
||||
if hadPrev {
|
||||
delta = combatSessionDelta(sessionData, prev)
|
||||
} else {
|
||||
delta = sessionData
|
||||
}
|
||||
|
||||
// Load lifetime from cache, else DB (else empty).
|
||||
i.mu.Lock()
|
||||
lifetime, cached := i.combatLifetimeCache[char]
|
||||
i.mu.Unlock()
|
||||
if !cached {
|
||||
lifetime = map[string]any{}
|
||||
if row, err := queryRowAsMap(ctx, i.pool,
|
||||
`SELECT stats_data FROM combat_stats WHERE character_name=$1`, char); err == nil && row != nil {
|
||||
if m := asJSONMap(row["stats_data"]); m != nil {
|
||||
lifetime = m
|
||||
}
|
||||
}
|
||||
}
|
||||
lifetime = combatMergeIntoLifetime(lifetime, delta)
|
||||
i.mu.Lock()
|
||||
i.combatLifetimeCache[char] = lifetime
|
||||
i.mu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
ltJSON, _ := json.Marshal(lifetime)
|
||||
// delete-then-insert lifetime (no ON CONFLICT, matching Python)
|
||||
if _, err := i.pool.Exec(ctx, `DELETE FROM combat_stats WHERE character_name=$1`, char); err == nil {
|
||||
if _, err := i.pool.Exec(ctx,
|
||||
`INSERT INTO combat_stats (character_name,timestamp,stats_data) VALUES ($1,$2,$3)`,
|
||||
char, now, ltJSON); err != nil {
|
||||
i.log.Error("combat_stats insert failed", "err", err, "char", char)
|
||||
}
|
||||
}
|
||||
if sessionID != "" {
|
||||
sdJSON, _ := json.Marshal(sessionData)
|
||||
if _, err := i.pool.Exec(ctx,
|
||||
`DELETE FROM combat_stats_sessions WHERE character_name=$1 AND session_id=$2`, char, sessionID); err == nil {
|
||||
if _, err := i.pool.Exec(ctx,
|
||||
`INSERT INTO combat_stats_sessions (character_name,session_id,timestamp,stats_data) VALUES ($1,$2,$3,$4)`,
|
||||
char, sessionID, now, sdJSON); err != nil {
|
||||
i.log.Error("combat_stats_sessions insert failed", "err", err, "char", char)
|
||||
}
|
||||
}
|
||||
}
|
||||
data["lifetime"] = lifetime
|
||||
}
|
||||
|
||||
i.mu.Lock()
|
||||
i.liveCombatStats[char] = data
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// Golden values cross-checked against the Python _combat_session_delta /
|
||||
// _combat_merge_into_lifetime on identical input (see compare run). Folds two
|
||||
// cumulative snapshots; the first is treated as the whole delta.
|
||||
func TestCombatMerge(t *testing.T) {
|
||||
snap1 := map[string]any{
|
||||
"total_damage_given": 100.0, "total_kills": 2.0,
|
||||
"monsters": map[string]any{
|
||||
"Drudge": map[string]any{
|
||||
"name": "Drudge", "kill_count": 2.0, "damage_given": 100.0,
|
||||
"offense": map[string]any{"melee": map[string]any{"slashing": map[string]any{
|
||||
"total_attacks": 10.0, "max_normal_damage": 15.0,
|
||||
}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
snap2 := map[string]any{
|
||||
"total_damage_given": 250.0, "total_kills": 5.0,
|
||||
"monsters": map[string]any{
|
||||
"Drudge": map[string]any{
|
||||
"name": "Drudge", "kill_count": 4.0, "damage_given": 200.0,
|
||||
"offense": map[string]any{"melee": map[string]any{"slashing": map[string]any{
|
||||
"total_attacks": 20.0, "max_normal_damage": 18.0,
|
||||
}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
lifetime := map[string]any{}
|
||||
lifetime = combatMergeIntoLifetime(lifetime, snap1) // first = whole delta
|
||||
lifetime = combatMergeIntoLifetime(lifetime, combatSessionDelta(snap2, snap1))
|
||||
|
||||
if got := num(lifetime["total_kills"]); got != 5 {
|
||||
t.Errorf("total_kills = %v, want 5", got)
|
||||
}
|
||||
if got := num(lifetime["total_damage_given"]); got != 250 {
|
||||
t.Errorf("total_damage_given = %v, want 250", got)
|
||||
}
|
||||
drudge := asMap(asMap(lifetime["monsters"])["Drudge"])
|
||||
if got := num(drudge["kill_count"]); got != 4 {
|
||||
t.Errorf("Drudge.kill_count = %v, want 4", got)
|
||||
}
|
||||
slashing := asMap(asMap(asMap(drudge["offense"])["melee"])["slashing"])
|
||||
// offense uses the latest snapshot additively (the documented quirk): 10 + 20.
|
||||
if got := num(slashing["total_attacks"]); got != 30 {
|
||||
t.Errorf("offense slashing total_attacks = %v, want 30 (latest-additive quirk)", got)
|
||||
}
|
||||
if got := num(slashing["max_normal_damage"]); got != 18 {
|
||||
t.Errorf("offense slashing max_normal_damage = %v, want 18 (max)", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
module git.snakedesert.se/SawatoMosswartsEnjoyersClub/MosswartOverlord/go-services/tracker-go
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/jackc/pgx/v5 v5.10.0
|
||||
|
|
@ -1,519 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Ingestor implements the plugin event handlers (the /ws/position logic),
|
||||
// faithfully mirroring main.py's write semantics. It owns the in-memory live
|
||||
// state and writes to a read-write pool (its own DB in shadow/cutover mode).
|
||||
//
|
||||
// It is fed either by the real /ws/position server (cutover) or by the shadow
|
||||
// consumer replaying Python's /ws/live broadcast firehose. broadcast is invoked
|
||||
// after each handled event (nil = no browser fan-out, e.g. shadow mode).
|
||||
type Ingestor struct {
|
||||
pool *pgxpool.Pool
|
||||
log *slog.Logger
|
||||
broadcast func(map[string]any)
|
||||
|
||||
mu sync.RWMutex
|
||||
liveSnapshots map[string]map[string]any
|
||||
liveVitals map[string]map[string]any
|
||||
liveCharacterStats map[string]map[string]any
|
||||
liveEquipmentCantrip map[string]map[string]any
|
||||
liveNearbyObjects map[string]map[string]any
|
||||
liveCombatStats map[string]map[string]any
|
||||
dungeonMapCache map[string]map[string]any
|
||||
questStatus map[string]map[string]string
|
||||
lastKills map[string]int // "session_id|character_name" -> kills
|
||||
combatLastSession map[string]map[string]any // "char:session_id" -> last cumulative session
|
||||
combatLifetimeCache map[string]map[string]any // character_name -> accumulated lifetime
|
||||
vitalSubscribers map[string]bool
|
||||
vitalPeerState map[string]map[string]any
|
||||
|
||||
plugins *pluginRegistry // for share_* fan-out + plugin_connected status
|
||||
|
||||
invFwd *invForwarder // inventory forwarding (cutover only; nil in shadow/read)
|
||||
aclog *aclogPoster // death/idle Discord alerts (cutover only; nil otherwise)
|
||||
}
|
||||
|
||||
func newIngestor(pool *pgxpool.Pool, log *slog.Logger, broadcast func(map[string]any), plugins *pluginRegistry) *Ingestor {
|
||||
return &Ingestor{
|
||||
pool: pool,
|
||||
log: log,
|
||||
broadcast: broadcast,
|
||||
plugins: plugins,
|
||||
liveSnapshots: map[string]map[string]any{},
|
||||
liveVitals: map[string]map[string]any{},
|
||||
liveCharacterStats: map[string]map[string]any{},
|
||||
liveEquipmentCantrip: map[string]map[string]any{},
|
||||
liveNearbyObjects: map[string]map[string]any{},
|
||||
liveCombatStats: map[string]map[string]any{},
|
||||
dungeonMapCache: map[string]map[string]any{},
|
||||
questStatus: map[string]map[string]string{},
|
||||
lastKills: map[string]int{},
|
||||
combatLastSession: map[string]map[string]any{},
|
||||
combatLifetimeCache: map[string]map[string]any{},
|
||||
vitalSubscribers: map[string]bool{},
|
||||
vitalPeerState: map[string]map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch routes a parsed message to the right handler. Over /ws/position the
|
||||
// discriminator is the "type" field; over the /ws/live broadcast, telemetry has
|
||||
// NO type (it's the raw snapshot), so we also match it by shape.
|
||||
func (i *Ingestor) dispatch(ctx context.Context, data map[string]any) {
|
||||
t := toStr(data["type"])
|
||||
switch {
|
||||
case t == "telemetry" || (t == "" && hasTelemetryShape(data)):
|
||||
i.handleTelemetry(ctx, data)
|
||||
// Python broadcasts telemetry as a TYPELESS snapshot (snap.dict()); the
|
||||
// browser intentionally ignores typeless messages (useLiveData drops
|
||||
// `if (!msg.type) return`) and takes player data from the 5s /live poll
|
||||
// instead. Broadcasting it WITH a type makes the UI overwrite the
|
||||
// /live-derived telemetry (which has total_kills/total_rares/session_rares)
|
||||
// with the raw plugin payload (which lacks them), flapping those counters
|
||||
// 0<->value. Strip the type to match.
|
||||
if i.broadcast != nil {
|
||||
i.broadcast(stripType(data))
|
||||
}
|
||||
return
|
||||
case t == "rare":
|
||||
i.handleRare(ctx, data)
|
||||
case t == "portal":
|
||||
i.handlePortal(ctx, data)
|
||||
case t == "character_stats":
|
||||
i.handleCharacterStats(ctx, data)
|
||||
case t == "spawn":
|
||||
i.handleSpawn(ctx, data)
|
||||
case t == "vitals":
|
||||
i.handleVitals(data)
|
||||
case t == "quest":
|
||||
i.handleQuest(data)
|
||||
case t == "equipment_cantrip_state":
|
||||
i.handleEquipmentCantrip(data)
|
||||
case t == "nearby_objects":
|
||||
i.handleNearbyObjects(data)
|
||||
case t == "dungeon_map":
|
||||
i.handleDungeonMap(data)
|
||||
case t == "combat_stats":
|
||||
i.handleCombatStats(ctx, data)
|
||||
case t == "full_inventory":
|
||||
// Forward the full snapshot to the inventory service; not browser-broadcast.
|
||||
if i.invFwd != nil {
|
||||
i.invFwd.forwardFullInventory(data)
|
||||
}
|
||||
return
|
||||
case t == "inventory_delta":
|
||||
// Fire-and-forget forward; the forwarder broadcasts the enriched delta.
|
||||
if i.invFwd != nil {
|
||||
i.invFwd.handleInventoryDelta(data)
|
||||
}
|
||||
return
|
||||
case t == "share_subscribe":
|
||||
i.handleShareSubscribe(data)
|
||||
case t == "share_unsubscribe":
|
||||
i.handleShareUnsubscribe(data)
|
||||
return // unsubscribe broadcasts its own share_peer_removed; don't re-broadcast
|
||||
case strings.HasPrefix(t, "share_"):
|
||||
i.handleShareUpdate(t, data)
|
||||
case t == "register":
|
||||
// no DB / no broadcast; plugin_conns belongs to the /ws/position server
|
||||
case t == "chat":
|
||||
// broadcast-only
|
||||
}
|
||||
if i.broadcast != nil {
|
||||
i.broadcast(data)
|
||||
}
|
||||
}
|
||||
|
||||
// stripType returns a shallow copy of the message without its "type" key, so the
|
||||
// browser treats it as a typeless snapshot (and ignores it, deferring to /live).
|
||||
func stripType(data map[string]any) map[string]any {
|
||||
cp := make(map[string]any, len(data))
|
||||
for k, v := range data {
|
||||
if k != "type" {
|
||||
cp[k] = v
|
||||
}
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
func hasTelemetryShape(d map[string]any) bool {
|
||||
_, a := d["session_id"]
|
||||
_, b := d["ew"]
|
||||
_, c := d["kills"]
|
||||
return a && b && c
|
||||
}
|
||||
|
||||
// --- telemetry: INSERT telemetry_events + kill-delta into char_stats (main.py:3124) ---
|
||||
|
||||
const insTelemetry = `INSERT INTO telemetry_events
|
||||
(character_name,char_tag,session_id,timestamp,ew,ns,z,kills,kills_per_hour,onlinetime,
|
||||
deaths,total_deaths,rares_found,prismatic_taper_count,vt_state,mem_mb,cpu_pct,mem_handles,latency_ms,received_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,0,$13,$14,$15,$16,$17,$18,$19)`
|
||||
|
||||
const upsertCharKills = `INSERT INTO char_stats (character_name,total_kills) VALUES ($1,$2)
|
||||
ON CONFLICT (character_name) DO UPDATE SET total_kills = char_stats.total_kills + $2`
|
||||
|
||||
func (i *Ingestor) handleTelemetry(ctx context.Context, data map[string]any) {
|
||||
name := toStr(data["character_name"])
|
||||
sessionID := toStr(data["session_id"])
|
||||
if name == "" || sessionID == "" {
|
||||
return
|
||||
}
|
||||
kills := toInt(data["kills"])
|
||||
received := time.Now().UTC()
|
||||
|
||||
key := sessionID + "|" + name
|
||||
i.mu.RLock()
|
||||
last, ok := i.lastKills[key]
|
||||
i.mu.RUnlock()
|
||||
if !ok {
|
||||
if row, err := queryRowAsMap(ctx, i.pool,
|
||||
`SELECT kills FROM telemetry_events WHERE character_name=$1 AND session_id=$2 ORDER BY timestamp DESC LIMIT 1`,
|
||||
name, sessionID); err == nil && row != nil {
|
||||
last = toInt(row["kills"])
|
||||
}
|
||||
}
|
||||
delta := kills - last
|
||||
|
||||
tx, err := i.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
i.log.Error("telemetry tx begin failed", "err", err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, insTelemetry,
|
||||
name, nstr(data["char_tag"]), sessionID, parseTSAny(data["timestamp"]),
|
||||
toFloat(data["ew"]), toFloat(data["ns"]), toFloat(data["z"]), kills,
|
||||
nfloat(data["kills_per_hour"]), nstr(data["onlinetime"]), toInt(data["deaths"]),
|
||||
nint(data["total_deaths"]), toInt(data["prismatic_taper_count"]), nstr(data["vt_state"]),
|
||||
nfloat(data["mem_mb"]), nfloat(data["cpu_pct"]), nint(data["mem_handles"]),
|
||||
nfloat(data["latency_ms"]), received,
|
||||
); err != nil {
|
||||
i.log.Error("telemetry insert failed", "err", err, "char", name)
|
||||
return
|
||||
}
|
||||
if delta > 0 {
|
||||
if _, err := tx.Exec(ctx, upsertCharKills, name, delta); err != nil {
|
||||
i.log.Error("char_stats upsert failed", "err", err, "char", name)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
i.log.Error("telemetry commit failed", "err", err, "char", name)
|
||||
return
|
||||
}
|
||||
|
||||
i.mu.Lock()
|
||||
i.lastKills[key] = kills
|
||||
i.liveSnapshots[name] = data
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- rare: rare_stats + rare_stats_sessions + rare_events (main.py:3234) ---
|
||||
|
||||
const upsertRareStats = `INSERT INTO rare_stats (character_name,total_rares) VALUES ($1,1)
|
||||
ON CONFLICT (character_name) DO UPDATE SET total_rares = rare_stats.total_rares + 1`
|
||||
const upsertRareSession = `INSERT INTO rare_stats_sessions (character_name,session_id,session_rares) VALUES ($1,$2,1)
|
||||
ON CONFLICT (character_name,session_id) DO UPDATE SET session_rares = rare_stats_sessions.session_rares + 1`
|
||||
const insRareEvent = `INSERT INTO rare_events (character_name,name,timestamp,ew,ns,z) VALUES ($1,$2,$3,$4,$5,$6)`
|
||||
|
||||
func (i *Ingestor) handleRare(ctx context.Context, data map[string]any) {
|
||||
name := toStr(data["character_name"])
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return
|
||||
}
|
||||
if _, err := i.pool.Exec(ctx, upsertRareStats, name); err != nil {
|
||||
i.log.Error("rare_stats upsert failed", "err", err, "char", name)
|
||||
return
|
||||
}
|
||||
// Session id: live snapshot first, else latest telemetry row.
|
||||
i.mu.RLock()
|
||||
sessionID := toStr(i.liveSnapshots[name]["session_id"])
|
||||
i.mu.RUnlock()
|
||||
if sessionID == "" {
|
||||
if row, err := queryRowAsMap(ctx, i.pool,
|
||||
`SELECT session_id FROM telemetry_events WHERE character_name=$1 ORDER BY timestamp DESC LIMIT 1`, name); err == nil && row != nil {
|
||||
sessionID = toStr(row["session_id"])
|
||||
}
|
||||
}
|
||||
if sessionID != "" {
|
||||
if _, err := i.pool.Exec(ctx, upsertRareSession, name, sessionID); err != nil {
|
||||
i.log.Error("rare_stats_sessions upsert failed", "err", err, "char", name)
|
||||
}
|
||||
}
|
||||
if _, err := i.pool.Exec(ctx, insRareEvent,
|
||||
name, toStr(data["name"]), parseTSAny(data["timestamp"]),
|
||||
toFloat(data["ew"]), toFloat(data["ns"]), toFloatOr(data["z"], 0),
|
||||
); err != nil {
|
||||
i.log.Error("rare_events insert failed", "err", err, "char", name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- portal: upsert on rounded coords (main.py:3567) ---
|
||||
|
||||
const upsertPortal = `INSERT INTO portals (portal_name,ns,ew,z,discovered_at,discovered_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
ON CONFLICT (ROUND(ns::numeric,1), ROUND(ew::numeric,1)) DO UPDATE SET
|
||||
discovered_at = EXCLUDED.discovered_at,
|
||||
discovered_by = EXCLUDED.discovered_by,
|
||||
portal_name = EXCLUDED.portal_name`
|
||||
|
||||
func (i *Ingestor) handlePortal(ctx context.Context, data map[string]any) {
|
||||
name := toStr(data["character_name"])
|
||||
portalName := toStr(data["portal_name"])
|
||||
ts := data["timestamp"]
|
||||
if name == "" || portalName == "" || data["ns"] == nil || data["ew"] == nil || data["z"] == nil || ts == nil {
|
||||
return
|
||||
}
|
||||
if _, err := i.pool.Exec(ctx, upsertPortal,
|
||||
portalName, toFloat(data["ns"]), toFloat(data["ew"]), toFloat(data["z"]),
|
||||
parseTSAny(ts), name,
|
||||
); err != nil {
|
||||
i.log.Error("portal upsert failed", "err", err, "char", name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- character_stats: build stats_data subset + upsert (main.py:3443) ---
|
||||
|
||||
const upsertCharacterStats = `INSERT INTO character_stats
|
||||
(character_name,timestamp,level,total_xp,unassigned_xp,luminance_earned,luminance_total,deaths,stats_data)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
ON CONFLICT (character_name) DO UPDATE SET
|
||||
timestamp=EXCLUDED.timestamp, level=EXCLUDED.level, total_xp=EXCLUDED.total_xp,
|
||||
unassigned_xp=EXCLUDED.unassigned_xp, luminance_earned=EXCLUDED.luminance_earned,
|
||||
luminance_total=EXCLUDED.luminance_total, deaths=EXCLUDED.deaths, stats_data=EXCLUDED.stats_data`
|
||||
|
||||
var statsDataKeys = []string{
|
||||
"attributes", "vitals", "skills", "allegiance", "active_item_enchantments",
|
||||
"race", "gender", "birth", "current_title", "skill_credits", "burden",
|
||||
"burden_units", "encumbrance_capacity", "properties", "titles",
|
||||
}
|
||||
|
||||
func (i *Ingestor) handleCharacterStats(ctx context.Context, data map[string]any) {
|
||||
name := toStr(data["character_name"])
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
statsData := map[string]any{}
|
||||
for _, k := range statsDataKeys {
|
||||
if v, ok := data[k]; ok && v != nil {
|
||||
statsData[k] = v
|
||||
}
|
||||
}
|
||||
sdJSON, _ := json.Marshal(statsData)
|
||||
if _, err := i.pool.Exec(ctx, upsertCharacterStats,
|
||||
name, parseTSAny(data["timestamp"]), nint(data["level"]), nint(data["total_xp"]),
|
||||
nint(data["unassigned_xp"]), nint(data["luminance_earned"]), nint(data["luminance_total"]),
|
||||
nint(data["deaths"]), sdJSON,
|
||||
); err != nil {
|
||||
i.log.Error("character_stats upsert failed", "err", err, "char", name)
|
||||
return
|
||||
}
|
||||
i.mu.Lock()
|
||||
i.liveCharacterStats[name] = data
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- spawn: INSERT spawn_events (main.py:3110). Not broadcast, so only the real
|
||||
// /ws/position path feeds this; covered by ingest_test.go. ---
|
||||
|
||||
const insSpawn = `INSERT INTO spawn_events (character_name,mob,timestamp,ew,ns,z) VALUES ($1,$2,$3,$4,$5,$6)`
|
||||
|
||||
func (i *Ingestor) handleSpawn(ctx context.Context, data map[string]any) {
|
||||
name := toStr(data["character_name"])
|
||||
mob := toStr(data["mob"])
|
||||
if name == "" || mob == "" {
|
||||
return
|
||||
}
|
||||
if _, err := i.pool.Exec(ctx, insSpawn,
|
||||
name, mob, parseTSAny(data["timestamp"]),
|
||||
toFloat(data["ew"]), toFloat(data["ns"]), toFloatOr(data["z"], 0),
|
||||
); err != nil {
|
||||
i.log.Error("spawn insert failed", "err", err, "char", name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- memory-only handlers ---
|
||||
|
||||
func (i *Ingestor) handleVitals(data map[string]any) {
|
||||
name := toStr(data["character_name"])
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
// Death detection (main.py:3419): vitae crossing 0 -> >0. Only in cutover
|
||||
// (i.aclog != nil); in shadow mode it stays off to avoid duplicating the
|
||||
// production alert.
|
||||
if i.aclog != nil {
|
||||
i.mu.RLock()
|
||||
prev := i.liveVitals[name]
|
||||
i.mu.RUnlock()
|
||||
var prevVitae float64
|
||||
if prev != nil {
|
||||
prevVitae = toFloat(prev["vitae"])
|
||||
}
|
||||
if newVitae := toFloat(data["vitae"]); prevVitae == 0 && newVitae > 0 {
|
||||
i.aclog.maybeDeath(name, newVitae)
|
||||
}
|
||||
}
|
||||
i.mu.Lock()
|
||||
i.liveVitals[name] = data
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
var allowedQuests = map[string]bool{
|
||||
"Stipend Collection Timer": true,
|
||||
"Blank Augmentation Gem Pickup Timer": true,
|
||||
"Insatiable Eater Jaw": true,
|
||||
}
|
||||
|
||||
func (i *Ingestor) handleQuest(data map[string]any) {
|
||||
name := toStr(data["character_name"])
|
||||
quest := toStr(data["quest_name"])
|
||||
countdown, ok := data["countdown"]
|
||||
if name == "" || quest == "" || !ok || countdown == nil || !allowedQuests[quest] {
|
||||
return
|
||||
}
|
||||
i.mu.Lock()
|
||||
if i.questStatus[name] == nil {
|
||||
i.questStatus[name] = map[string]string{}
|
||||
}
|
||||
i.questStatus[name][quest] = toStr(countdown)
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
func (i *Ingestor) handleEquipmentCantrip(data map[string]any) {
|
||||
name := toStr(data["character_name"])
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
i.mu.Lock()
|
||||
i.liveEquipmentCantrip[name] = data
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
// clearEquipmentCantrip drops a character's cantrip overlay on plugin register
|
||||
// (main.py:3106).
|
||||
func (i *Ingestor) clearEquipmentCantrip(name string) {
|
||||
i.mu.Lock()
|
||||
delete(i.liveEquipmentCantrip, name)
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
func (i *Ingestor) handleNearbyObjects(data map[string]any) {
|
||||
name := toStr(data["character_name"])
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
i.mu.Lock()
|
||||
i.liveNearbyObjects[name] = data
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
func (i *Ingestor) handleDungeonMap(data map[string]any) {
|
||||
lb := toStr(data["landblock"])
|
||||
if lb == "" {
|
||||
return
|
||||
}
|
||||
i.mu.Lock()
|
||||
i.dungeonMapCache[lb] = data
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- read-side overlay accessors (used by the HTTP handlers when an ingestor
|
||||
// is present, mirroring Python's "live cache first, DB fallback") ---
|
||||
|
||||
func (i *Ingestor) snapshot(m map[string]map[string]any, name string) (map[string]any, bool) {
|
||||
i.mu.RLock()
|
||||
defer i.mu.RUnlock()
|
||||
v, ok := m[name]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (i *Ingestor) getCharacterStats(name string) (map[string]any, bool) {
|
||||
return i.snapshot(i.liveCharacterStats, name)
|
||||
}
|
||||
func (i *Ingestor) getEquipmentCantrip(name string) (map[string]any, bool) {
|
||||
return i.snapshot(i.liveEquipmentCantrip, name)
|
||||
}
|
||||
func (i *Ingestor) getCombatStats(name string) (map[string]any, bool) {
|
||||
return i.snapshot(i.liveCombatStats, name)
|
||||
}
|
||||
func (i *Ingestor) allCombatStats() map[string]map[string]any {
|
||||
i.mu.RLock()
|
||||
defer i.mu.RUnlock()
|
||||
out := make(map[string]map[string]any, len(i.liveCombatStats))
|
||||
for k, v := range i.liveCombatStats {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (i *Ingestor) questData() (map[string]map[string]string, int) {
|
||||
i.mu.RLock()
|
||||
defer i.mu.RUnlock()
|
||||
out := make(map[string]map[string]string, len(i.questStatus))
|
||||
for c, qs := range i.questStatus {
|
||||
cp := make(map[string]string, len(qs))
|
||||
for k, v := range qs {
|
||||
cp[k] = v
|
||||
}
|
||||
out[c] = cp
|
||||
}
|
||||
return out, len(i.questStatus)
|
||||
}
|
||||
|
||||
// --- small value helpers (JSON numbers decode as float64) ---
|
||||
|
||||
func nstr(v any) any {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// nint/nfloat return a typed number or nil (for nullable columns), coercing
|
||||
// string-encoded numbers the plugin sends (see coerceNum).
|
||||
func nint(v any) any {
|
||||
if f, ok := coerceNum(v); ok {
|
||||
return int64(f)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func nfloat(v any) any {
|
||||
if f, ok := coerceNum(v); ok {
|
||||
return f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func toFloatOr(v any, def float64) float64 {
|
||||
if f, ok := coerceNum(v); ok {
|
||||
return f
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func parseTSAny(v any) time.Time {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
s = strings.Replace(s, "Z", "+00:00", 1)
|
||||
for _, l := range []string{
|
||||
time.RFC3339Nano, time.RFC3339,
|
||||
"2006-01-02T15:04:05.999999-07:00", "2006-01-02T15:04:05-07:00",
|
||||
"2006-01-02T15:04:05.999999", "2006-01-02T15:04:05",
|
||||
} {
|
||||
if t, err := time.Parse(l, s); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// invForwarder forwards plugin inventory events to the inventory service,
|
||||
// porting main.py's _forward_to_inventory_service / _handle_inventory_delta.
|
||||
// Only active in cutover (write) mode; nil in shadow/read-only mode, where the
|
||||
// plugin firehose never carries inventory anyway.
|
||||
//
|
||||
// full_inventory -> POST {url}/process-inventory (full replace)
|
||||
// inventory_delta add/update -> POST {url}/inventory/{char}/item
|
||||
// inventory_delta remove -> DELETE {url}/inventory/{char}/item/{item_id}
|
||||
//
|
||||
// Deltas are fire-and-forget (never block the /ws/position read loop), serialized
|
||||
// per-character (so a char's rapid deltas don't race the inventory DELETE+INSERT),
|
||||
// and globally capped at 8 concurrent forwards.
|
||||
type invForwarder struct {
|
||||
url string
|
||||
client *http.Client
|
||||
sem chan struct{}
|
||||
mu sync.Mutex
|
||||
locks map[string]*sync.Mutex
|
||||
log *slog.Logger
|
||||
broadcast func(map[string]any)
|
||||
}
|
||||
|
||||
func newInvForwarder(rawURL string, log *slog.Logger, broadcast func(map[string]any)) *invForwarder {
|
||||
return &invForwarder{
|
||||
url: strings.TrimRight(rawURL, "/"),
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
sem: make(chan struct{}, 8),
|
||||
locks: map[string]*sync.Mutex{},
|
||||
log: log,
|
||||
broadcast: broadcast,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *invForwarder) charLock(name string) *sync.Mutex {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
l := f.locks[name]
|
||||
if l == nil {
|
||||
l = &sync.Mutex{}
|
||||
f.locks[name] = l
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// forwardFullInventory POSTs a full inventory snapshot (full replace). Runs
|
||||
// inline on the /ws/position handler — main.py awaits _store_inventory too.
|
||||
func (f *invForwarder) forwardFullInventory(data map[string]any) {
|
||||
char := toStr(data["character_name"])
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"character_name": char,
|
||||
"timestamp": data["timestamp"],
|
||||
"items": data["items"],
|
||||
})
|
||||
resp, err := f.client.Post(f.url+"/process-inventory", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
f.log.Error("full_inventory forward failed", "err", err, "char", char)
|
||||
return
|
||||
}
|
||||
defer drain(resp)
|
||||
if resp.StatusCode >= 400 {
|
||||
f.log.Warn("inventory service error (full_inventory)", "status", resp.StatusCode, "char", char)
|
||||
}
|
||||
}
|
||||
|
||||
// handleInventoryDelta forwards a single add/update/remove. Fire-and-forget.
|
||||
func (f *invForwarder) handleInventoryDelta(data map[string]any) {
|
||||
go func() {
|
||||
char := toStr(data["character_name"])
|
||||
lock := f.charLock(char)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
f.sem <- struct{}{}
|
||||
defer func() { <-f.sem }()
|
||||
|
||||
out := data
|
||||
switch toStr(data["action"]) {
|
||||
case "remove":
|
||||
if itemID := data["item_id"]; itemID != nil {
|
||||
req, _ := http.NewRequest(http.MethodDelete,
|
||||
fmt.Sprintf("%s/inventory/%s/item/%v", f.url, url.PathEscape(char), itemID), nil)
|
||||
if resp, err := f.client.Do(req); err != nil {
|
||||
f.log.Warn("inventory delta remove failed", "err", err, "char", char)
|
||||
} else {
|
||||
if resp.StatusCode >= 400 {
|
||||
f.log.Warn("inventory service error (delta remove)", "status", resp.StatusCode, "char", char)
|
||||
}
|
||||
drain(resp)
|
||||
}
|
||||
}
|
||||
case "add", "update":
|
||||
if item := data["item"]; item != nil {
|
||||
b, _ := json.Marshal(item)
|
||||
resp, err := f.client.Post(fmt.Sprintf("%s/inventory/%s/item", f.url, url.PathEscape(char)),
|
||||
"application/json", bytes.NewReader(b))
|
||||
if err != nil {
|
||||
f.log.Warn("inventory delta add/update failed", "err", err, "char", char)
|
||||
} else {
|
||||
if resp.StatusCode < 400 {
|
||||
// Re-broadcast the enriched item the service returns.
|
||||
var r map[string]any
|
||||
if json.NewDecoder(resp.Body).Decode(&r) == nil {
|
||||
if enriched, ok := r["item"]; ok && enriched != nil {
|
||||
out = map[string]any{
|
||||
"type": "inventory_delta",
|
||||
"action": toStr(data["action"]),
|
||||
"character_name": char,
|
||||
"item": enriched,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
f.log.Warn("inventory service error (delta add/update)", "status", resp.StatusCode, "char", char)
|
||||
}
|
||||
drain(resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.broadcast != nil {
|
||||
f.broadcast(out)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func drain(resp *http.Response) {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Timing constants mirror main.py.
|
||||
const (
|
||||
activeWindow = 30 * time.Second // ACTIVE_WINDOW — the real "online" test
|
||||
chunkLookback = 10 * time.Minute // coarse bound, only so TimescaleDB can prune chunks
|
||||
trailsWindow = 600 * time.Second // /trails lookback (hardcoded; the `seconds` param is ignored)
|
||||
cacheInterval = 5 * time.Second // _refresh_cache_loop cadence
|
||||
)
|
||||
|
||||
// liveSQL mirrors main.py:837 exactly. $1 = chunk_cutoff (now-10min), $2 = cutoff (now-30s).
|
||||
// Online-ness is decided on COALESCE(received_at, timestamp) — server receive-time — because
|
||||
// game clients' clocks drift up to ~90s and would otherwise flap the player count.
|
||||
const liveSQL = `
|
||||
SELECT sub.*,
|
||||
COALESCE(rs.total_rares, 0) AS total_rares,
|
||||
COALESCE(rss.session_rares, 0) AS session_rares,
|
||||
COALESCE(cs.total_kills, 0) AS total_kills
|
||||
FROM (
|
||||
SELECT DISTINCT ON (character_name) *
|
||||
FROM telemetry_events
|
||||
WHERE timestamp > $1
|
||||
AND COALESCE(received_at, timestamp) > $2
|
||||
ORDER BY character_name, timestamp DESC
|
||||
) sub
|
||||
LEFT JOIN rare_stats rs ON sub.character_name = rs.character_name
|
||||
LEFT JOIN rare_stats_sessions rss ON sub.character_name = rss.character_name
|
||||
AND sub.session_id = rss.session_id
|
||||
LEFT JOIN char_stats cs ON sub.character_name = cs.character_name`
|
||||
|
||||
// trailsSQL mirrors main.py:874 — last 600s of position points, ordered for the map.
|
||||
const trailsSQL = `
|
||||
SELECT timestamp, character_name, ew, ns, z
|
||||
FROM telemetry_events
|
||||
WHERE timestamp >= $1
|
||||
ORDER BY character_name, timestamp`
|
||||
|
||||
// liveCache holds the pre-marshaled JSON bodies for /live and /trails, swapped
|
||||
// atomically every cacheInterval by the refresh loop.
|
||||
type liveCache struct {
|
||||
mu sync.RWMutex
|
||||
liveJSON []byte
|
||||
trailsJSON []byte
|
||||
}
|
||||
|
||||
func newLiveCache() *liveCache {
|
||||
return &liveCache{
|
||||
liveJSON: []byte(`{"players":[]}`),
|
||||
trailsJSON: []byte(`{"trails":[]}`),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *liveCache) getLive() []byte {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.liveJSON
|
||||
}
|
||||
|
||||
func (c *liveCache) getTrails() []byte {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.trailsJSON
|
||||
}
|
||||
|
||||
func (c *liveCache) set(live, trails []byte) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.liveJSON = live
|
||||
c.trailsJSON = trails
|
||||
}
|
||||
|
||||
// refresh recomputes both caches from the DB. Both queries use the SAME `now`
|
||||
// so the online window and trails window are consistent within a tick.
|
||||
func (s *Server) refreshLiveCache(ctx context.Context) error {
|
||||
qctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
players, err := queryRowsAsMaps(qctx, s.pool, liveSQL, now.Add(-chunkLookback), now.Add(-activeWindow))
|
||||
if err != nil {
|
||||
return fmt.Errorf("live query: %w", err)
|
||||
}
|
||||
formatTimes(players, "timestamp", "received_at")
|
||||
liveJSON, err := json.Marshal(map[string]any{"players": players})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal live: %w", err)
|
||||
}
|
||||
|
||||
trails, err := queryRowsAsMaps(qctx, s.pool, trailsSQL, now.Add(-trailsWindow))
|
||||
if err != nil {
|
||||
return fmt.Errorf("trails query: %w", err)
|
||||
}
|
||||
formatTimes(trails, "timestamp")
|
||||
trailsJSON, err := json.Marshal(map[string]any{"trails": trails})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal trails: %w", err)
|
||||
}
|
||||
|
||||
s.cache.set(liveJSON, trailsJSON)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCacheLoop refreshes the cache every cacheInterval until ctx is cancelled.
|
||||
// It refreshes immediately on entry (refresh-then-sleep) so the cache is warm
|
||||
// shortly after startup. pgxpool handles reconnection transparently, so we just
|
||||
// log failures and keep serving the last good snapshot.
|
||||
func (s *Server) runCacheLoop(ctx context.Context) {
|
||||
failures := 0
|
||||
for {
|
||||
if err := s.refreshLiveCache(ctx); err != nil {
|
||||
failures++
|
||||
s.log.Error("live cache refresh failed", "err", err, "consecutive", failures)
|
||||
} else {
|
||||
if failures > 0 {
|
||||
s.log.Info("live cache refresh recovered", "after_failures", failures)
|
||||
}
|
||||
failures = 0
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(cacheInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleLive(w http.ResponseWriter, r *http.Request) {
|
||||
writeRawJSON(w, s.cache.getLive())
|
||||
}
|
||||
|
||||
func (s *Server) handleTrails(w http.ResponseWriter, r *http.Request) {
|
||||
// `seconds` query param is accepted but ignored, matching main.py:2001.
|
||||
writeRawJSON(w, s.cache.getTrails())
|
||||
}
|
||||
|
||||
func writeRawJSON(w http.ResponseWriter, body []byte) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
|
@ -1,337 +0,0 @@
|
|||
// Command tracker-go is a Go reimplementation of the MosswartOverlord
|
||||
// "dereth-tracker" backend, deployed in parallel with the live Python service
|
||||
// for side-by-side comparison (strangler-fig migration).
|
||||
//
|
||||
// Phase 1: read-side parity. Connects READ-ONLY to the existing dereth
|
||||
// TimescaleDB and reimplements the HTTP read API, starting with the /live and
|
||||
// /trails caches (the 5s _refresh_cache_loop). It never touches anything the
|
||||
// Python service writes.
|
||||
//
|
||||
// Routes are declared WITHOUT the nginx-stripped "/go/" prefix, mirroring the
|
||||
// Python service's "no /api/ prefix" convention. nginx's `location /go/` strips
|
||||
// the prefix before proxying to this service on 127.0.0.1:8770.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// buildVersion is injected at build time via -ldflags "-X main.buildVersion=...".
|
||||
// Mirrors the Python service's APP_VERSION / "/api-version" stamp.
|
||||
var buildVersion = "dev"
|
||||
|
||||
// Server holds the shared dependencies for HTTP handlers.
|
||||
type Server struct {
|
||||
pool *pgxpool.Pool
|
||||
cache *liveCache
|
||||
totals *totalsCache
|
||||
invProxy *httputil.ReverseProxy
|
||||
staticDir string
|
||||
secretKey string
|
||||
sharedSecret string
|
||||
sharedSecretLegacy string
|
||||
ingestor *Ingestor // non-nil only in ingest/shadow mode
|
||||
hub *Hub // browser /ws/live fan-out
|
||||
plugins *pluginRegistry
|
||||
loginLimiter *loginLimiter
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func main() {
|
||||
// `tracker-go combat-merge` reads a JSON array of cumulative session
|
||||
// snapshots from stdin and prints the folded lifetime — a deterministic hook
|
||||
// for cross-language parity testing against the Python combat functions.
|
||||
if len(os.Args) > 1 && os.Args[1] == "combat-merge" {
|
||||
runCombatMergeCLI()
|
||||
return
|
||||
}
|
||||
// `tracker-go issue-cookie <username> <is_admin> <secret_key>` prints a
|
||||
// session token — a hook to cross-check itsdangerous cookie interop with the
|
||||
// Python service.
|
||||
if len(os.Args) > 1 && os.Args[1] == "issue-cookie" {
|
||||
runIssueCookieCLI()
|
||||
return
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
cfg := loadConfig()
|
||||
logger.Info("starting tracker-go", "version", buildVersion, "addr", cfg.Addr)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
srv := &Server{
|
||||
cache: newLiveCache(),
|
||||
totals: newTotalsCache(),
|
||||
loginLimiter: newLoginLimiter(),
|
||||
staticDir: cfg.StaticDir,
|
||||
secretKey: cfg.SecretKey,
|
||||
sharedSecret: cfg.SharedSecret,
|
||||
sharedSecretLegacy: cfg.SharedSecretLegacy,
|
||||
hub: newHub(),
|
||||
plugins: newPluginRegistry(logger),
|
||||
log: logger,
|
||||
}
|
||||
if cfg.SecretKey == "" {
|
||||
// Fail closed like the Python service: with no key, no external cookie
|
||||
// can verify, so only internal-trust (loopback/compose) requests pass.
|
||||
logger.Warn("SECRET_KEY unset — external (nginx-proxied) requests will all be rejected")
|
||||
}
|
||||
|
||||
// Inventory-service reverse proxy (independent of the DB).
|
||||
if err := srv.initInvProxy(cfg.InventoryURL); err != nil {
|
||||
logger.Error("inventory proxy init failed", "err", err, "target", cfg.InventoryURL)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Connect to the dereth DB (read-only). If DATABASE_URL is unset we still
|
||||
// serve health/version (Phase-0 mode) so the container is observable.
|
||||
if cfg.DatabaseURL == "" {
|
||||
logger.Warn("DATABASE_URL unset — running without DB; DB-backed endpoints will be empty")
|
||||
} else {
|
||||
connectCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
pool, err := newPool(connectCtx, cfg.DatabaseURL, cfg.ReadOnly)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Error("db pool init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
srv.pool = pool
|
||||
|
||||
// Write mode (shadow OR cutover) owns the ingest path; read-only mode
|
||||
// (parallel read API) skips all of this.
|
||||
if !cfg.ReadOnly {
|
||||
// Schema init only when we own a fresh DB. In cutover (reusing the
|
||||
// production DB) SKIP_SCHEMA_INIT keeps us from running ANY DDL.
|
||||
if !cfg.SkipSchemaInit {
|
||||
schemaCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
initSchema(schemaCtx, pool, logger)
|
||||
cancel()
|
||||
}
|
||||
|
||||
srv.ingestor = newIngestor(pool, logger, srv.hub.broadcast, srv.plugins)
|
||||
|
||||
if cfg.IngestWS != "" {
|
||||
// Shadow: replay the Python /ws/live firehose. Inventory forwarding
|
||||
// + Discord alerts stay OFF (would double production writes/alerts;
|
||||
// inventory isn't in the firehose anyway).
|
||||
go srv.runShadowConsumer(ctx, cfg.IngestWS)
|
||||
logger.Info("shadow ingest enabled", "source", cfg.IngestWS)
|
||||
} else {
|
||||
// Cutover: the real plugin connects to /ws/position. Forward
|
||||
// inventory to the inventory service and post death/idle alerts.
|
||||
srv.ingestor.invFwd = newInvForwarder(cfg.InventoryURL, logger, srv.hub.broadcast)
|
||||
if cfg.DiscordACLog != "" {
|
||||
srv.ingestor.aclog = newACLogPoster(cfg.DiscordACLog, logger)
|
||||
go srv.ingestor.aclog.runIdleLoop(ctx, pool)
|
||||
}
|
||||
logger.Info("cutover ingest enabled", "inventory_url", cfg.InventoryURL, "aclog", cfg.DiscordACLog != "")
|
||||
}
|
||||
} else if cfg.IngestWS != "" {
|
||||
logger.Error("SHADOW_INGEST_WS set but READ_ONLY=true; refusing to ingest into the production DB")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
go srv.runCacheLoop(ctx)
|
||||
go srv.runTotalsLoop(ctx)
|
||||
logger.Info("db connected; cache loops started",
|
||||
"read_only", cfg.ReadOnly, "live_interval", cacheInterval.String(), "totals_interval", totalsInterval.String())
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
srv.registerRoutes(mux)
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: withRequestLogging(srv.authMiddleware(mux)),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("http server failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
logger.Info("listening", "addr", cfg.Addr)
|
||||
|
||||
<-ctx.Done()
|
||||
logger.Info("shutdown signal received, draining")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("graceful shutdown failed", "err", err)
|
||||
}
|
||||
logger.Info("stopped")
|
||||
}
|
||||
|
||||
// config holds runtime configuration sourced from environment variables,
|
||||
// matching the Python service's env var names where they overlap.
|
||||
type config struct {
|
||||
Addr string // listen address, e.g. ":8770"
|
||||
DatabaseURL string // dereth TimescaleDB DSN
|
||||
ReadOnly bool // true = read-side parity (force read-only txns); false = ingest/shadow (owns its DB)
|
||||
InventoryURL string // inventory-service base URL
|
||||
StaticDir string // directory for static assets / openissues.json
|
||||
SecretKey string // session-cookie signing key (must match the Python service)
|
||||
SharedSecret string // plugin /ws/position auth
|
||||
SharedSecretLegacy string // plugin auth rotation fallback
|
||||
IngestWS string // optional: a /ws/live URL to shadow-ingest from (Python tracker)
|
||||
SkipSchemaInit bool // cutover: trust the existing prod schema, run no DDL
|
||||
DiscordACLog string // #aclog webhook for death/idle alerts (cutover only)
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
return config{
|
||||
Addr: ":" + envOr("PORT", "8770"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
ReadOnly: envOr("READ_ONLY", "true") != "false",
|
||||
InventoryURL: envOr("INVENTORY_SERVICE_URL", "http://inventory-service:8000"),
|
||||
StaticDir: envOr("STATIC_DIR", "static"),
|
||||
SecretKey: os.Getenv("SECRET_KEY"),
|
||||
SharedSecret: os.Getenv("SHARED_SECRET"),
|
||||
SharedSecretLegacy: os.Getenv("SHARED_SECRET_LEGACY"),
|
||||
IngestWS: os.Getenv("SHADOW_INGEST_WS"),
|
||||
SkipSchemaInit: envOr("SKIP_SCHEMA_INIT", "false") == "true",
|
||||
DiscordACLog: os.Getenv("DISCORD_ACLOG_WEBHOOK"),
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (s *Server) registerRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
// Mirrors Python's GET /api-version (hyphenated so nginx never strips it).
|
||||
mux.HandleFunc("GET /api-version", s.handleVersion)
|
||||
|
||||
// Phase 1 read-side: the 5s caches.
|
||||
mux.HandleFunc("GET /live", s.handleLive)
|
||||
mux.HandleFunc("GET /live/", s.handleLive)
|
||||
mux.HandleFunc("GET /trails", s.handleTrails)
|
||||
mux.HandleFunc("GET /trails/", s.handleTrails)
|
||||
|
||||
// Totals (5-minute caches).
|
||||
mux.HandleFunc("GET /total-rares", s.handleTotalRares)
|
||||
mux.HandleFunc("GET /total-rares/", s.handleTotalRares)
|
||||
mux.HandleFunc("GET /total-kills", s.handleTotalKills)
|
||||
mux.HandleFunc("GET /total-kills/", s.handleTotalKills)
|
||||
|
||||
// Per-character & aggregate DB reads.
|
||||
mux.HandleFunc("GET /stats/{character_name}", s.handleStats)
|
||||
mux.HandleFunc("GET /portals", s.handlePortals)
|
||||
mux.HandleFunc("GET /spawns/heatmap", s.handleSpawnHeatmap)
|
||||
mux.HandleFunc("GET /server-health", s.handleServerHealth)
|
||||
mux.HandleFunc("GET /character-stats/{name}", s.handleCharacterStats)
|
||||
mux.HandleFunc("GET /combat-stats", s.handleCombatStatsAll)
|
||||
mux.HandleFunc("GET /combat-stats/{character_name}", s.handleCombatStatsOne)
|
||||
mux.HandleFunc("GET /inventories", s.handleInventories)
|
||||
mux.HandleFunc("GET /inventory/{character_name}/search", s.handleInventorySearch)
|
||||
|
||||
// Ingest-only state (empty/default in Phase 1).
|
||||
mux.HandleFunc("GET /quest-status", s.handleQuestStatus)
|
||||
mux.HandleFunc("GET /vital-sharing/peers", s.handleVitalSharingPeers)
|
||||
mux.HandleFunc("GET /equipment-cantrip-state/{name}", s.handleEquipmentCantrip)
|
||||
mux.HandleFunc("GET /issues", s.handleIssues)
|
||||
mux.HandleFunc("GET /me", s.handleMe)
|
||||
|
||||
// WebSocket servers (cutover-ready): browser fan-out + plugin ingest.
|
||||
mux.HandleFunc("GET /ws/live", s.handleWSLive)
|
||||
mux.HandleFunc("GET /ws/position", s.handleWSPosition)
|
||||
|
||||
// Inventory-service reverse proxies.
|
||||
s.registerProxyRoutes(mux)
|
||||
|
||||
// Website layer: login/logout + icons + static frontend (cutover).
|
||||
mux.HandleFunc("GET /login", s.handleLoginGet)
|
||||
mux.HandleFunc("POST /login", s.handleLoginPost)
|
||||
mux.HandleFunc("GET /logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /icons/{filename}", s.handleIcon)
|
||||
|
||||
// Admin user management.
|
||||
mux.HandleFunc("GET /admin/users", s.handleAdminPage)
|
||||
mux.HandleFunc("GET /api-admin/users", s.handleListUsers)
|
||||
mux.HandleFunc("POST /api-admin/users", s.handleCreateUser)
|
||||
mux.HandleFunc("PATCH /api-admin/users/{user_id}", s.handleUpdateUser)
|
||||
mux.HandleFunc("DELETE /api-admin/users/{user_id}", s.handleDeleteUser)
|
||||
|
||||
// Issue board write side (GET /issues is registered above).
|
||||
mux.HandleFunc("POST /issues", s.handleAddIssue)
|
||||
mux.HandleFunc("PATCH /issues/{issue_id}", s.handleUpdateIssue)
|
||||
mux.HandleFunc("POST /issues/{issue_id}/comments", s.handleAddComment)
|
||||
mux.HandleFunc("DELETE /issues/{issue_id}", s.handleDeleteIssue)
|
||||
// Catch-all: serve the static frontend (SPA). Registered last; every
|
||||
// specific route above is more specific, so this only handles the rest.
|
||||
mux.HandleFunc("GET /", s.handleStatic)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"service": "tracker-go",
|
||||
"version": buildVersion,
|
||||
"db": s.pool != nil,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"version": buildVersion})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("json encode failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// withRequestLogging is a thin access-log middleware.
|
||||
func withRequestLogging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sr := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(sr, r)
|
||||
slog.Info("http",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", sr.status,
|
||||
"dur_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
s.status = code
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController (used by coder/websocket to hijack the
|
||||
// connection for /ws upgrades) reach the underlying ResponseWriter through this
|
||||
// logging wrapper. Without it, WebSocket handshakes fail.
|
||||
func (s *statusRecorder) Unwrap() http.ResponseWriter {
|
||||
return s.ResponseWriter
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// These endpoints are backed by ingest-only in-memory state in the Python
|
||||
// service (populated from /ws/position events). Phase 1 has no ingest, so they
|
||||
// return the same empty/default shapes the Python service emits when no data is
|
||||
// present — preserving the API contract for the frontend.
|
||||
|
||||
// GET /quest-status (main.py:1940)
|
||||
func (s *Server) handleQuestStatus(w http.ResponseWriter, r *http.Request) {
|
||||
questData := map[string]any{}
|
||||
playerCount := 0
|
||||
if s.ingestor != nil {
|
||||
qd, n := s.ingestor.questData()
|
||||
playerCount = n
|
||||
for c, qs := range qd {
|
||||
m := map[string]any{}
|
||||
for k, v := range qs {
|
||||
m[k] = v
|
||||
}
|
||||
questData[c] = m
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"quest_data": questData,
|
||||
"tracked_quests": []string{
|
||||
"Stipend Collection Timer",
|
||||
"Blank Augmentation Gem Pickup Timer",
|
||||
"Insatiable Eater Jaw",
|
||||
},
|
||||
"player_count": playerCount,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /vital-sharing/peers (main.py:1800)
|
||||
func (s *Server) handleVitalSharingPeers(w http.ResponseWriter, r *http.Request) {
|
||||
if s.ingestor == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"peers": []any{}, "subscriber_count": 0})
|
||||
return
|
||||
}
|
||||
peers, subCount := s.ingestor.vitalSharingPeers()
|
||||
sort.Slice(peers, func(i, j int) bool {
|
||||
return toStr(peers[i]["character_name"]) < toStr(peers[j]["character_name"])
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"peers": peers, "subscriber_count": subCount})
|
||||
}
|
||||
|
||||
// GET /equipment-cantrip-state/{name} (main.py:4167)
|
||||
func (s *Server) handleEquipmentCantrip(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if s.ingestor != nil {
|
||||
if v, ok := s.ingestor.getEquipmentCantrip(name); ok {
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"type": "equipment_cantrip_state",
|
||||
"character_name": name,
|
||||
"items": []any{},
|
||||
})
|
||||
}
|
||||
|
||||
// GET /issues — flat-file issue board. (main.py:1709)
|
||||
func (s *Server) handleIssues(w http.ResponseWriter, r *http.Request) {
|
||||
issues := s.loadIssues()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"issues": issues})
|
||||
}
|
||||
|
||||
func (s *Server) loadIssues() []any {
|
||||
empty := []any{}
|
||||
b, err := os.ReadFile(filepath.Join(s.staticDir, "openissues.json"))
|
||||
if err != nil {
|
||||
return empty
|
||||
}
|
||||
var v []any
|
||||
if json.Unmarshal(b, &v) != nil {
|
||||
return empty
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// GET /me — current user from the session (main.py:1455). Internal-trust
|
||||
// loopback requests carry no user identity, so they get 401 too.
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r)
|
||||
if u == nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Not authenticated"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"username": u.Username, "is_admin": u.IsAdmin})
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// initInvProxy builds a streaming reverse proxy to the inventory-service.
|
||||
// FlushInterval=-1 flushes writes immediately so SSE endpoints (the suitbuilder
|
||||
// search stream) work. Connection errors map to 503, mirroring the Python
|
||||
// service's "Inventory service unavailable".
|
||||
func (s *Server) initInvProxy(target string) error {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rp := httputil.NewSingleHostReverseProxy(u)
|
||||
rp.FlushInterval = -1
|
||||
rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
s.log.Error("inventory proxy error", "err", err, "path", r.URL.Path)
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "Inventory service unavailable"})
|
||||
}
|
||||
s.invProxy = rp
|
||||
return nil
|
||||
}
|
||||
|
||||
// proxyInv returns a handler that rewrites the request path (via rewrite) and
|
||||
// forwards it to the inventory-service, preserving method, query, headers, and
|
||||
// body. The original /inv/* prefix etc. is mapped to the upstream path.
|
||||
func (s *Server) proxyInv(rewrite func(r *http.Request) string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.invProxy == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "Inventory service unavailable"})
|
||||
return
|
||||
}
|
||||
r.URL.Path = rewrite(r)
|
||||
r.URL.RawPath = "" // force re-encode from the (decoded) Path
|
||||
s.invProxy.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) registerProxyRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /inventory/{character_name}", s.proxyInv(func(r *http.Request) string {
|
||||
return "/inventory/" + r.PathValue("character_name")
|
||||
}))
|
||||
mux.HandleFunc("GET /inventory-characters", s.proxyInv(func(r *http.Request) string {
|
||||
return "/characters/list"
|
||||
}))
|
||||
mux.HandleFunc("GET /search/items", s.proxyInv(func(r *http.Request) string {
|
||||
return "/search/items"
|
||||
}))
|
||||
mux.HandleFunc("GET /search/equipped/{character_name}", s.proxyInv(func(r *http.Request) string {
|
||||
return "/search/equipped/" + r.PathValue("character_name")
|
||||
}))
|
||||
mux.HandleFunc("GET /search/upgrades/{character_name}/{slot}", s.proxyInv(func(r *http.Request) string {
|
||||
return "/search/upgrades/" + r.PathValue("character_name") + "/" + r.PathValue("slot")
|
||||
}))
|
||||
mux.HandleFunc("GET /sets/list", s.proxyInv(func(r *http.Request) string {
|
||||
return "/sets/list"
|
||||
}))
|
||||
|
||||
// /inv/test is a static liveness probe in the Python service.
|
||||
mux.HandleFunc("GET /inv/test", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"message": "Inventory proxy route is working"})
|
||||
})
|
||||
// Generic catch-all proxy: /inv/{path...} -> {SVC}/{path}. Covers GET and
|
||||
// POST (incl. the SSE suitbuilder search). Registered for both methods.
|
||||
invAll := s.proxyInv(func(r *http.Request) string {
|
||||
return "/" + r.PathValue("path")
|
||||
})
|
||||
mux.HandleFunc("GET /inv/{path...}", invAll)
|
||||
mux.HandleFunc("POST /inv/{path...}", invAll)
|
||||
}
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// coerceNum converts a JSON value to a float64, parsing string-encoded numbers.
|
||||
// The plugin sends several telemetry fields as strings (kills_per_hour, deaths,
|
||||
// total_deaths, prismatic_taper_count via .ToString()); Python's pydantic
|
||||
// coerced them, so Go must too or it writes null/0 (causing the live counters
|
||||
// to flap 0<->value between the WS broadcast and the DB-derived /live poll).
|
||||
func coerceNum(v any) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, true
|
||||
case float32:
|
||||
return float64(x), true
|
||||
case int:
|
||||
return float64(x), true
|
||||
case int32:
|
||||
return float64(x), true
|
||||
case int64:
|
||||
return float64(x), true
|
||||
case string:
|
||||
s := strings.TrimSpace(x)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
return f, err == nil
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// reqCtx returns a child of the request context with a query timeout.
|
||||
func reqCtx(r *http.Request) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(r.Context(), 15*time.Second)
|
||||
}
|
||||
|
||||
func (s *Server) dbErr(w http.ResponseWriter, where string, err error) {
|
||||
s.log.Error("db query failed", "where", where, "err", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "Internal server error"})
|
||||
}
|
||||
|
||||
// GET /stats/{character_name} — latest telemetry snapshot + lifetime totals. (main.py:3927)
|
||||
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
cn := r.PathValue("character_name")
|
||||
ctx, cancel := reqCtx(r)
|
||||
defer cancel()
|
||||
|
||||
const sql = `
|
||||
WITH latest AS (
|
||||
SELECT * FROM telemetry_events
|
||||
WHERE character_name = $1
|
||||
ORDER BY timestamp DESC LIMIT 1
|
||||
)
|
||||
SELECT l.*,
|
||||
COALESCE(cs.total_kills, 0) AS total_kills,
|
||||
COALESCE(rs.total_rares, 0) AS total_rares
|
||||
FROM latest l
|
||||
LEFT JOIN char_stats cs ON l.character_name = cs.character_name
|
||||
LEFT JOIN rare_stats rs ON l.character_name = rs.character_name`
|
||||
|
||||
row, err := queryRowAsMap(ctx, s.pool, sql, cn)
|
||||
if err != nil {
|
||||
s.dbErr(w, "stats", err)
|
||||
return
|
||||
}
|
||||
if row == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "Character not found"})
|
||||
return
|
||||
}
|
||||
totalKills := row["total_kills"]
|
||||
totalRares := row["total_rares"]
|
||||
delete(row, "total_kills")
|
||||
delete(row, "total_rares")
|
||||
formatTimes([]map[string]any{row}, "timestamp", "received_at")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"character_name": cn,
|
||||
"latest_snapshot": row,
|
||||
"total_kills": totalKills,
|
||||
"total_rares": totalRares,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /portals — all active portals (cleanup job handles 1h expiry). (main.py:1959)
|
||||
func (s *Server) handlePortals(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := reqCtx(r)
|
||||
defer cancel()
|
||||
|
||||
rows, err := queryRowsAsMaps(ctx, s.pool,
|
||||
`SELECT portal_name, ns, ew, z, discovered_at, discovered_by FROM portals ORDER BY discovered_at DESC`)
|
||||
if err != nil {
|
||||
s.dbErr(w, "portals", err)
|
||||
return
|
||||
}
|
||||
portals := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
da := ""
|
||||
if t, ok := row["discovered_at"].(time.Time); ok {
|
||||
da = pyISO(t)
|
||||
}
|
||||
portals = append(portals, map[string]any{
|
||||
"portal_name": row["portal_name"],
|
||||
"coordinates": map[string]any{"ns": row["ns"], "ew": row["ew"], "z": row["z"]},
|
||||
"discovered_at": da,
|
||||
"discovered_by": row["discovered_by"],
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"portals": portals, "portal_count": len(portals)})
|
||||
}
|
||||
|
||||
// GET /spawns/heatmap?hours=&limit= — aggregated spawn density. (main.py:2037)
|
||||
func (s *Server) handleSpawnHeatmap(w http.ResponseWriter, r *http.Request) {
|
||||
hours := clampInt(queryInt(r, "hours", 24), 1, 168)
|
||||
limit := clampInt(queryInt(r, "limit", 10000), 100, 50000)
|
||||
ctx, cancel := reqCtx(r)
|
||||
defer cancel()
|
||||
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||
rows, err := queryRowsAsMaps(ctx, s.pool,
|
||||
`SELECT ew, ns, COUNT(*) AS spawn_count FROM spawn_events
|
||||
WHERE timestamp >= $1 GROUP BY ew, ns ORDER BY spawn_count DESC LIMIT $2`,
|
||||
cutoff, limit)
|
||||
if err != nil {
|
||||
s.dbErr(w, "spawns/heatmap", err)
|
||||
return
|
||||
}
|
||||
points := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
points = append(points, map[string]any{
|
||||
"ew": toFloat(row["ew"]),
|
||||
"ns": toFloat(row["ns"]),
|
||||
"intensity": toInt(row["spawn_count"]),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"spawn_points": points,
|
||||
"total_points": len(points),
|
||||
"timestamp": pyISO(time.Now().UTC()),
|
||||
"hours_window": hours,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /server-health — current Coldeve status + computed uptime. (main.py:1881)
|
||||
func (s *Server) handleServerHealth(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := reqCtx(r)
|
||||
defer cancel()
|
||||
|
||||
row, err := queryRowAsMap(ctx, s.pool, `SELECT * FROM server_status WHERE server_name = $1`, "Coldeve")
|
||||
if err != nil {
|
||||
s.dbErr(w, "server-health", err)
|
||||
return
|
||||
}
|
||||
|
||||
status := "unknown"
|
||||
var latency, playerCount, lastRestart, lastCheck any
|
||||
var uptimeSeconds int64
|
||||
if row != nil {
|
||||
if v, ok := row["current_status"].(string); ok && v != "" {
|
||||
status = v
|
||||
}
|
||||
latency = row["last_latency_ms"]
|
||||
playerCount = row["last_player_count"]
|
||||
uptimeSeconds = toInt64(row["total_uptime_seconds"])
|
||||
if t, ok := row["last_restart"].(time.Time); ok {
|
||||
lastRestart = pyISO(t)
|
||||
}
|
||||
if t, ok := row["last_check"].(time.Time); ok {
|
||||
lastCheck = pyISO(t)
|
||||
}
|
||||
}
|
||||
days := uptimeSeconds / 86400
|
||||
hours := (uptimeSeconds % 86400) / 3600
|
||||
minutes := (uptimeSeconds % 3600) / 60
|
||||
uptime := fmt.Sprintf("%dh %dm", hours, minutes)
|
||||
if days > 0 {
|
||||
uptime = fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"server_name": "Coldeve",
|
||||
"status": status,
|
||||
"latency_ms": latency,
|
||||
"player_count": playerCount,
|
||||
"uptime": uptime,
|
||||
"uptime_seconds": uptimeSeconds,
|
||||
"last_restart": lastRestart,
|
||||
"last_check": lastCheck,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /inventories — characters with stored inventories. (main.py:2212)
|
||||
func (s *Server) handleInventories(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := reqCtx(r)
|
||||
defer cancel()
|
||||
|
||||
rows, err := queryRowsAsMaps(ctx, s.pool,
|
||||
`SELECT character_name, COUNT(*) AS item_count, MAX(timestamp) AS last_updated
|
||||
FROM character_inventories GROUP BY character_name ORDER BY last_updated DESC`)
|
||||
if err != nil {
|
||||
s.dbErr(w, "inventories", err)
|
||||
return
|
||||
}
|
||||
formatTimes(rows, "last_updated")
|
||||
chars := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
chars = append(chars, map[string]any{
|
||||
"character_name": row["character_name"],
|
||||
"item_count": row["item_count"],
|
||||
"last_updated": row["last_updated"],
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"characters": chars, "total_characters": len(chars)})
|
||||
}
|
||||
|
||||
// GET /inventory/{character_name}/search — filtered local inventory rows. (main.py:2135)
|
||||
func (s *Server) handleInventorySearch(w http.ResponseWriter, r *http.Request) {
|
||||
cn := r.PathValue("character_name")
|
||||
name := optStr(r, "name")
|
||||
objectClass := optInt(r, "object_class")
|
||||
minValue := optInt(r, "min_value")
|
||||
maxValue := optInt(r, "max_value")
|
||||
minBurden := optInt(r, "min_burden")
|
||||
maxBurden := optInt(r, "max_burden")
|
||||
|
||||
conds := []string{"character_name = $1"}
|
||||
args := []any{cn}
|
||||
add := func(tmpl string, val any) {
|
||||
args = append(args, val)
|
||||
conds = append(conds, fmt.Sprintf(tmpl, len(args)))
|
||||
}
|
||||
if name != nil && *name != "" {
|
||||
add("name ILIKE $%d", "%"+*name+"%")
|
||||
}
|
||||
if objectClass != nil {
|
||||
add("object_class = $%d", *objectClass)
|
||||
}
|
||||
if minValue != nil {
|
||||
add("value >= $%d", *minValue)
|
||||
}
|
||||
if maxValue != nil {
|
||||
add("value <= $%d", *maxValue)
|
||||
}
|
||||
if minBurden != nil {
|
||||
add("burden >= $%d", *minBurden)
|
||||
}
|
||||
if maxBurden != nil {
|
||||
add("burden <= $%d", *maxBurden)
|
||||
}
|
||||
|
||||
sql := `SELECT name, icon, object_class, value, burden, has_id_data, item_data, timestamp
|
||||
FROM character_inventories WHERE ` + join(conds, " AND ") + ` ORDER BY value DESC, name`
|
||||
|
||||
ctx, cancel := reqCtx(r)
|
||||
defer cancel()
|
||||
rows, err := queryRowsAsMaps(ctx, s.pool, sql, args...)
|
||||
if err != nil {
|
||||
s.dbErr(w, "inventory-search", err)
|
||||
return
|
||||
}
|
||||
formatTimes(rows, "timestamp")
|
||||
for _, row := range rows {
|
||||
if v, ok := row["item_data"]; ok {
|
||||
row["item_data"] = decodeJSONValue(v)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"character_name": cn,
|
||||
"item_count": len(rows),
|
||||
"search_criteria": map[string]any{
|
||||
"name": derefStr(name),
|
||||
"object_class": derefInt(objectClass),
|
||||
"min_value": derefInt(minValue),
|
||||
"max_value": derefInt(maxValue),
|
||||
"min_burden": derefInt(minBurden),
|
||||
"max_burden": derefInt(maxBurden),
|
||||
},
|
||||
"items": rows,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- small param/number helpers ----
|
||||
|
||||
func queryInt(r *http.Request, key string, def int) int {
|
||||
if v := r.URL.Query().Get(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func optInt(r *http.Request, key string) *int {
|
||||
v := r.URL.Query().Get(key)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &n
|
||||
}
|
||||
|
||||
func optStr(r *http.Request, key string) *string {
|
||||
vs := r.URL.Query()
|
||||
if !vs.Has(key) {
|
||||
return nil
|
||||
}
|
||||
v := vs.Get(key)
|
||||
return &v
|
||||
}
|
||||
|
||||
func derefStr(p *string) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func derefInt(p *int) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func clampInt(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func join(parts []string, sep string) string {
|
||||
out := ""
|
||||
for i, p := range parts {
|
||||
if i > 0 {
|
||||
out += sep
|
||||
}
|
||||
out += p
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toFloat(v any) float64 {
|
||||
f, _ := coerceNum(v)
|
||||
return f
|
||||
}
|
||||
|
||||
func toInt(v any) int {
|
||||
f, _ := coerceNum(v)
|
||||
return int(f)
|
||||
}
|
||||
|
||||
func toInt64(v any) int64 {
|
||||
f, _ := coerceNum(v)
|
||||
return int64(f)
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// initSchema creates the dereth schema on an ingest-owned database, faithfully
|
||||
// replicating db_async.init_db_async (idempotent DDL). It runs ONLY for an
|
||||
// instance that owns its DB (read-write shadow/ingest mode) — never against the
|
||||
// production dereth DB. Like the Python init, it logs and continues per
|
||||
// statement so an optional step (e.g. a timescale policy) can't abort the rest.
|
||||
//
|
||||
// One deliberate divergence from db_async.py: the portal unique index uses
|
||||
// ROUND(..,1), matching main.py's ON CONFLICT target, so portal upserts resolve
|
||||
// on a fresh DB (db_async.py creates ROUND(..,2) — the known production drift).
|
||||
func initSchema(ctx context.Context, pool *pgxpool.Pool, log *slog.Logger) {
|
||||
stmts := []string{
|
||||
`CREATE EXTENSION IF NOT EXISTS timescaledb`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS telemetry_events (
|
||||
character_name VARCHAR NOT NULL,
|
||||
char_tag VARCHAR,
|
||||
session_id VARCHAR NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
ew DOUBLE PRECISION NOT NULL,
|
||||
ns DOUBLE PRECISION NOT NULL,
|
||||
z DOUBLE PRECISION NOT NULL,
|
||||
kills INTEGER NOT NULL,
|
||||
kills_per_hour DOUBLE PRECISION,
|
||||
onlinetime VARCHAR,
|
||||
deaths INTEGER NOT NULL,
|
||||
total_deaths INTEGER,
|
||||
rares_found INTEGER NOT NULL,
|
||||
prismatic_taper_count INTEGER NOT NULL,
|
||||
vt_state VARCHAR,
|
||||
mem_mb DOUBLE PRECISION,
|
||||
cpu_pct DOUBLE PRECISION,
|
||||
mem_handles INTEGER,
|
||||
latency_ms DOUBLE PRECISION,
|
||||
received_at TIMESTAMPTZ
|
||||
)`,
|
||||
`SELECT create_hypertable('telemetry_events','timestamp', if_not_exists => true, migrate_data => true, create_default_indexes => false)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_telemetry_events_char_ts ON telemetry_events (character_name, timestamp)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_telemetry_events_character_name ON telemetry_events (character_name)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_telemetry_events_session_id ON telemetry_events (session_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_telemetry_events_timestamp ON telemetry_events (timestamp)`,
|
||||
`SELECT add_retention_policy('telemetry_events', INTERVAL '7 days', if_not_exists => TRUE)`,
|
||||
// Compression must be enabled on the hypertable before a policy can be added.
|
||||
`ALTER TABLE telemetry_events SET (timescaledb.compress, timescaledb.compress_segmentby = 'character_name')`,
|
||||
`SELECT add_compression_policy('telemetry_events', INTERVAL '1 day', if_not_exists => TRUE)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS char_stats (
|
||||
character_name VARCHAR PRIMARY KEY,
|
||||
total_kills INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS rare_stats (
|
||||
character_name VARCHAR PRIMARY KEY,
|
||||
total_rares INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS rare_stats_sessions (
|
||||
character_name VARCHAR NOT NULL,
|
||||
session_id VARCHAR NOT NULL,
|
||||
session_rares INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (character_name, session_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS combat_stats (
|
||||
character_name VARCHAR PRIMARY KEY,
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
stats_data JSONB NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS combat_stats_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
character_name VARCHAR NOT NULL,
|
||||
session_id VARCHAR NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
stats_data JSONB NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_combat_stats_sessions_character_name ON combat_stats_sessions (character_name)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_combat_stats_sessions_session_id ON combat_stats_sessions (session_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_combat_stats_sessions_timestamp ON combat_stats_sessions (timestamp)`,
|
||||
|
||||
// No sole-id PRIMARY KEY: TimescaleDB requires the partition column
|
||||
// (timestamp) in every unique index, so a bare id PK blocks hypertable
|
||||
// conversion. id stays an auto-increment column for an append-only log.
|
||||
`CREATE TABLE IF NOT EXISTS spawn_events (
|
||||
id BIGSERIAL,
|
||||
character_name VARCHAR NOT NULL,
|
||||
mob VARCHAR NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
ew DOUBLE PRECISION NOT NULL,
|
||||
ns DOUBLE PRECISION NOT NULL,
|
||||
z DOUBLE PRECISION NOT NULL
|
||||
)`,
|
||||
`SELECT create_hypertable('spawn_events','timestamp', if_not_exists => TRUE, migrate_data => FALSE, chunk_time_interval => INTERVAL '1 day')`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_spawn_events_timestamp ON spawn_events (timestamp)`,
|
||||
`SELECT add_retention_policy('spawn_events', INTERVAL '7 days', if_not_exists => TRUE)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS rare_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
character_name VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
ew DOUBLE PRECISION NOT NULL,
|
||||
ns DOUBLE PRECISION NOT NULL,
|
||||
z DOUBLE PRECISION NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_rare_events_timestamp ON rare_events (timestamp)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS character_inventories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
character_name VARCHAR NOT NULL,
|
||||
item_id BIGINT NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
name VARCHAR,
|
||||
icon INTEGER,
|
||||
object_class INTEGER,
|
||||
value INTEGER,
|
||||
burden INTEGER,
|
||||
has_id_data BOOLEAN,
|
||||
item_data JSONB NOT NULL,
|
||||
CONSTRAINT uq_char_item UNIQUE (character_name, item_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_character_inventories_character_name ON character_inventories (character_name)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_character_inventories_object_class ON character_inventories (object_class)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_character_inventories_value ON character_inventories (value)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS portals (
|
||||
id SERIAL PRIMARY KEY,
|
||||
portal_name VARCHAR NOT NULL,
|
||||
ns DOUBLE PRECISION NOT NULL,
|
||||
ew DOUBLE PRECISION NOT NULL,
|
||||
z DOUBLE PRECISION NOT NULL,
|
||||
discovered_at TIMESTAMPTZ NOT NULL,
|
||||
discovered_by VARCHAR NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_portals_discovered_at ON portals (discovered_at)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS unique_portal_coords ON portals (ROUND(ns::numeric, 1), ROUND(ew::numeric, 1))`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_portals_coords ON portals (ns, ew)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS server_status (
|
||||
server_name VARCHAR PRIMARY KEY,
|
||||
current_status VARCHAR(10) NOT NULL,
|
||||
last_seen_up TIMESTAMPTZ,
|
||||
last_restart TIMESTAMPTZ,
|
||||
total_uptime_seconds BIGINT DEFAULT 0,
|
||||
last_check TIMESTAMPTZ,
|
||||
last_latency_ms DOUBLE PRECISION,
|
||||
last_player_count INTEGER
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS character_stats (
|
||||
character_name VARCHAR(255) PRIMARY KEY,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
level INTEGER,
|
||||
total_xp BIGINT,
|
||||
unassigned_xp BIGINT,
|
||||
luminance_earned BIGINT,
|
||||
luminance_total BIGINT,
|
||||
deaths INTEGER,
|
||||
stats_data JSONB NOT NULL
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR NOT NULL UNIQUE,
|
||||
password_hash VARCHAR NOT NULL,
|
||||
is_admin BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
}
|
||||
|
||||
ok, failed := 0, 0
|
||||
for _, s := range stmts {
|
||||
if _, err := pool.Exec(ctx, s); err != nil {
|
||||
failed++
|
||||
log.Warn("schema statement failed (continuing)", "stmt", firstLine(s), "err", err)
|
||||
continue
|
||||
}
|
||||
ok++
|
||||
}
|
||||
log.Info("schema init complete", "ok", ok, "failed", failed)
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return strings.TrimSpace(s[:i])
|
||||
}
|
||||
if len(s) > 80 {
|
||||
return s[:80]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// runShadowConsumer connects to the Python tracker's /ws/live, receives the full
|
||||
// broadcast firehose (no subscribe = all types), and replays each message
|
||||
// through the ingest handlers into THIS instance's own DB. This validates the
|
||||
// Go ingest path against real traffic without touching production or stealing
|
||||
// plugin connections. Reconnects with exponential backoff.
|
||||
//
|
||||
// Note: telemetry broadcasts carry no "type" field (dispatch matches by shape);
|
||||
// spawn and full_inventory are NOT broadcast, so they don't arrive here (covered
|
||||
// by unit tests / the future /ws/position path).
|
||||
func (s *Server) runShadowConsumer(ctx context.Context, wsURL string) {
|
||||
backoff := time.Second
|
||||
const maxBackoff = 60 * time.Second
|
||||
for ctx.Err() == nil {
|
||||
err := s.shadowConnect(ctx, wsURL)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.log.Warn("shadow consumer disconnected; reconnecting", "err", err, "backoff", backoff.String())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) shadowConnect(ctx context.Context, wsURL string) error {
|
||||
c, _, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.CloseNow()
|
||||
c.SetReadLimit(32 << 20) // nearby_objects / dungeon_map payloads can be large
|
||||
|
||||
connCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
// No outbound keepalive ping: the firehose is constant, so the connection is
|
||||
// never idle, and the read-deadline watchdog below handles dead connections.
|
||||
|
||||
// Decouple socket read from ALL processing, including JSON parsing: the read
|
||||
// loop only copies raw frames onto a queue, so it drains the socket as fast
|
||||
// as the network delivers. If parsing or DB-bound dispatch ran inline, the
|
||||
// read would stall, the upstream /ws/live broadcast send would error, and
|
||||
// Python would evict us (Read then blocks forever). A single worker
|
||||
// unmarshals + dispatches in order, preserving per-char kill-delta / combat
|
||||
// accumulation.
|
||||
queue := make(chan []byte, 16384)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for raw := range queue {
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
continue
|
||||
}
|
||||
s.ingestor.dispatch(connCtx, m)
|
||||
}
|
||||
}()
|
||||
|
||||
s.log.Info("shadow consumer connected; replaying /ws/live into ingest", "url", wsURL)
|
||||
var n, dropped int
|
||||
loopErr := s.shadowReadLoop(ctx, c, queue, &n, &dropped)
|
||||
close(queue)
|
||||
<-done
|
||||
return loopErr
|
||||
}
|
||||
|
||||
func (s *Server) shadowReadLoop(ctx context.Context, c *websocket.Conn, queue chan []byte, n, dropped *int) error {
|
||||
for {
|
||||
// Read deadline acts as a liveness watchdog: the firehose is constant, so
|
||||
// a multi-second silence means the upstream evicted us without closing —
|
||||
// time out quickly and let runShadowConsumer reconnect (high duty cycle).
|
||||
rctx, rcancel := context.WithTimeout(ctx, 12*time.Second)
|
||||
_, raw, err := c.Read(rctx)
|
||||
rcancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case queue <- raw:
|
||||
default:
|
||||
*dropped++
|
||||
if *dropped%1000 == 1 {
|
||||
s.log.Warn("shadow queue full; dropping messages", "dropped", *dropped)
|
||||
}
|
||||
}
|
||||
*n++
|
||||
if *n%5000 == 0 {
|
||||
s.log.Info("shadow consumer progress", "messages", *n, "queued", len(queue), "dropped", *dropped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
package main
|
||||
|
||||
// Cross-machine vital sharing (share_*), a faithful port of main.py:3658-3703 +
|
||||
// _update_vital_sharing_peer_state / _broadcast_share_to_plugin_clients.
|
||||
// Memory-only: subscriber set + last-known peer snapshot, fanned out to other
|
||||
// opted-in plugin clients and to browsers. In shadow mode there are no plugin
|
||||
// connections, so the fan-out is a no-op; the peer state still drives
|
||||
// /vital-sharing/peers.
|
||||
|
||||
func (i *Ingestor) handleShareSubscribe(data map[string]any) {
|
||||
char := toStr(data["character_name"])
|
||||
if char == "" {
|
||||
return
|
||||
}
|
||||
i.mu.Lock()
|
||||
i.vitalSubscribers[char] = true
|
||||
entry := i.vitalPeerEntry(char)
|
||||
if tags, ok := data["tags"].([]any); ok {
|
||||
entry["tags"] = tags
|
||||
}
|
||||
entry["connected"] = true
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
func (i *Ingestor) handleShareUnsubscribe(data map[string]any) {
|
||||
char := toStr(data["character_name"])
|
||||
if char == "" {
|
||||
return
|
||||
}
|
||||
i.mu.Lock()
|
||||
delete(i.vitalSubscribers, char)
|
||||
delete(i.vitalPeerState, char)
|
||||
i.mu.Unlock()
|
||||
if i.broadcast != nil {
|
||||
i.broadcast(map[string]any{"type": "share_peer_removed", "character_name": char})
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Ingestor) handleShareUpdate(msgType string, data map[string]any) {
|
||||
origin := toStr(data["character_name"])
|
||||
i.mu.Lock()
|
||||
i.updateVitalPeerState(msgType, data)
|
||||
// Snapshot subscribers for the fan-out.
|
||||
subs := make(map[string]bool, len(i.vitalSubscribers))
|
||||
for k := range i.vitalSubscribers {
|
||||
subs[k] = true
|
||||
}
|
||||
i.mu.Unlock()
|
||||
// Fan out to other opted-in plugin clients (no-op when no plugins connected).
|
||||
if i.plugins != nil && len(subs) > 0 {
|
||||
i.plugins.fanoutShare(data, origin, subs)
|
||||
}
|
||||
}
|
||||
|
||||
// vitalPeerEntry returns (creating if needed) the peer snapshot for char. Caller
|
||||
// holds i.mu.
|
||||
func (i *Ingestor) vitalPeerEntry(char string) map[string]any {
|
||||
entry, ok := i.vitalPeerState[char]
|
||||
if !ok {
|
||||
entry = map[string]any{
|
||||
"character_name": char, "tags": []any{}, "vitals": nil,
|
||||
"position": nil, "items": nil, "connected": true, "last_update": nil,
|
||||
}
|
||||
i.vitalPeerState[char] = entry
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// updateVitalPeerState mirrors _update_vital_sharing_peer_state. Caller holds i.mu.
|
||||
func (i *Ingestor) updateVitalPeerState(msgType string, data map[string]any) {
|
||||
char := toStr(data["character_name"])
|
||||
if char == "" {
|
||||
return
|
||||
}
|
||||
entry := i.vitalPeerEntry(char)
|
||||
entry["last_update"] = data["timestamp"]
|
||||
if tags, ok := data["tags"].([]any); ok {
|
||||
entry["tags"] = tags
|
||||
}
|
||||
switch msgType {
|
||||
case "share_vital_update":
|
||||
entry["vitals"] = map[string]any{
|
||||
"current_health": data["current_health"], "max_health": data["max_health"],
|
||||
"current_stamina": data["current_stamina"], "max_stamina": data["max_stamina"],
|
||||
"current_mana": data["current_mana"], "max_mana": data["max_mana"],
|
||||
}
|
||||
case "share_position_update":
|
||||
entry["position"] = map[string]any{
|
||||
"ew": data["ew"], "ns": data["ns"], "z": data["z"], "heading": data["heading"],
|
||||
}
|
||||
case "share_item_update":
|
||||
entry["items"] = data["items"]
|
||||
}
|
||||
}
|
||||
|
||||
// vitalSharingPeers returns the peer list for /vital-sharing/peers (main.py:1800).
|
||||
func (i *Ingestor) vitalSharingPeers() ([]map[string]any, int) {
|
||||
i.mu.RLock()
|
||||
defer i.mu.RUnlock()
|
||||
peers := make([]map[string]any, 0, len(i.vitalPeerState))
|
||||
for char, entry := range i.vitalPeerState {
|
||||
p := make(map[string]any, len(entry)+2)
|
||||
for k, v := range entry {
|
||||
p[k] = v
|
||||
}
|
||||
p["subscribed"] = i.vitalSubscribers[char]
|
||||
p["plugin_connected"] = i.plugins != nil && i.plugins.isConnected(char)
|
||||
peers = append(peers, p)
|
||||
}
|
||||
return peers, len(i.vitalSubscribers)
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// newPool creates a pgx pool against a dereth TimescaleDB.
|
||||
//
|
||||
// When readOnly is true (the default — read-side parity against the live
|
||||
// production dereth DB), every pooled connection is forced into read-only
|
||||
// transaction mode as defense-in-depth, so even a buggy write cannot mutate the
|
||||
// data the Python service owns. When false (ingest/shadow mode against this
|
||||
// instance's OWN database), writes are permitted.
|
||||
func newPool(ctx context.Context, dsn string, readOnly bool) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse DATABASE_URL: %w", err)
|
||||
}
|
||||
cfg.MaxConns = 10
|
||||
cfg.MaxConnIdleTime = 5 * time.Minute
|
||||
if readOnly {
|
||||
cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
|
||||
if _, err := conn.Exec(ctx, "SET default_transaction_read_only = on"); err != nil {
|
||||
return fmt.Errorf("set read-only: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pool: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// queryRowsAsMaps runs a query and returns each row as a column-name->value map,
|
||||
// mirroring how the Python service builds response dicts directly from rows.
|
||||
// A nil result is coerced to an empty (non-nil) slice so JSON encodes "[]".
|
||||
func queryRowsAsMaps(ctx context.Context, pool *pgxpool.Pool, sql string, args ...any) ([]map[string]any, error) {
|
||||
rows, err := pool.Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := pgx.CollectRows(rows, pgx.RowToMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []map[string]any{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// queryRowAsMap runs a query expected to return at most one row. It returns
|
||||
// (nil, nil) when there are no rows, so callers can map that to a 404.
|
||||
func queryRowAsMap(ctx context.Context, pool *pgxpool.Pool, sql string, args ...any) (map[string]any, error) {
|
||||
rows, err := pool.Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := pgx.CollectExactlyOneRow(rows, pgx.RowToMap)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// asJSONMap coerces a value that may be JSON bytes, a JSON string, or an
|
||||
// already-decoded map into a map[string]any. Used for JSONB columns where pgx's
|
||||
// decoding can vary. Returns nil if the value can't be interpreted as an object.
|
||||
func asJSONMap(v any) map[string]any {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case map[string]any:
|
||||
return x
|
||||
case []byte:
|
||||
var m map[string]any
|
||||
if json.Unmarshal(x, &m) == nil {
|
||||
return m
|
||||
}
|
||||
case string:
|
||||
var m map[string]any
|
||||
if json.Unmarshal([]byte(x), &m) == nil {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeJSONValue coerces a JSON/JSONB column value into its natural Go value
|
||||
// (map, slice, scalar). Bytes/strings are unmarshaled; anything else is
|
||||
// returned unchanged.
|
||||
func decodeJSONValue(v any) any {
|
||||
switch x := v.(type) {
|
||||
case []byte:
|
||||
var out any
|
||||
if json.Unmarshal(x, &out) == nil {
|
||||
return out
|
||||
}
|
||||
case string:
|
||||
var out any
|
||||
if json.Unmarshal([]byte(x), &out) == nil {
|
||||
return out
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// pyISO formats a timestamp the way Python's datetime.isoformat() does for a
|
||||
// UTC tz-aware value, so output matches FastAPI's jsonable_encoder:
|
||||
// - no fractional part when microseconds are zero
|
||||
// - otherwise exactly 6 fractional digits
|
||||
// - "+00:00" offset (not "Z")
|
||||
// Postgres timestamptz has microsecond resolution, so ns is always a multiple
|
||||
// of 1000.
|
||||
func pyISO(t time.Time) string {
|
||||
t = t.UTC()
|
||||
if t.Nanosecond() == 0 {
|
||||
return t.Format("2006-01-02T15:04:05+00:00")
|
||||
}
|
||||
return t.Format("2006-01-02T15:04:05") + fmt.Sprintf(".%06d+00:00", t.Nanosecond()/1000)
|
||||
}
|
||||
|
||||
// formatTimes rewrites the named time.Time columns in-place to pyISO strings.
|
||||
// Missing or NULL (nil) values are left untouched, so they encode as JSON null.
|
||||
func formatTimes(rows []map[string]any, keys ...string) {
|
||||
for _, m := range rows {
|
||||
for _, k := range keys {
|
||||
if t, ok := m[k].(time.Time); ok {
|
||||
m[k] = pyISO(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const totalsInterval = 300 * time.Second // _refresh_total_rares_cache cadence
|
||||
|
||||
// totalsCache holds the pre-marshaled bodies for /total-rares and /total-kills,
|
||||
// refreshed every totalsInterval, mirroring main.py:924.
|
||||
type totalsCache struct {
|
||||
mu sync.RWMutex
|
||||
raresJSON []byte
|
||||
killsJSON []byte
|
||||
}
|
||||
|
||||
func newTotalsCache() *totalsCache {
|
||||
return &totalsCache{
|
||||
raresJSON: []byte(`{"all_time":0,"today":0,"last_updated":null}`),
|
||||
killsJSON: []byte(`{"total":0,"last_updated":null}`),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *totalsCache) getRares() []byte { c.mu.RLock(); defer c.mu.RUnlock(); return c.raresJSON }
|
||||
func (c *totalsCache) getKills() []byte { c.mu.RLock(); defer c.mu.RUnlock(); return c.killsJSON }
|
||||
|
||||
func (c *totalsCache) set(rares, kills []byte) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.raresJSON = rares
|
||||
c.killsJSON = kills
|
||||
}
|
||||
|
||||
func (s *Server) refreshTotals(ctx context.Context) error {
|
||||
qctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var allTime, today, totalKills int64
|
||||
// Each query degrades to 0 on error, mirroring the Python try/except blocks.
|
||||
_ = s.pool.QueryRow(qctx, "SELECT COALESCE(SUM(total_rares), 0) FROM rare_stats").Scan(&allTime)
|
||||
_ = s.pool.QueryRow(qctx, "SELECT COUNT(*) FROM rare_events WHERE timestamp >= CURRENT_DATE").Scan(&today)
|
||||
_ = s.pool.QueryRow(qctx, "SELECT COALESCE(SUM(total_kills), 0) FROM char_stats").Scan(&totalKills)
|
||||
|
||||
lastUpdated := pyISO(time.Now().UTC())
|
||||
raresJSON, err := json.Marshal(map[string]any{"all_time": allTime, "today": today, "last_updated": lastUpdated})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
killsJSON, err := json.Marshal(map[string]any{"total": totalKills, "last_updated": lastUpdated})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.totals.set(raresJSON, killsJSON)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) runTotalsLoop(ctx context.Context) {
|
||||
for {
|
||||
if err := s.refreshTotals(ctx); err != nil {
|
||||
s.log.Error("totals cache refresh failed", "err", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(totalsInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleTotalRares(w http.ResponseWriter, r *http.Request) {
|
||||
writeRawJSON(w, s.totals.getRares())
|
||||
}
|
||||
|
||||
func (s *Server) handleTotalKills(w http.ResponseWriter, r *http.Request) {
|
||||
writeRawJSON(w, s.totals.getKills())
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Website-serving layer: static frontend + login/logout, porting main.py so the
|
||||
// unchanged frontend loads on the Go tracker. Cookie issuing/verifying is in
|
||||
// auth.go; this file is the handlers + the static file server.
|
||||
|
||||
// A fixed bcrypt hash used to keep the no-such-user path constant-time, matching
|
||||
// Python's _DUMMY_HASH. (Hash of an arbitrary constant; never matches input.)
|
||||
var dummyBcryptHash = []byte("$2a$12$C6UzMDM.H6dfI/f/IKcEeO3Jj6Q1jK7Z1qkq9b2yY6m4eW7N0pZ2K")
|
||||
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
last map[string]time.Time
|
||||
}
|
||||
|
||||
func newLoginLimiter() *loginLimiter { return &loginLimiter{last: map[string]time.Time{}} }
|
||||
|
||||
// allow returns false if this IP attempted within the 5s cooldown (main.py).
|
||||
func (l *loginLimiter) allow(ip string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
if t, ok := l.last[ip]; ok && now.Sub(t) < 5*time.Second {
|
||||
return false
|
||||
}
|
||||
l.last[ip] = now
|
||||
return true
|
||||
}
|
||||
|
||||
// GET /login — serve the login page (main.py:login_page).
|
||||
func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) {
|
||||
s.serveStaticFile(w, r, "login.html")
|
||||
}
|
||||
|
||||
// loginClientIP returns the rate-limit key for a login attempt. Every nginx
|
||||
// location proxying to the tracker sets X-Real-IP to $remote_addr (see
|
||||
// nginx/overlord.conf), which the client cannot forge. X-Forwarded-For is
|
||||
// $proxy_add_x_forwarded_for — nginx only APPENDS to it, so a client-supplied
|
||||
// leftmost hop survives untouched and would let an attacker rotate spoofed
|
||||
// values to dodge the cooldown.
|
||||
func loginClientIP(r *http.Request) string {
|
||||
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); ip != "" {
|
||||
return ip
|
||||
}
|
||||
return clientIP(r)
|
||||
}
|
||||
|
||||
// POST /login — authenticate and set the session cookie (main.py:login).
|
||||
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
ip := loginClientIP(r)
|
||||
if !s.loginLimiter.allow(ip) {
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]any{"detail": "Too many login attempts. Try again in a few seconds."})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body) != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
username := strings.ToLower(strings.TrimSpace(body.Username))
|
||||
if username == "" || body.Password == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Username and password required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
var dbUser, hash string
|
||||
var isAdmin bool
|
||||
err := s.pool.QueryRow(ctx,
|
||||
"SELECT username, password_hash, is_admin FROM users WHERE LOWER(username) = $1", username,
|
||||
).Scan(&dbUser, &hash, &isAdmin)
|
||||
// Constant-time: always run bcrypt, even when the user doesn't exist.
|
||||
pwOK := false
|
||||
if err == nil {
|
||||
pwOK = bcrypt.CompareHashAndPassword([]byte(hash), []byte(body.Password)) == nil
|
||||
} else {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(body.Password))
|
||||
}
|
||||
if !pwOK {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Invalid username or password"})
|
||||
return
|
||||
}
|
||||
|
||||
token := issueSessionCookie(s.secretKey, sessionUser{Username: dbUser, IsAdmin: isAdmin})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session", Value: token, Path: "/", MaxAge: sessionMaxAge,
|
||||
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "username": dbUser, "is_admin": isAdmin})
|
||||
}
|
||||
|
||||
// GET /logout — clear the cookie and redirect to /login (main.py:logout).
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1})
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
// GET /icons/{filename} — serve an icon file (main.py:serve_icon).
|
||||
func (s *Server) handleIcon(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("filename")
|
||||
if name == "" || strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.serveStaticFile(w, r, filepath.Join("icons", name))
|
||||
}
|
||||
|
||||
// handleStatic is the catch-all GET handler: serves files from staticDir, falls
|
||||
// back to index.html for SPA routes (React client-side routing). Registered last
|
||||
// so the specific API routes take precedence.
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
upath := path.Clean("/" + r.URL.Path)
|
||||
full := filepath.Join(s.staticDir, filepath.FromSlash(upath))
|
||||
// Guard against path traversal escaping staticDir.
|
||||
if rel, err := filepath.Rel(s.staticDir, full); err != nil || strings.HasPrefix(rel, "..") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if info, err := os.Stat(full); err == nil {
|
||||
if info.IsDir() {
|
||||
if idx := filepath.Join(full, "index.html"); fileExists(idx) {
|
||||
http.ServeFile(w, r, idx)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
http.ServeFile(w, r, full)
|
||||
return
|
||||
}
|
||||
}
|
||||
// SPA fallback — serve the app shell for unknown (client-routed) paths.
|
||||
http.ServeFile(w, r, filepath.Join(s.staticDir, "index.html"))
|
||||
}
|
||||
|
||||
func (s *Server) serveStaticFile(w http.ResponseWriter, r *http.Request, rel string) {
|
||||
full := filepath.Join(s.staticDir, filepath.FromSlash(rel))
|
||||
if !fileExists(full) {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, full)
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
info, err := os.Stat(p)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// runIssueCookieCLI prints a session token for cross-checking itsdangerous
|
||||
// cookie interop with the Python service.
|
||||
func runIssueCookieCLI() {
|
||||
if len(os.Args) < 5 {
|
||||
os.Stderr.WriteString("usage: tracker-go issue-cookie <username> <is_admin:true|false> <secret_key>\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Stdout.WriteString(issueSessionCookie(os.Args[4], sessionUser{Username: os.Args[2], IsAdmin: os.Args[3] == "true"}))
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Admin user management — port of main.py's /admin + /api-admin/users routes.
|
||||
// All require an admin session (requireAdmin). Writes only succeed in write
|
||||
// (cutover) mode; on the read-only parallel instance the txn is rejected.
|
||||
|
||||
// GET /admin/users — serve the admin page (admin only).
|
||||
func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
s.serveStaticFile(w, r, "admin.html")
|
||||
}
|
||||
|
||||
// GET /api-admin/users — list users (admin only).
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
rows, err := s.pool.Query(ctx, "SELECT id, username, is_admin, created_at FROM users ORDER BY id")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "db error"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
users := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var username string
|
||||
var isAdmin bool
|
||||
var createdAt time.Time
|
||||
if rows.Scan(&id, &username, &isAdmin, &createdAt) != nil {
|
||||
continue
|
||||
}
|
||||
users = append(users, map[string]any{
|
||||
"id": id, "username": username, "is_admin": isAdmin,
|
||||
"created_at": createdAt.UTC().Format("2006-01-02T15:04:05.999999"),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": users})
|
||||
}
|
||||
|
||||
// POST /api-admin/users — create a user (admin only).
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body)
|
||||
username := strings.TrimSpace(body.Username)
|
||||
if username == "" || body.Password == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Username and password required"})
|
||||
return
|
||||
}
|
||||
if len(body.Password) < 4 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Password must be at least 4 characters"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
var existing int
|
||||
if s.pool.QueryRow(ctx, "SELECT id FROM users WHERE LOWER(username) = $1", strings.ToLower(username)).Scan(&existing) == nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{"detail": "Username already exists"})
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(body.Password), 12)
|
||||
if _, err := s.pool.Exec(ctx, "INSERT INTO users (username, password_hash, is_admin) VALUES ($1,$2,$3)", username, string(hash), body.IsAdmin); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "Failed to create user"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "username": username})
|
||||
}
|
||||
|
||||
// PATCH /api-admin/users/{user_id} — password reset / admin toggle (admin only).
|
||||
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
id, _ := strconv.Atoi(r.PathValue("user_id"))
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
var exists int
|
||||
if errors.Is(s.pool.QueryRow(ctx, "SELECT id FROM users WHERE id = $1", id).Scan(&exists), pgx.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "User not found"})
|
||||
return
|
||||
}
|
||||
if pw, ok := body["password"].(string); ok {
|
||||
if len(pw) < 4 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Password must be at least 4 characters"})
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(pw), 12)
|
||||
if _, err := s.pool.Exec(ctx, "UPDATE users SET password_hash = $1 WHERE id = $2", string(hash), id); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "update failed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if a, ok := body["is_admin"]; ok {
|
||||
isAdmin, _ := a.(bool)
|
||||
if _, err := s.pool.Exec(ctx, "UPDATE users SET is_admin = $1 WHERE id = $2", isAdmin, id); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "update failed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// DELETE /api-admin/users/{user_id} — delete a user (admin only, not yourself).
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
id, _ := strconv.Atoi(r.PathValue("user_id"))
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
var username string
|
||||
if errors.Is(s.pool.QueryRow(ctx, "SELECT username FROM users WHERE id = $1", id).Scan(&username), pgx.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "User not found"})
|
||||
return
|
||||
}
|
||||
if cur := currentUser(r); cur != nil && strings.EqualFold(username, cur.Username) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Cannot delete yourself"})
|
||||
return
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, "DELETE FROM users WHERE id = $1", id); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "delete failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue board write side — port of main.py's POST/PATCH/DELETE /issues. Issues
|
||||
// live in static/openissues.json (the same flat file the read side uses); writes
|
||||
// are serialized by issuesMu. Needs the file mounted read-write in cutover.
|
||||
|
||||
var issuesMu sync.Mutex
|
||||
|
||||
func (s *Server) issuesPath() string { return filepath.Join(s.staticDir, "openissues.json") }
|
||||
|
||||
func (s *Server) loadIssuesRW() []map[string]any {
|
||||
b, err := os.ReadFile(s.issuesPath())
|
||||
if err != nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
var v []map[string]any
|
||||
if json.Unmarshal(b, &v) != nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Server) saveIssues(issues []map[string]any) error {
|
||||
b, _ := json.MarshalIndent(issues, "", " ")
|
||||
return os.WriteFile(s.issuesPath(), b, 0o644)
|
||||
}
|
||||
|
||||
func issueAuthor(r *http.Request) string {
|
||||
if u := currentUser(r); u != nil {
|
||||
return u.Username
|
||||
}
|
||||
return "Anonymous"
|
||||
}
|
||||
|
||||
func nowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.999999") }
|
||||
|
||||
func randHex8() string {
|
||||
b := make([]byte, 4)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// pyHTMLEscape matches Python's html.escape(s, quote=True).
|
||||
func pyHTMLEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
|
||||
// POST /issues
|
||||
func (s *Server) handleAddIssue(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body)
|
||||
title := pyHTMLEscape(strings.TrimSpace(toStr(body["title"])))
|
||||
if title == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Title is required"})
|
||||
return
|
||||
}
|
||||
category := strings.TrimSpace(toStr(body["category"]))
|
||||
if category == "" {
|
||||
category = "other"
|
||||
}
|
||||
newIssue := map[string]any{
|
||||
"id": randHex8(),
|
||||
"title": title,
|
||||
"description": pyHTMLEscape(strings.TrimSpace(toStr(body["description"]))),
|
||||
"category": pyHTMLEscape(category),
|
||||
"author": issueAuthor(r),
|
||||
"created": nowISO(),
|
||||
"resolved": false,
|
||||
"comments": []any{},
|
||||
}
|
||||
issuesMu.Lock()
|
||||
defer issuesMu.Unlock()
|
||||
issues := append([]map[string]any{newIssue}, s.loadIssuesRW()...)
|
||||
if err := s.saveIssues(issues); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "save failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, newIssue)
|
||||
}
|
||||
|
||||
// PATCH /issues/{issue_id}
|
||||
func (s *Server) handleUpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("issue_id")
|
||||
var update map[string]any
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&update)
|
||||
issuesMu.Lock()
|
||||
defer issuesMu.Unlock()
|
||||
issues := s.loadIssuesRW()
|
||||
var found map[string]any
|
||||
for _, i := range issues {
|
||||
if toStr(i["id"]) == id {
|
||||
if v, ok := update["resolved"]; ok {
|
||||
b, _ := v.(bool)
|
||||
i["resolved"] = b
|
||||
}
|
||||
if v, ok := update["title"]; ok {
|
||||
t := pyHTMLEscape(strings.TrimSpace(toStr(v)))
|
||||
if t == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Title cannot be empty"})
|
||||
return
|
||||
}
|
||||
i["title"] = t
|
||||
}
|
||||
if v, ok := update["description"]; ok {
|
||||
i["description"] = pyHTMLEscape(strings.TrimSpace(toStr(v)))
|
||||
}
|
||||
if v, ok := update["category"]; ok {
|
||||
i["category"] = pyHTMLEscape(toStr(v))
|
||||
}
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "Issue not found"})
|
||||
return
|
||||
}
|
||||
if err := s.saveIssues(issues); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "save failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, found)
|
||||
}
|
||||
|
||||
// POST /issues/{issue_id}/comments
|
||||
func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("issue_id")
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body)
|
||||
issuesMu.Lock()
|
||||
defer issuesMu.Unlock()
|
||||
issues := s.loadIssuesRW()
|
||||
var found map[string]any
|
||||
for _, i := range issues {
|
||||
if toStr(i["id"]) == id {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "Issue not found"})
|
||||
return
|
||||
}
|
||||
text := pyHTMLEscape(strings.TrimSpace(toStr(body["text"])))
|
||||
if text == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Comment text is required"})
|
||||
return
|
||||
}
|
||||
comment := map[string]any{"id": randHex8(), "author": issueAuthor(r), "text": text, "created": nowISO()}
|
||||
comments, _ := found["comments"].([]any)
|
||||
found["comments"] = append(comments, comment)
|
||||
if err := s.saveIssues(issues); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "save failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, comment)
|
||||
}
|
||||
|
||||
// DELETE /issues/{issue_id}
|
||||
func (s *Server) handleDeleteIssue(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("issue_id")
|
||||
issuesMu.Lock()
|
||||
defer issuesMu.Unlock()
|
||||
kept := []map[string]any{}
|
||||
for _, i := range s.loadIssuesRW() {
|
||||
if toStr(i["id"]) != id {
|
||||
kept = append(kept, i)
|
||||
}
|
||||
}
|
||||
if err := s.saveIssues(kept); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "save failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// nginx sets X-Real-IP to $remote_addr on every proxied location (see
|
||||
// nginx/overlord.conf), so it can't be spoofed by the client. XFF, in
|
||||
// contrast, is $proxy_add_x_forwarded_for — nginx APPENDS to whatever the
|
||||
// client sent, so an attacker-supplied leftmost value survives untouched.
|
||||
// The rate limiter must key on X-Real-IP, not XFF.
|
||||
func TestLoginClientIP_IgnoresSpoofedXFF(t *testing.T) {
|
||||
r := &http.Request{
|
||||
RemoteAddr: "10.0.0.1:12345", // nginx's own connection to the app
|
||||
Header: http.Header{},
|
||||
}
|
||||
r.Header.Set("X-Real-IP", "203.0.113.5")
|
||||
r.Header.Set("X-Forwarded-For", "6.6.6.6, 203.0.113.5") // attacker-spoofed leftmost hop
|
||||
|
||||
if got := loginClientIP(r); got != "203.0.113.5" {
|
||||
t.Errorf("loginClientIP = %q, want %q (trusted X-Real-IP, not spoofable XFF)", got, "203.0.113.5")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginClientIP_FallsBackToRemoteAddr(t *testing.T) {
|
||||
r := &http.Request{
|
||||
RemoteAddr: "198.51.100.7:54321",
|
||||
Header: http.Header{},
|
||||
}
|
||||
|
||||
if got := loginClientIP(r); got != "198.51.100.7" {
|
||||
t.Errorf("loginClientIP = %q, want %q (no X-Real-IP, fall back to RemoteAddr)", got, "198.51.100.7")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// Hub is the browser broadcast fan-out for /ws/live, mirroring main.py's
|
||||
// browser_conns + _do_broadcast: each client has an optional message-type
|
||||
// filter (nil = all); a message is delivered when the filter is nil or contains
|
||||
// the message's "type". Telemetry broadcasts carry no type, so only unfiltered
|
||||
// clients receive them (matching Python — which is why the React map polls /live
|
||||
// over HTTP rather than relying on the WS for positions).
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*browserClient]bool
|
||||
}
|
||||
|
||||
type browserClient struct {
|
||||
filter map[string]bool // nil = all types
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
func newHub() *Hub { return &Hub{clients: map[*browserClient]bool{}} }
|
||||
|
||||
func (h *Hub) add(c *browserClient) {
|
||||
h.mu.Lock()
|
||||
h.clients[c] = true
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) remove(c *browserClient) {
|
||||
h.mu.Lock()
|
||||
if h.clients[c] {
|
||||
delete(h.clients, c)
|
||||
close(c.send)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) count() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// broadcast serializes once and delivers to matching clients. A slow client
|
||||
// (full send buffer) is skipped for this message rather than blocking the
|
||||
// ingest path, matching the spirit of Python's per-send timeout + eviction.
|
||||
func (h *Hub) broadcast(data map[string]any) {
|
||||
h.mu.RLock()
|
||||
empty := len(h.clients) == 0
|
||||
h.mu.RUnlock()
|
||||
if empty {
|
||||
return // no browsers: skip the marshal entirely
|
||||
}
|
||||
msg, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
msgType, _ := data["type"].(string)
|
||||
h.mu.RLock()
|
||||
for c := range h.clients {
|
||||
if c.filter != nil && (msgType == "" || !c.filter[msgType]) {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case c.send <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
|
||||
func (s *Server) handleWSLive(w http.ResponseWriter, r *http.Request) {
|
||||
// Auth: internal-trust (private peer + no XFF) OR a valid session cookie.
|
||||
if !(r.Header.Get("X-Forwarded-For") == "" && isPrivateAddr(clientIP(r))) {
|
||||
c, err := r.Cookie("session")
|
||||
if err != nil || verifySessionCookie(s.secretKey, c.Value) == nil {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
conn.SetReadLimit(8 << 20)
|
||||
|
||||
client := &browserClient{send: make(chan []byte, 256)}
|
||||
s.hub.add(client)
|
||||
defer s.hub.remove(client)
|
||||
|
||||
ctx := r.Context()
|
||||
// Writer goroutine: the only writer for this conn (serializes writes).
|
||||
go func() {
|
||||
for msg := range client.send {
|
||||
wctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
err := conn.Write(wctx, websocket.MessageText, msg)
|
||||
cancel()
|
||||
if err != nil {
|
||||
conn.CloseNow()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
_, raw, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
continue
|
||||
}
|
||||
s.handleBrowserMessage(client, m)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBrowserMessage handles subscribe / request_dungeon_map / command
|
||||
// envelopes from a browser client (main.py:3846).
|
||||
func (s *Server) handleBrowserMessage(c *browserClient, m map[string]any) {
|
||||
switch toStr(m["type"]) {
|
||||
case "subscribe":
|
||||
types := toStringSlice(m["message_types"])
|
||||
if len(types) == 0 {
|
||||
c.filter = nil // all
|
||||
return
|
||||
}
|
||||
f := make(map[string]bool, len(types))
|
||||
for _, t := range types {
|
||||
f[t] = true
|
||||
}
|
||||
c.filter = f
|
||||
return
|
||||
case "request_dungeon_map":
|
||||
lb := toStr(m["landblock"])
|
||||
if lb != "" && s.ingestor != nil {
|
||||
if dm, ok := s.ingestor.snapshot(s.ingestor.dungeonMapCache, lb); ok {
|
||||
if b, err := json.Marshal(dm); err == nil {
|
||||
select {
|
||||
case c.send <- b:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// Command envelopes: new {player_name, command} or legacy {type:command, character_name, text}.
|
||||
if pn, ok := m["player_name"].(string); ok {
|
||||
if cmd, ok := m["command"].(string); ok {
|
||||
s.plugins.send(pn, map[string]any{"player_name": pn, "command": cmd})
|
||||
return
|
||||
}
|
||||
}
|
||||
if toStr(m["type"]) == "command" {
|
||||
pn := toStr(m["character_name"])
|
||||
text := toStr(m["text"])
|
||||
if pn != "" {
|
||||
s.plugins.send(pn, map[string]any{"player_name": pn, "command": text})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toStringSlice(v any) []string {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// pluginRegistry maps character_name -> plugin connection for backend->plugin
|
||||
// command routing (main.py plugin_conns).
|
||||
type pluginRegistry struct {
|
||||
mu sync.RWMutex
|
||||
conns map[string]*websocket.Conn
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func newPluginRegistry(log *slog.Logger) *pluginRegistry {
|
||||
return &pluginRegistry{conns: map[string]*websocket.Conn{}, log: log}
|
||||
}
|
||||
|
||||
func (p *pluginRegistry) register(name string, c *websocket.Conn) {
|
||||
p.mu.Lock()
|
||||
p.conns[name] = c
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// removeConn drops every name bound to this connection (on disconnect).
|
||||
func (p *pluginRegistry) removeConn(c *websocket.Conn) {
|
||||
p.mu.Lock()
|
||||
for n, cc := range p.conns {
|
||||
if cc == c {
|
||||
delete(p.conns, n)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *pluginRegistry) isConnected(name string) bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
_, ok := p.conns[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// send routes an opaque {player_name, command} envelope to a plugin; evicts the
|
||||
// connection on write failure (main.py command-forward semantics).
|
||||
func (p *pluginRegistry) send(name string, payload map[string]any) {
|
||||
p.mu.RLock()
|
||||
c := p.conns[name]
|
||||
p.mu.RUnlock()
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := c.Write(ctx, websocket.MessageText, b); err != nil {
|
||||
p.mu.Lock()
|
||||
if p.conns[name] == c {
|
||||
delete(p.conns, name)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// fanoutShare forwards a share_* message to other opted-in plugin clients
|
||||
// (every connected name that is subscribed and isn't the origin). Send failures
|
||||
// are logged-and-ignored, not evicted (main.py:2829).
|
||||
func (p *pluginRegistry) fanoutShare(data map[string]any, origin string, subs map[string]bool) {
|
||||
p.mu.RLock()
|
||||
type target struct {
|
||||
name string
|
||||
c *websocket.Conn
|
||||
}
|
||||
var targets []target
|
||||
for n, c := range p.conns {
|
||||
if n != origin && subs[n] {
|
||||
targets = append(targets, target{n, c})
|
||||
}
|
||||
}
|
||||
p.mu.RUnlock()
|
||||
if len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(data)
|
||||
for _, t := range targets {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
_ = t.c.Write(ctx, websocket.MessageText, b)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// pluginAuthOK constant-time-compares the supplied secret to SHARED_SECRET (and
|
||||
// the optional rotation fallback). Fails closed when unset or left at the
|
||||
// placeholder, matching main.py.
|
||||
func (s *Server) pluginAuthOK(key string) bool {
|
||||
ok := s.sharedSecret != "" && s.sharedSecret != "your_shared_secret" &&
|
||||
hmac.Equal([]byte(key), []byte(s.sharedSecret))
|
||||
if !ok && s.sharedSecretLegacy != "" {
|
||||
ok = hmac.Equal([]byte(key), []byte(s.sharedSecretLegacy))
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *Server) handleWSPosition(w http.ResponseWriter, r *http.Request) {
|
||||
if s.ingestor == nil {
|
||||
http.Error(w, "ingest disabled on this instance", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
key := r.URL.Query().Get("secret")
|
||||
if key == "" {
|
||||
key = r.Header.Get("X-Plugin-Secret")
|
||||
}
|
||||
if !s.pluginAuthOK(key) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
defer s.plugins.removeConn(conn)
|
||||
conn.SetReadLimit(32 << 20)
|
||||
|
||||
ctx := r.Context()
|
||||
for {
|
||||
_, raw, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
continue
|
||||
}
|
||||
if toStr(m["type"]) == "register" {
|
||||
name := toStr(m["character_name"])
|
||||
if name == "" {
|
||||
name = toStr(m["player_name"])
|
||||
}
|
||||
if name != "" {
|
||||
s.plugins.register(name, conn)
|
||||
s.ingestor.clearEquipmentCantrip(name)
|
||||
s.log.Info("plugin registered", "character", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.ingestor.dispatch(ctx, m)
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
import{e as I,r as t,l as _,f as L,g as N,h as W,j as e,D as F}from"./index-Dg_ifawe.js";import"./react-yfL0ty4i.js";function R(c){try{return new Date(c).toISOString().slice(0,10)}catch{return c}}const q=({id:c,zIndex:x})=>{const{user:j}=I(),[g,k]=t.useState([]),[w,p]=t.useState(!0),[b,n]=t.useState(null),[l,C]=t.useState(""),[i,f]=t.useState(""),[h,y]=t.useState(!1),[d,S]=t.useState(!1),[A,u]=t.useState(null),[o,m]=t.useState(""),r=t.useCallback(async()=>{p(!0),n(null);try{const s=await _();k(s.users??[])}catch(s){n(String(s))}finally{p(!1)}},[]);t.useEffect(()=>{r()},[r]);const U=t.useCallback(async s=>{if(s.preventDefault(),!l.trim()||i.length<4){n("Username required and password must be at least 4 chars");return}S(!0),n(null);try{await L(l.trim(),i,h),C(""),f(""),y(!1),await r()}catch(a){n(String(a))}finally{S(!1)}},[l,i,h,r]),v=t.useCallback(async s=>{n(null);try{await N(s.id,{is_admin:!s.is_admin}),await r()}catch(a){n(String(a))}},[r]),D=t.useCallback(async s=>{if(o.length<4){n("Password must be at least 4 characters");return}n(null);try{await N(s,{password:o}),u(null),m("")}catch(a){n(String(a))}},[o]),P=t.useCallback(async s=>{if(confirm(`Delete user "${s.username}"? This cannot be undone.`)){n(null);try{await W(s.id),await r()}catch(a){n(String(a))}}},[r]);return e.jsx(F,{id:c,title:"🛡️ Admin · Users",zIndex:x,width:620,height:540,children:e.jsxs("div",{className:"ml-admin",children:[b&&e.jsx("div",{className:"ml-admin-error",children:b}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsx("h3",{children:"Add user"}),e.jsxs("form",{onSubmit:U,className:"ml-admin-create",children:[e.jsx("input",{type:"text",placeholder:"Username",value:l,onChange:s=>C(s.target.value),disabled:d,autoComplete:"off"}),e.jsx("input",{type:"password",placeholder:"Password (min 4)",value:i,onChange:s=>f(s.target.value),disabled:d,autoComplete:"new-password"}),e.jsxs("label",{children:[e.jsx("input",{type:"checkbox",checked:h,onChange:s=>y(s.target.checked),disabled:d}),"admin"]}),e.jsx("button",{type:"submit",disabled:d||!l.trim()||i.length<4,children:d?"Adding…":"Add"})]})]}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsxs("h3",{children:["Users ",w&&e.jsx("span",{className:"ml-admin-muted",children:"(loading…)"})]}),e.jsxs("table",{className:"ml-admin-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"ID"}),e.jsx("th",{children:"Username"}),e.jsx("th",{children:"Admin"}),e.jsx("th",{children:"Created"}),e.jsx("th",{children:"Actions"})]})}),e.jsxs("tbody",{children:[g.map(s=>{const a=j!=null&&j.username.toLowerCase()===s.username.toLowerCase();return e.jsxs("tr",{children:[e.jsx("td",{children:s.id}),e.jsxs("td",{children:[s.username,a&&e.jsx("span",{className:"ml-admin-muted",children:" (you)"})]}),e.jsx("td",{children:e.jsx("button",{className:"ml-admin-toggle",onClick:()=>v(s),title:"Click to toggle admin",children:s.is_admin?"✓":"–"})}),e.jsx("td",{children:R(s.created_at)}),e.jsx("td",{children:A===s.id?e.jsxs("span",{className:"ml-admin-pw-edit",children:[e.jsx("input",{type:"text",placeholder:"New password",value:o,onChange:E=>m(E.target.value),autoFocus:!0}),e.jsx("button",{onClick:()=>D(s.id),children:"Save"}),e.jsx("button",{onClick:()=>{u(null),m("")},children:"Cancel"})]}):e.jsxs(e.Fragment,{children:[e.jsx("button",{onClick:()=>{u(s.id),m("")},children:"Reset PW"}),!a&&e.jsx("button",{className:"ml-admin-danger",onClick:()=>P(s),children:"Delete"})]})})]},s.id)}),g.length===0&&!w&&e.jsx("tr",{children:e.jsx("td",{colSpan:5,className:"ml-admin-muted",children:"No users."})})]})]})]})]})})};export{q as AdminUsersWindow};
|
||||
import{e as I,r as t,l as _,f as L,g as N,h as W,j as e,D as F}from"./index-DlaI7zTK.js";import"./react-yfL0ty4i.js";function R(c){try{return new Date(c).toISOString().slice(0,10)}catch{return c}}const q=({id:c,zIndex:x})=>{const{user:j}=I(),[g,k]=t.useState([]),[w,p]=t.useState(!0),[b,n]=t.useState(null),[l,C]=t.useState(""),[i,f]=t.useState(""),[h,y]=t.useState(!1),[d,S]=t.useState(!1),[A,u]=t.useState(null),[o,m]=t.useState(""),r=t.useCallback(async()=>{p(!0),n(null);try{const s=await _();k(s.users??[])}catch(s){n(String(s))}finally{p(!1)}},[]);t.useEffect(()=>{r()},[r]);const U=t.useCallback(async s=>{if(s.preventDefault(),!l.trim()||i.length<4){n("Username required and password must be at least 4 chars");return}S(!0),n(null);try{await L(l.trim(),i,h),C(""),f(""),y(!1),await r()}catch(a){n(String(a))}finally{S(!1)}},[l,i,h,r]),v=t.useCallback(async s=>{n(null);try{await N(s.id,{is_admin:!s.is_admin}),await r()}catch(a){n(String(a))}},[r]),D=t.useCallback(async s=>{if(o.length<4){n("Password must be at least 4 characters");return}n(null);try{await N(s,{password:o}),u(null),m("")}catch(a){n(String(a))}},[o]),P=t.useCallback(async s=>{if(confirm(`Delete user "${s.username}"? This cannot be undone.`)){n(null);try{await W(s.id),await r()}catch(a){n(String(a))}}},[r]);return e.jsx(F,{id:c,title:"🛡️ Admin · Users",zIndex:x,width:620,height:540,children:e.jsxs("div",{className:"ml-admin",children:[b&&e.jsx("div",{className:"ml-admin-error",children:b}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsx("h3",{children:"Add user"}),e.jsxs("form",{onSubmit:U,className:"ml-admin-create",children:[e.jsx("input",{type:"text",placeholder:"Username",value:l,onChange:s=>C(s.target.value),disabled:d,autoComplete:"off"}),e.jsx("input",{type:"password",placeholder:"Password (min 4)",value:i,onChange:s=>f(s.target.value),disabled:d,autoComplete:"new-password"}),e.jsxs("label",{children:[e.jsx("input",{type:"checkbox",checked:h,onChange:s=>y(s.target.checked),disabled:d}),"admin"]}),e.jsx("button",{type:"submit",disabled:d||!l.trim()||i.length<4,children:d?"Adding…":"Add"})]})]}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsxs("h3",{children:["Users ",w&&e.jsx("span",{className:"ml-admin-muted",children:"(loading…)"})]}),e.jsxs("table",{className:"ml-admin-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"ID"}),e.jsx("th",{children:"Username"}),e.jsx("th",{children:"Admin"}),e.jsx("th",{children:"Created"}),e.jsx("th",{children:"Actions"})]})}),e.jsxs("tbody",{children:[g.map(s=>{const a=j!=null&&j.username.toLowerCase()===s.username.toLowerCase();return e.jsxs("tr",{children:[e.jsx("td",{children:s.id}),e.jsxs("td",{children:[s.username,a&&e.jsx("span",{className:"ml-admin-muted",children:" (you)"})]}),e.jsx("td",{children:e.jsx("button",{className:"ml-admin-toggle",onClick:()=>v(s),title:"Click to toggle admin",children:s.is_admin?"✓":"–"})}),e.jsx("td",{children:R(s.created_at)}),e.jsx("td",{children:A===s.id?e.jsxs("span",{className:"ml-admin-pw-edit",children:[e.jsx("input",{type:"text",placeholder:"New password",value:o,onChange:E=>m(E.target.value),autoFocus:!0}),e.jsx("button",{onClick:()=>D(s.id),children:"Save"}),e.jsx("button",{onClick:()=>{u(null),m("")},children:"Cancel"})]}):e.jsxs(e.Fragment,{children:[e.jsx("button",{onClick:()=>{u(s.id),m("")},children:"Reset PW"}),!a&&e.jsx("button",{className:"ml-admin-danger",onClick:()=>P(s),children:"Delete"})]})})]},s.id)}),g.length===0&&!w&&e.jsx("tr",{children:e.jsx("td",{colSpan:5,className:"ml-admin-muted",children:"No users."})})]})]})]})]})})};export{q as AdminUsersWindow};
|
||||
|
|
@ -1 +1 @@
|
|||
import{r as n,b as w,c as k,d as E,j as t,D as I}from"./index-Dg_ifawe.js";import"./react-yfL0ty4i.js";const h="overlord_agent_session_id";function v(){if(typeof crypto<"u"&&typeof crypto.randomUUID=="function")return crypto.randomUUID();const s=l=>Math.floor(Math.random()*l);return`${s(4294967296).toString(16).padStart(8,"0")}-${s(65536).toString(16).padStart(4,"0")}-4${s(4096).toString(16).padStart(3,"0")}-${(8+s(4)).toString(16)}${s(4096).toString(16).padStart(3,"0")}-${s(281474976710656).toString(16).padStart(12,"0")}`}function $(){try{const l=localStorage.getItem(h);if(l)return l}catch{}const s=v();try{localStorage.setItem(h,s)}catch{}return s}const _=({id:s,zIndex:l})=>{const[o,j]=n.useState(()=>$()),[d,i]=n.useState([]),[g,m]=n.useState(""),[r,f]=n.useState(!1),[p,x]=n.useState(!0),S=n.useRef(null);n.useEffect(()=>{let e=!1;return x(!0),w(o).then(a=>{if(e)return;const c=(a.messages??[]).map(y=>({role:y.role,text:y.text}));i(c)}).catch(()=>{e||i([])}).finally(()=>{e||x(!1)}),()=>{e=!0}},[o]),n.useEffect(()=>{const e=S.current;e&&(e.scrollTop=e.scrollHeight)},[d.length,r]);const u=n.useCallback(async()=>{const e=g.trim();if(!(!e||r)){m(""),i(a=>[...a,{role:"user",text:e}]),f(!0);try{const a=await k(e,o);i(c=>[...c,{role:a.is_error?"error":"assistant",text:a.result||"(no response)"}])}catch(a){i(c=>[...c,{role:"error",text:`Request failed: ${String(a)}`}])}finally{f(!1)}}},[g,r,o]),N=n.useCallback(async()=>{if(r)return;let e="";try{e=(await E()).session_id}catch{e=v()}try{localStorage.setItem(h,e)}catch{}j(e),i([]),m("")},[r]),b=n.useCallback(e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),u())},[u]);return t.jsx(I,{id:s,title:"🤖 Overlord Assistant",zIndex:l,width:520,height:620,children:t.jsxs("div",{className:"ml-agent",children:[t.jsxs("div",{className:"ml-agent-toolbar",children:[t.jsx("button",{className:"ml-agent-btn",onClick:N,disabled:r,children:"+ New Chat"}),t.jsxs("span",{className:"ml-agent-session",title:o,children:[o.slice(0,8),"…"]})]}),t.jsxs("div",{className:"ml-agent-messages",ref:S,children:[p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Loading conversation…"}),!p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Ask anything about the live game state — players, kills, inventory, suitbuilder, recent rares, etc."}),d.map((e,a)=>t.jsxs("div",{className:`ml-agent-msg ml-agent-${e.role}`,children:[t.jsx("div",{className:"ml-agent-role",children:e.role==="user"?"You":e.role==="assistant"?"Overlord":"Error"}),t.jsx("div",{className:"ml-agent-text",children:e.text})]},a)),r&&t.jsxs("div",{className:"ml-agent-msg ml-agent-assistant",children:[t.jsx("div",{className:"ml-agent-role",children:"Overlord"}),t.jsx("div",{className:"ml-agent-text ml-agent-thinking",children:"Thinking…"})]})]}),t.jsxs("form",{className:"ml-agent-form",onSubmit:e=>{e.preventDefault(),u()},children:[t.jsx("textarea",{className:"ml-agent-input",value:g,onChange:e=>m(e.target.value),onKeyDown:b,placeholder:r?"Waiting for response…":"Type a message — Enter to send, Shift+Enter for newline",disabled:r,rows:2}),t.jsx("button",{type:"submit",className:"ml-agent-send",disabled:r||!g.trim(),children:"Send"})]})]})})};export{_ as AgentWindow};
|
||||
import{r as n,b as w,c as k,d as E,j as t,D as I}from"./index-DlaI7zTK.js";import"./react-yfL0ty4i.js";const h="overlord_agent_session_id";function v(){if(typeof crypto<"u"&&typeof crypto.randomUUID=="function")return crypto.randomUUID();const s=l=>Math.floor(Math.random()*l);return`${s(4294967296).toString(16).padStart(8,"0")}-${s(65536).toString(16).padStart(4,"0")}-4${s(4096).toString(16).padStart(3,"0")}-${(8+s(4)).toString(16)}${s(4096).toString(16).padStart(3,"0")}-${s(281474976710656).toString(16).padStart(12,"0")}`}function $(){try{const l=localStorage.getItem(h);if(l)return l}catch{}const s=v();try{localStorage.setItem(h,s)}catch{}return s}const _=({id:s,zIndex:l})=>{const[o,j]=n.useState(()=>$()),[d,i]=n.useState([]),[g,m]=n.useState(""),[r,f]=n.useState(!1),[p,x]=n.useState(!0),S=n.useRef(null);n.useEffect(()=>{let e=!1;return x(!0),w(o).then(a=>{if(e)return;const c=(a.messages??[]).map(y=>({role:y.role,text:y.text}));i(c)}).catch(()=>{e||i([])}).finally(()=>{e||x(!1)}),()=>{e=!0}},[o]),n.useEffect(()=>{const e=S.current;e&&(e.scrollTop=e.scrollHeight)},[d.length,r]);const u=n.useCallback(async()=>{const e=g.trim();if(!(!e||r)){m(""),i(a=>[...a,{role:"user",text:e}]),f(!0);try{const a=await k(e,o);i(c=>[...c,{role:a.is_error?"error":"assistant",text:a.result||"(no response)"}])}catch(a){i(c=>[...c,{role:"error",text:`Request failed: ${String(a)}`}])}finally{f(!1)}}},[g,r,o]),N=n.useCallback(async()=>{if(r)return;let e="";try{e=(await E()).session_id}catch{e=v()}try{localStorage.setItem(h,e)}catch{}j(e),i([]),m("")},[r]),b=n.useCallback(e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),u())},[u]);return t.jsx(I,{id:s,title:"🤖 Overlord Assistant",zIndex:l,width:520,height:620,children:t.jsxs("div",{className:"ml-agent",children:[t.jsxs("div",{className:"ml-agent-toolbar",children:[t.jsx("button",{className:"ml-agent-btn",onClick:N,disabled:r,children:"+ New Chat"}),t.jsxs("span",{className:"ml-agent-session",title:o,children:[o.slice(0,8),"…"]})]}),t.jsxs("div",{className:"ml-agent-messages",ref:S,children:[p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Loading conversation…"}),!p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Ask anything about the live game state — players, kills, inventory, suitbuilder, recent rares, etc."}),d.map((e,a)=>t.jsxs("div",{className:`ml-agent-msg ml-agent-${e.role}`,children:[t.jsx("div",{className:"ml-agent-role",children:e.role==="user"?"You":e.role==="assistant"?"Overlord":"Error"}),t.jsx("div",{className:"ml-agent-text",children:e.text})]},a)),r&&t.jsxs("div",{className:"ml-agent-msg ml-agent-assistant",children:[t.jsx("div",{className:"ml-agent-role",children:"Overlord"}),t.jsx("div",{className:"ml-agent-text ml-agent-thinking",children:"Thinking…"})]})]}),t.jsxs("form",{className:"ml-agent-form",onSubmit:e=>{e.preventDefault(),u()},children:[t.jsx("textarea",{className:"ml-agent-input",value:g,onChange:e=>m(e.target.value),onKeyDown:b,placeholder:r?"Waiting for response…":"Type a message — Enter to send, Shift+Enter for newline",disabled:r,rows:2}),t.jsx("button",{type:"submit",className:"ml-agent-send",disabled:r||!g.trim(),children:"Send"})]})]})})};export{_ as AgentWindow};
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
import{u as c,j as r,D as d}from"./index-Dg_ifawe.js";import"./react-yfL0ty4i.js";const p=({id:n,zIndex:i,characters:a})=>{const{openWindow:s}=c(),e=Array.from(a.keys()).sort();return r.jsx(d,{id:n,title:"Combat Stats — Select Character",zIndex:i,width:300,height:400,children:r.jsx("div",{style:{flex:1,overflowY:"auto",padding:6},children:e.length===0?r.jsx("div",{style:{padding:12,color:"#666",textAlign:"center",fontSize:"0.8rem"},children:"No characters online"}):e.map(o=>r.jsx("div",{style:{padding:"5px 8px",cursor:"pointer",borderBottom:"1px solid #222",color:"#ccc",fontSize:"0.82rem"},onMouseEnter:t=>t.currentTarget.style.background="#2a2a2a",onMouseLeave:t=>t.currentTarget.style.background="",onClick:()=>s(`combat-${o}`,`Combat: ${o}`,o),children:o},o))})})};export{p as CombatPickerWindow};
|
||||
import{u as c,j as r,D as d}from"./index-DlaI7zTK.js";import"./react-yfL0ty4i.js";const p=({id:n,zIndex:i,characters:a})=>{const{openWindow:s}=c(),e=Array.from(a.keys()).sort();return r.jsx(d,{id:n,title:"Combat Stats — Select Character",zIndex:i,width:300,height:400,children:r.jsx("div",{style:{flex:1,overflowY:"auto",padding:6},children:e.length===0?r.jsx("div",{style:{padding:12,color:"#666",textAlign:"center",fontSize:"0.8rem"},children:"No characters online"}):e.map(o=>r.jsx("div",{style:{padding:"5px 8px",cursor:"pointer",borderBottom:"1px solid #222",color:"#ccc",fontSize:"0.82rem"},onMouseEnter:t=>t.currentTarget.style.background="#2a2a2a",onMouseLeave:t=>t.currentTarget.style.background="",onClick:()=>s(`combat-${o}`,`Combat: ${o}`,o),children:o},o))})})};export{p as CombatPickerWindow};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
import{r as c,j as t,D as u,a as f}from"./index-Dg_ifawe.js";import"./react-yfL0ty4i.js";const j=({id:p,zIndex:x})=>{const[s,h]=c.useState(null);c.useEffect(()=>{const e=async()=>{try{h(await f("/quest-status"))}catch{}};e();const o=setInterval(e,3e4);return()=>clearInterval(o)},[]);const i=s?Object.keys(s.quest_data).sort():[],l=new Set;if(s)for(const e of Object.values(s.quest_data))for(const o of Object.keys(e))l.add(o);const n=Array.from(l).sort();return t.jsx(u,{id:p,title:"Quest Status",zIndex:x,width:780,height:500,children:t.jsx("div",{style:{flex:1,overflow:"auto",fontSize:"0.72rem"},children:s?i.length===0?t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"No quest data available"}):t.jsxs("table",{style:{width:"100%",borderCollapse:"collapse"},children:[t.jsx("thead",{children:t.jsxs("tr",{style:{position:"sticky",top:0,background:"#1a1a1a",zIndex:1},children:[t.jsx("th",{style:{textAlign:"left",padding:"4px 8px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.65rem",fontWeight:600,minWidth:140},children:"Character"}),n.map(e=>t.jsx("th",{style:{textAlign:"center",padding:"4px 6px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.6rem",fontWeight:600,maxWidth:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},title:e,children:e.replace(" Timer","").replace(" Pickup","")},e))]})}),t.jsx("tbody",{children:i.map(e=>{const o=s.quest_data[e]||{};return t.jsxs("tr",{style:{borderBottom:"1px solid #222"},children:[t.jsx("td",{style:{padding:"3px 8px",color:"#ccc",fontWeight:500,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis",maxWidth:160},children:e}),n.map(d=>{const r=o[d],a=r==="READY";return t.jsx("td",{style:{textAlign:"center",padding:"3px 6px",color:a?"#4c4":r?"#ca0":"#333",fontWeight:a?600:400,fontSize:a?"0.7rem":"0.68rem"},children:r||"—"},d)})]},e)})})]}):t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"Loading quest data..."})})})};export{j as QuestStatusWindow};
|
||||
import{r as c,j as t,D as u,a as f}from"./index-DlaI7zTK.js";import"./react-yfL0ty4i.js";const j=({id:p,zIndex:x})=>{const[s,h]=c.useState(null);c.useEffect(()=>{const e=async()=>{try{h(await f("/quest-status"))}catch{}};e();const o=setInterval(e,3e4);return()=>clearInterval(o)},[]);const i=s?Object.keys(s.quest_data).sort():[],l=new Set;if(s)for(const e of Object.values(s.quest_data))for(const o of Object.keys(e))l.add(o);const n=Array.from(l).sort();return t.jsx(u,{id:p,title:"Quest Status",zIndex:x,width:780,height:500,children:t.jsx("div",{style:{flex:1,overflow:"auto",fontSize:"0.72rem"},children:s?i.length===0?t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"No quest data available"}):t.jsxs("table",{style:{width:"100%",borderCollapse:"collapse"},children:[t.jsx("thead",{children:t.jsxs("tr",{style:{position:"sticky",top:0,background:"#1a1a1a",zIndex:1},children:[t.jsx("th",{style:{textAlign:"left",padding:"4px 8px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.65rem",fontWeight:600,minWidth:140},children:"Character"}),n.map(e=>t.jsx("th",{style:{textAlign:"center",padding:"4px 6px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.6rem",fontWeight:600,maxWidth:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},title:e,children:e.replace(" Timer","").replace(" Pickup","")},e))]})}),t.jsx("tbody",{children:i.map(e=>{const o=s.quest_data[e]||{};return t.jsxs("tr",{style:{borderBottom:"1px solid #222"},children:[t.jsx("td",{style:{padding:"3px 8px",color:"#ccc",fontWeight:500,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis",maxWidth:160},children:e}),n.map(d=>{const r=o[d],a=r==="READY";return t.jsx("td",{style:{textAlign:"center",padding:"3px 6px",color:a?"#4c4":r?"#ca0":"#333",fontWeight:a?600:400,fontSize:a?"0.7rem":"0.68rem"},children:r||"—"},d)})]},e)})})]}):t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"Loading quest data..."})})})};export{j as QuestStatusWindow};
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
import{r as o,j as t,D as d}from"./index-Dg_ifawe.js";import"./react-yfL0ty4i.js";const c=[{title:"Kills per Hour",id:1},{title:"Memory (MB)",id:2},{title:"CPU (%)",id:3},{title:"Mem Handles",id:4}],m=[{label:"1H",value:"now-1h"},{label:"6H",value:"now-6h"},{label:"24H",value:"now-24h"},{label:"7D",value:"now-7d"}],v=({id:s,charName:a,zIndex:i})=>{const[l,r]=o.useState("now-24h"),n=e=>`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${e}&var-character=${encodeURIComponent(a)}&from=${l}&to=now&theme=light`;return t.jsxs(d,{id:s,title:`Stats: ${a}`,zIndex:i,width:750,height:480,children:[t.jsx("div",{className:"ml-stats-controls",children:m.map(e=>t.jsx("button",{className:`ml-stats-range-btn ${l===e.value?"active":""}`,onClick:()=>r(e.value),children:e.label},e.value))}),t.jsx("div",{className:"ml-stats-grid",children:c.map(e=>t.jsx("div",{className:"ml-stats-panel",children:t.jsx("iframe",{src:n(e.id),width:"100%",height:"100%",frameBorder:"0",title:e.title})},e.id))})]})};export{v as StatsWindow};
|
||||
import{r as o,j as t,D as d}from"./index-DlaI7zTK.js";import"./react-yfL0ty4i.js";const c=[{title:"Kills per Hour",id:1},{title:"Memory (MB)",id:2},{title:"CPU (%)",id:3},{title:"Mem Handles",id:4}],m=[{label:"1H",value:"now-1h"},{label:"6H",value:"now-6h"},{label:"24H",value:"now-24h"},{label:"7D",value:"now-7d"}],v=({id:s,charName:a,zIndex:i})=>{const[l,r]=o.useState("now-24h"),n=e=>`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${e}&var-character=${encodeURIComponent(a)}&from=${l}&to=now&theme=light`;return t.jsxs(d,{id:s,title:`Stats: ${a}`,zIndex:i,width:750,height:480,children:[t.jsx("div",{className:"ml-stats-controls",children:m.map(e=>t.jsx("button",{className:`ml-stats-range-btn ${l===e.value?"active":""}`,onClick:()=>r(e.value),children:e.label},e.value))}),t.jsx("div",{className:"ml-stats-grid",children:c.map(e=>t.jsx("div",{className:"ml-stats-panel",children:t.jsx("iframe",{src:n(e.id),width:"100%",height:"100%",frameBorder:"0",title:e.title})},e.id))})]})};export{v as StatsWindow};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue