diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9f24789 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +**/node_modules +**/dist +server/data +.git +*.log +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c7fc989 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Copy to .env next to docker-compose.yml and fill in. + +# Full connection string to your shared Postgres on the `backend` network. +# Use the Postgres container/service name as the host (e.g. `postgres`). +# Create the database first: CREATE DATABASE albion; +DATABASE_URL=postgres://albion:CHANGE_ME@postgres:5432/albion + +# Bearer token the web UI and the Windows agent must send. Generate one, e.g.: +# openssl rand -hex 32 +ALBION_API_KEY=change-me-to-a-long-random-string diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..33346f1 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,68 @@ +name: Deploy + +# Deploy on every push to master (and allow manual runs from the Actions tab). +on: + push: + branches: [master] + workflow_dispatch: + +# A STABLE compose project name so the named volume (albion_data, the downloaded +# game-data JSON cache) is the same on every run. App state (prices, profiles, +# world map) lives in the shared Postgres, not the volume. Compose prefixes +# volumes with the project name, so a changing name would orphan the cache. +env: + COMPOSE_PROJECT_NAME: albion + +jobs: + deploy: + # `docker` label => node:22-bookworm with the host Docker socket available. + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + + # The job image has the Docker socket available but may not ship the docker + # CLI / compose plugin. Install them only if missing (no-op otherwise) so + # `docker compose ...` can talk to the host daemon. + - name: Ensure Docker CLI + Compose plugin + run: | + if ! command -v docker >/dev/null 2>&1; then + curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \ + | tar -xz -C /usr/local/bin --strip-components=1 docker/docker + fi + if ! docker compose version >/dev/null 2>&1; then + mkdir -p /usr/local/lib/docker/cli-plugins + curl -fsSL https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-linux-x86_64 \ + -o /usr/local/lib/docker/cli-plugins/docker-compose + chmod +x /usr/local/lib/docker/cli-plugins/docker-compose + fi + docker version + docker compose version + + # `proxy` is the shared external network Nginx Proxy Manager sits on. + # Create it if it doesn't exist yet so `docker compose up` won't fail. + - name: Ensure 'proxy' network exists + run: docker network inspect proxy >/dev/null 2>&1 || docker network create proxy + + # The compose file requires DATABASE_URL and ALBION_API_KEY. Provide them + # from Forgejo Actions secrets so they never live in the repo. Written to + # .env, which docker compose reads automatically. + - name: Write .env + run: | + { + echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" + echo "ALBION_API_KEY=${{ secrets.ALBION_API_KEY }}" + } > .env + + # Build the image locally on the host (no external registry needed). + - name: Build image + run: docker compose build + + # Reconcile the stack: compose only recreates services whose config or + # image actually changed, and cleans up any services removed from the file. + - name: Deploy stack + run: docker compose up -d --remove-orphans + + # Free disk on the shared runner by dropping old, untagged images. + - name: Prune old images + run: docker image prune -f diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7ddb58c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1 + +# Multi-stage build: install workspace deps, build the web UI, run the server +# (which serves the built UI and connects to the shared Postgres). The agent +# workspace is NOT in the image — it runs on the player's PC. +FROM node:22-bookworm-slim AS build +WORKDIR /app + +# Install deps using the workspace manifests (better layer caching). No native +# toolchain needed — the data layer is pure-JS `pg`. +COPY package.json package-lock.json ./ +COPY shared/package.json ./shared/ +COPY server/package.json ./server/ +COPY web/package.json ./web/ +COPY agent/package.json ./agent/ +RUN npm ci + +# Copy sources and build the web UI. +COPY shared ./shared +COPY server ./server +COPY web ./web +RUN npm run build -w web + +# ---- runtime image ---- +FROM node:22-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=4310 \ + ALBION_DATA_DIR=/data \ + WEB_DIST=/app/web/dist + +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/package.json ./package.json +COPY --from=build /app/shared ./shared +COPY --from=build /app/server ./server +COPY --from=build /app/web/dist ./web/dist + +# Downloaded game-data JSON cache lives here (the DB is external Postgres). +RUN mkdir -p /data +VOLUME /data +EXPOSE 4310 + +# The server creates its schema and, on first boot with an empty DB, downloads +# and parses game data in the background. +CMD ["node", "--import", "tsx", "server/src/index.ts"] diff --git a/README.md b/README.md index f6caca0..4a7fbb5 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,76 @@ # Albion Economy Assistant -A local web app that tells you what to craft, refine, enchant, haul, and watch in Albion Online — using live market prices, the game's own recipe data, and **your** character's destiny board progress. +Tells you what to craft, refine, enchant, haul, and watch in Albion Online — using live market prices, the game's own recipe data, and **your** character's destiny board progress. -## Run it +## Architecture + +Three deployable pieces plus a shared library: + +- **server/** — Fastify + TypeScript API that also serves the web UI. Stores everything in a **shared Postgres**. Deployable (Docker) or run locally. +- **web/** — Vite + React UI (built and served by the server in production). +- **agent/** — a small **Windows companion** that runs on your gaming PC. It reads game-local data a deployed server can't see (world map, live market orders) and pushes it to the server over an authenticated API. See `agent/README.md`. +- **shared/** — world-map parsing + packet capture, reused by server and agent. ``` +[ Your gaming PC ] [ Server (VPS / Forgejo deploy) ] + Albion Online albion-economy container + agent ── HTTPS + API key ──► /api/ingest/* ├─ serves web UI + (world map, market orders) └─ Postgres (shared, `backend` net) + Nginx Proxy Manager (`proxy` net) → subdomain + You ── browser ──────────────► the same server (web UI, API key in browser) +``` + +## Run locally (single machine) + +Needs a Postgres to point at. Quick throwaway one: + +``` +docker run -d --name albion-pg -e POSTGRES_PASSWORD=pw -e POSTGRES_DB=albion -p 5432:5432 postgres:16-alpine +export DATABASE_URL=postgres://postgres:pw@127.0.0.1:5432/albion # PowerShell: $env:DATABASE_URL=... npm install -npm run gamedata # first time only: download items/recipes (~40 MB) -npm run dev # starts API (port 4310) + UI +npm run gamedata # download items/recipes into Postgres (first time) +npm run dev # API on :4310, Vite UI on :5173 ``` -Open **http://localhost:5173**. +Open **http://localhost:5173**. With no `ALBION_API_KEY` set, the API is open (fine for localhost). -## First-time setup +## Deploy (Docker + Forgejo) -1. **Settings page** — pick your Albion server (Europe is default). If `albion-online-data.com` is blocked in your country, turn on your VPN before downloading prices (or set a proxy URL if your VPN is browser-only). Set your game folder (e.g. `E:\albion`) for the travel planner. -2. **Market Data page** — click *Download prices* for your tier range. Prices show age badges (green = fresh); you can manually override any price, and manual prices always win over API data. -3. **Character page** — create a profile, set premium, and enter your destiny board levels per crafting category (mastery / spec / other specs). Every calculation uses the active profile. -4. **Travel Planner page** — click *Import world map* (reads zone connections from your game's files, read-only). +The repo ships a `Dockerfile`, `docker-compose.yml`, and `.forgejo/workflows/deploy.yml`. + +1. On the host, have your shared Postgres reachable on an external Docker network named **`backend`**, and Nginx Proxy Manager on **`proxy`**. Create the database: `CREATE DATABASE albion;` +2. Add Forgejo Actions **secrets**: `DATABASE_URL` (e.g. `postgres://albion:pw@postgres:5432/albion`) and `ALBION_API_KEY` (a long random string). +3. Push to `master` (or run the workflow manually). It builds the image and `docker compose up -d`. The container joins `proxy` + `backend`; point an NPM proxy host at `albion-economy:4310` and assign your subdomain. +4. First boot auto-downloads and parses game data into Postgres. + +Locally you can mirror it: create `.env` from `.env.example`, ensure the `proxy`/`backend` networks exist, then `docker compose up -d`. + +### Security + +- **`ALBION_API_KEY` is required in the compose file.** Every `/api/*` route (except `/api/health`) needs `Authorization: Bearer ` (or `?apiKey=`). The web UI prompts for it once and stores it in the browser; the agent sends it from its config. +- Put it behind HTTPS via NPM. For extra safety, keep the subdomain access-listed or behind a private network — an API key is a floor, not a ceiling. ## Pages -- **Opportunities** — ranks every craft/refine/enchant by profit for a chosen city: return rates (city bonuses + focus), station fees from item values, taxes, and recursive buy-vs-craft decisions for inputs. Sort by profit, ROI, or silver-per-focus. -- **Travel Planner** — routes between any two zones, *safe zones only* or *all zones* (shows red/black zone count), plus a haul advisor that ranks what's worth carrying between two cities for your carry weight. -- **Market Watch** — price alert rules (drops below / rises above / buy orders above) checked on an interval. -- **Game Sniffer** — optional: passively reads your own game traffic (requires [Wireshark](https://www.wireshark.org) with Npcap). While capturing, every marketplace you open in game feeds firsthand prices into the app. Passive read-only capture, the same SBI-tolerated technique as the Albion Data Project client. -- **Item Browser / Game Data** — recipe explorer; re-download game data after patches. +- **Opportunities** — ranks every craft/refine/enchant for a city by profit: return rates (city bonuses + focus), station fees, taxes, and recursive buy-vs-craft input decisions. Sort by profit, ROI, or silver-per-focus. Uses your active character profile. +- **Travel Planner** — safe-only vs all-zones routing (with red/black danger counts) over the real zone graph, plus a haul advisor ranking what to carry between cities by profit per weight. +- **Market Watch** — price alert rules checked on an interval. +- **Character** — destiny board profiles (mastery/spec per category) that drive focus costs and taxes. +- **Game Sniffer** — status of firsthand price capture (when the server runs on your PC; otherwise use the agent). +- **Item Browser / Game Data / Settings** — recipe explorer, patch-day refresh, server/proxy/game-path config. + +## Character & game-local data + +There is no official API for character progress, so the **Character** page lets you enter destiny board levels by hand. The **agent** can also push world-map and market data from your PC. Capturing your own traffic is the SBI-tolerated technique (Albion Data Project style) — read-only, no memory reading, no injection. ## After a game patch -- *Game Data* page → re-download game data. -- *Travel Planner* → re-import the world map. -- If balance values changed (return rates, taxes, city bonuses), edit them via `PUT /api/economy/config` — all game constants are config, not code. - -## Data sources - -- Recipes/items/zones: [ao-data/ao-bin-dumps](https://github.com/ao-data/ao-bin-dumps) + your local game files (read-only) -- Market prices: [Albion Online Data Project](https://www.albion-online-data.com) + optional local sniffer + manual overrides -- Character data: entered manually (no official API exists); sniffer-assisted auto-fill planned +- *Game Data* page → re-download game data (or the agent/`npm run gamedata`). +- *Travel Planner* → re-import the world map (or `npm run agent -- world`). +- Balance constants (return rates, taxes, city bonuses) are editable via `PUT /api/economy/config` — config, not code. ## Tests ``` -npm test +npm test # economy math (vitest); the engine is pure, no DB needed ``` diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..450dee6 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,47 @@ +# Albion Companion Agent + +Runs on your **gaming PC**. Reads game-local data a deployed server can't see and pushes it to your server: + +- **World map** — parsed from the game's `world.bin` (read-only), uploaded once. +- **Market orders** — passively captured while you play; every marketplace you open records firsthand prices. + +Read-only observation only — the SBI-tolerated technique (same as the Albion Data Project client). No memory reading, no injection, no automation. + +## Requirements + +- [Wireshark](https://www.wireshark.org) installed **with the bundled Npcap driver** (the agent uses `tshark` to capture your own traffic). +- Node.js 20+. + +## Setup + +``` +npm install # from the repo root (installs all workspaces) +npm run agent -- init # writes a config file, prints its path +``` + +Edit the config file (`%APPDATA%\albion-agent\config.json`): + +```json +{ + "serverUrl": "https://albion.yourdomain.com", + "apiKey": "the-same-key-as-ALBION_API_KEY-on-the-server", + "gamePath": "E:\\albion", + "interfaces": [], + "orderFlushSeconds": 15 +} +``` + +`interfaces: []` captures on all adapters. To narrow it down: + +``` +npm run agent -- interfaces +``` + +## Run + +``` +npm run agent # uploads world map, then streams market orders (Ctrl+C to stop) +npm run agent -- world # just upload the world map and exit +``` + +Only run it while you play — that's when the data exists. Everything else (prices from the community API, calculations, alerts, the web UI) lives on the server and runs 24/7 without this. diff --git a/agent/package.json b/agent/package.json new file mode 100644 index 0000000..3cfac38 --- /dev/null +++ b/agent/package.json @@ -0,0 +1,18 @@ +{ + "name": "@albion/agent", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@albion/shared": "*" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "tsx": "^4.20.0", + "typescript": "^5.8.0" + } +} diff --git a/agent/src/api.ts b/agent/src/api.ts new file mode 100644 index 0000000..6c25dca --- /dev/null +++ b/agent/src/api.ts @@ -0,0 +1,36 @@ +import type { AlbionOrder, ParsedWorld } from '@albion/shared'; +import type { AgentConfig } from './config.js'; + +/** Thin client for the server's ingest endpoints. */ +export class ServerClient { + constructor(private cfg: AgentConfig) {} + + private async post(pathname: string, body: unknown): Promise { + const res = await fetch(`${this.cfg.serverUrl}${pathname}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(this.cfg.apiKey ? { Authorization: `Bearer ${this.cfg.apiKey}` } : {}), + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30000), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`${pathname} -> HTTP ${res.status} ${text}`); + } + return res.json(); + } + + ping(): Promise { + return fetch(`${this.cfg.serverUrl}/api/health`, { signal: AbortSignal.timeout(10000) }).then((r) => r.json()); + } + + sendWorld(world: ParsedWorld): Promise { + return this.post('/api/ingest/world', { world }); + } + + sendOrders(orders: AlbionOrder[]): Promise { + return this.post('/api/ingest/orders', { orders }); + } +} diff --git a/agent/src/config.ts b/agent/src/config.ts new file mode 100644 index 0000000..f6752d0 --- /dev/null +++ b/agent/src/config.ts @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * Agent config, resolved from (in order): env vars, a config file, defaults. + * Config file lives at %APPDATA%\albion-agent\config.json (or ALBION_AGENT_CONFIG). + */ +export interface AgentConfig { + serverUrl: string; // e.g. https://albion.example.com + apiKey: string; + gamePath: string; // folder containing the "game" directory, e.g. E:\albion + interfaces: string[]; // tshark interface ids; empty = all + orderFlushSeconds: number; +} + +const DEFAULTS: AgentConfig = { + serverUrl: 'http://127.0.0.1:4310', + apiKey: '', + gamePath: 'E:\\albion', + interfaces: [], + orderFlushSeconds: 15, +}; + +export function configPath(): string { + if (process.env.ALBION_AGENT_CONFIG) return process.env.ALBION_AGENT_CONFIG; + const base = process.env.APPDATA ?? path.join(os.homedir(), '.config'); + return path.join(base, 'albion-agent', 'config.json'); +} + +export function loadConfig(): AgentConfig { + let fileCfg: Partial = {}; + const p = configPath(); + if (fs.existsSync(p)) { + try { + fileCfg = JSON.parse(fs.readFileSync(p, 'utf8')); + } catch { + console.warn(`Could not parse ${p}, ignoring.`); + } + } + return { + serverUrl: process.env.ALBION_SERVER_URL ?? fileCfg.serverUrl ?? DEFAULTS.serverUrl, + apiKey: process.env.ALBION_API_KEY ?? fileCfg.apiKey ?? DEFAULTS.apiKey, + gamePath: process.env.ALBION_GAME_PATH ?? fileCfg.gamePath ?? DEFAULTS.gamePath, + interfaces: fileCfg.interfaces ?? DEFAULTS.interfaces, + orderFlushSeconds: fileCfg.orderFlushSeconds ?? DEFAULTS.orderFlushSeconds, + }; +} + +export function saveConfig(cfg: AgentConfig): void { + const p = configPath(); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(cfg, null, 2)); +} diff --git a/agent/src/index.ts b/agent/src/index.ts new file mode 100644 index 0000000..6b4cdde --- /dev/null +++ b/agent/src/index.ts @@ -0,0 +1,143 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + decryptBin, + listInterfaces, + parseWorldXml, + startCapture, + WORLD_BIN_SUBPATH, + type AlbionOrder, +} from '@albion/shared'; +import { ServerClient } from './api.js'; +import { loadConfig, saveConfig, configPath, type AgentConfig } from './config.js'; + +/** + * Albion companion agent — runs on the gaming PC. It reads game-local data that + * a deployed server can't see and pushes it to the server: + * - world map (one-shot, from the game's world.bin) + * - live market orders (passive capture while you play) + * Read-only. No memory reading, no injection, no automation. + * + * Usage: + * npm run agent -- interfaces list capture interfaces + * npm run agent -- world parse + upload the world map, then exit + * npm run agent -- run upload world map, then stream market orders (default) + * npm run agent -- init write a starter config file + */ + +function resolveWorldBin(gamePath: string): string | null { + const direct = path.join(gamePath, WORLD_BIN_SUBPATH); + if (fs.existsSync(direct)) return direct; + if (gamePath.endsWith('world.bin') && fs.existsSync(gamePath)) return gamePath; + return null; +} + +async function uploadWorld(client: ServerClient, cfg: AgentConfig): Promise { + const binPath = resolveWorldBin(cfg.gamePath); + if (!binPath) { + console.error(`world.bin not found under "${cfg.gamePath}". Set gamePath in ${configPath()}.`); + return; + } + const xml = decryptBin(fs.readFileSync(binPath)).toString('utf8'); + const world = parseWorldXml(xml); + const res = await client.sendWorld(world); + console.log(`Uploaded world map: ${res.zones} zones, ${res.connections} connections.`); +} + +function streamOrders(client: ServerClient, cfg: AgentConfig): void { + let buffer: AlbionOrder[] = []; + let packets = 0; + let sent = 0; + + const capture = startCapture( + { + onPacket: () => { + packets++; + }, + onOrders: (orders) => { + buffer.push(...orders); + }, + onError: (message) => console.error('capture:', message), + }, + cfg.interfaces, + ); + + if ('error' in capture) { + console.error(capture.error); + process.exit(1); + } + + const flush = async () => { + if (buffer.length === 0) return; + const batch = buffer; + buffer = []; + try { + const res = await client.sendOrders(batch); + sent += res.pricesStored ?? 0; + console.log(`[${new Date().toLocaleTimeString()}] sent ${batch.length} orders (${res.pricesStored} prices) · ${packets} packets seen · ${sent} prices total`); + } catch (e) { + buffer.unshift(...batch); // retry next tick + console.error('upload failed (will retry):', e instanceof Error ? e.message : e); + } + }; + + const timer = setInterval(() => void flush(), Math.max(5, cfg.orderFlushSeconds) * 1000); + timer.unref?.(); + + console.log('Capturing market orders — open a marketplace in game to record prices. Ctrl+C to stop.'); + const shutdown = () => { + clearInterval(timer); + capture.stop(); + void flush().finally(() => process.exit(0)); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +async function main(): Promise { + const command = process.argv[2] ?? 'run'; + const cfg = loadConfig(); + + if (command === 'init') { + saveConfig(cfg); + console.log(`Wrote config to ${configPath()}. Edit serverUrl, apiKey and gamePath, then run: npm run agent`); + return; + } + + if (command === 'interfaces') { + const ifaces = listInterfaces(); + if (ifaces.length === 0) { + console.error('No interfaces found. Is Wireshark/tshark installed?'); + return; + } + for (const i of ifaces) console.log(`${i.id}\t${i.name}`); + console.log('\nAdd the ids you want to "interfaces" in your config, or leave empty for all.'); + return; + } + + const client = new ServerClient(cfg); + console.log(`Server: ${cfg.serverUrl}`); + try { + await client.ping(); + console.log('Server reachable.'); + } catch (e) { + console.error(`Cannot reach server at ${cfg.serverUrl}:`, e instanceof Error ? e.message : e); + process.exit(1); + } + + if (command === 'world') { + await uploadWorld(client, cfg); + return; + } + + if (command === 'run') { + await uploadWorld(client, cfg).catch((e) => console.error('world upload failed:', e instanceof Error ? e.message : e)); + streamOrders(client, cfg); + return; + } + + console.error(`Unknown command: ${command}. Use: init | interfaces | world | run`); + process.exit(1); +} + +void main(); diff --git a/agent/tsconfig.json b/agent/tsconfig.json new file mode 100644 index 0000000..27a0b58 --- /dev/null +++ b/agent/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["src", "../shared/src"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..15d3b41 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + albion: + build: + context: . + dockerfile: Dockerfile + image: albion-economy:latest + container_name: albion-economy + restart: unless-stopped + environment: + # Shared Postgres reachable on the internal `backend` network. Set the full + # URL in a .env file next to this compose, e.g. + # DATABASE_URL=postgres://albion:pw@postgres:5432/albion + DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL in .env} + # REQUIRED for public deployment — the agent and web UI send this as a + # Bearer token. Set it in .env too. + ALBION_API_KEY: ${ALBION_API_KEY:?set ALBION_API_KEY in .env} + HOST: 0.0.0.0 + PORT: "4310" + # Downloaded game-data JSON cache (not the DB) lives here. + ALBION_DATA_DIR: /data + volumes: + - albion_data:/data + networks: + - proxy # Nginx Proxy Manager terminates TLS + assigns the subdomain + - backend # shared Postgres lives here + # No host port publish: NPM reverse-proxies to albion:4310 on the proxy network. + # To run standalone without NPM, uncomment: + # ports: + # - "4310:4310" + +volumes: + albion_data: + +networks: + proxy: + external: true + backend: + external: true diff --git a/package-lock.json b/package-lock.json index b91d4fa..3eee769 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,17 +8,39 @@ "name": "albion-economy-assistant", "version": "0.1.0", "workspaces": [ + "shared", "server", - "web" + "web", + "agent" ], "devDependencies": { "concurrently": "^9.1.2" } }, + "agent": { + "name": "@albion/agent", + "version": "0.1.0", + "dependencies": { + "@albion/shared": "*" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "tsx": "^4.20.0", + "typescript": "^5.8.0" + } + }, + "node_modules/@albion/agent": { + "resolved": "agent", + "link": true + }, "node_modules/@albion/server": { "resolved": "server", "link": true }, + "node_modules/@albion/shared": { + "resolved": "shared", + "link": true + }, "node_modules/@albion/web": { "resolved": "web", "link": true @@ -1345,16 +1367,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1390,6 +1402,18 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -1687,26 +1711,6 @@ "fastq": "^1.17.1" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/baseline-browser-mapping": { "version": "2.11.18", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", @@ -1720,40 +1724,6 @@ "node": ">=6.0.0" } }, - "node_modules/better-sqlite3": { - "version": "12.11.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", - "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -1788,30 +1758,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffercursor": { "version": "0.0.12", "resolved": "https://registry.npmjs.org/buffercursor/-/buffercursor-0.0.12.tgz", @@ -1911,12 +1857,6 @@ "node": ">= 16" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -2028,21 +1968,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2053,15 +1978,6 @@ "node": ">=6" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2081,15 +1997,6 @@ "minimalistic-assert": "^1.0.0" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.412", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", @@ -2104,15 +2011,6 @@ "dev": true, "license": "MIT" }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2182,15 +2080,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2382,12 +2271,6 @@ } } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/find-my-way": { "version": "9.9.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", @@ -2402,12 +2285,6 @@ "node": ">=20" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2443,12 +2320,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2459,38 +2330,12 @@ "node": ">=8" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/ipaddr.js": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", @@ -2644,39 +2489,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", "license": "ISC" }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2703,36 +2521,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/node-abi": { - "version": "3.94.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", - "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-releases": { "version": "2.0.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", @@ -2752,15 +2540,6 @@ "node": ">=14.0.0" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/path-expression-matcher": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", @@ -2793,6 +2572,95 @@ "node": ">= 14.16" } }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/photon-packet-parser": { "version": "0.0.19", "resolved": "https://registry.npmjs.org/photon-packet-parser/-/photon-packet-parser-0.0.19.tgz", @@ -2888,31 +2756,43 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "license": "MIT", "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" + "xtend": "^4.0.0" }, "engines": { - "node": ">=10" + "node": ">=0.10.0" } }, "node_modules/process-warning": { @@ -2931,37 +2811,12 @@ ], "license": "MIT" }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -2993,20 +2848,6 @@ "node": ">=0.10.0" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -3116,26 +2957,6 @@ "tslib": "^2.1.0" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-regex2": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", @@ -3225,51 +3046,6 @@ "dev": true, "license": "ISC" }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -3312,15 +3088,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3349,15 +3116,6 @@ "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -3409,34 +3167,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", @@ -3561,18 +3291,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3634,12 +3352,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -3860,12 +3572,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/xml-naming": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", @@ -3881,6 +3587,15 @@ "node": ">=16.0.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -3931,21 +3646,32 @@ "name": "@albion/server", "version": "0.1.0", "dependencies": { - "better-sqlite3": "^12.2.0", - "des.js": "^1.1.0", - "fast-xml-parser": "^5.11.0", + "@albion/shared": "*", "fastify": "^5.4.0", - "photon-packet-parser": "^0.0.19", + "pg": "^8.13.1", "undici": "^8.10.0" }, "devDependencies": { - "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.13.3", + "@types/pg": "^8.11.10", "tsx": "^4.20.0", "typescript": "^5.8.0", "vitest": "^3.2.0" } }, + "shared": { + "name": "@albion/shared", + "version": "0.1.0", + "dependencies": { + "des.js": "^1.1.0", + "fast-xml-parser": "^5.11.0", + "photon-packet-parser": "^0.0.19" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "typescript": "^5.8.0" + } + }, "web": { "name": "@albion/web", "version": "0.1.0", diff --git a/package.json b/package.json index 6c79254..27c12bd 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,17 @@ "private": true, "description": "Albion Online economy assistant: profit calculator, production chain ranker, travel planner, market watcher", "workspaces": [ + "shared", "server", - "web" + "web", + "agent" ], "scripts": { "dev": "concurrently -n server,web -c blue,green \"npm:dev:server\" \"npm:dev:web\"", "dev:server": "npm run dev -w server", "dev:web": "npm run dev -w web", "gamedata": "npm run gamedata -w server", + "agent": "npm run start -w agent", "test": "npm run test -w server" }, "devDependencies": { diff --git a/server/package.json b/server/package.json index 5654876..9568749 100644 --- a/server/package.json +++ b/server/package.json @@ -11,16 +11,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "better-sqlite3": "^12.2.0", - "des.js": "^1.1.0", - "fast-xml-parser": "^5.11.0", + "@albion/shared": "*", "fastify": "^5.4.0", - "photon-packet-parser": "^0.0.19", + "pg": "^8.13.1", "undici": "^8.10.0" }, "devDependencies": { - "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.13.3", + "@types/pg": "^8.11.10", "tsx": "^4.20.0", "typescript": "^5.8.0", "vitest": "^3.2.0" diff --git a/server/src/auth.ts b/server/src/auth.ts new file mode 100644 index 0000000..4e13863 --- /dev/null +++ b/server/src/auth.ts @@ -0,0 +1,32 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +/** + * Optional API-key gate. When ALBION_API_KEY is set (required for any public + * deployment), every /api route except health needs `Authorization: Bearer ` + * or `?apiKey=`. When unset (localhost dev), the API is open. + * + * For real internet exposure, ALSO put this behind HTTPS + a private network + * (Tailscale/WireGuard) — an API key alone is a minimum, not a full solution. + */ +export function registerAuth(app: FastifyInstance): void { + const key = process.env.ALBION_API_KEY?.trim(); + if (!key) { + app.log.warn('ALBION_API_KEY not set — API is UNAUTHENTICATED (fine for localhost, unsafe if exposed).'); + return; + } + + app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { + const url = req.url.split('?')[0]; + // allow health checks and the static web UI without a key + if (url === '/api/health' || !url.startsWith('/api/')) return; + + const header = req.headers.authorization; + const bearer = header?.startsWith('Bearer ') ? header.slice(7).trim() : undefined; + const queryKey = (req.query as { apiKey?: string })?.apiKey; + const provided = bearer ?? queryKey; + + if (provided !== key) { + return reply.code(401).send({ error: 'unauthorized — missing or invalid API key' }); + } + }); +} diff --git a/server/src/character/service.ts b/server/src/character/service.ts index 1a3ae62..38ee0a9 100644 --- a/server/src/character/service.ts +++ b/server/src/character/service.ts @@ -1,4 +1,4 @@ -import { getDb } from '../db/index.js'; +import { get, query, transaction } from '../db/index.js'; /** * Character profile: the user's destiny board progress, entered manually @@ -49,59 +49,59 @@ function rowToProfile(r: any): Profile { return { id: r.id, name: r.name, isActive: !!r.is_active, premium: !!r.premium, data, updatedAt: r.updated_at }; } -export function listProfiles(): Profile[] { - return (getDb().prepare('SELECT * FROM profiles ORDER BY id').all() as any[]).map(rowToProfile); +export async function listProfiles(): Promise { + return (await query('SELECT * FROM profiles ORDER BY id')).map(rowToProfile); } -export function getActiveProfile(): Profile | null { - const row = getDb().prepare('SELECT * FROM profiles WHERE is_active = 1 LIMIT 1').get(); +export async function getActiveProfile(): Promise { + const row = await get('SELECT * FROM profiles WHERE is_active = 1 LIMIT 1'); return row ? rowToProfile(row) : null; } -export function createProfile(name: string): Profile { - const db = getDb(); - const { lastInsertRowid } = db - .prepare('INSERT INTO profiles (name, is_active, premium, data, updated_at) VALUES (?, 0, 1, ?, ?)') - .run(name, '{}', new Date().toISOString()); - const hasActive = db.prepare('SELECT COUNT(*) AS n FROM profiles WHERE is_active = 1').get() as { n: number }; - if (hasActive.n === 0) db.prepare('UPDATE profiles SET is_active = 1 WHERE id = ?').run(lastInsertRowid); - return rowToProfile(db.prepare('SELECT * FROM profiles WHERE id = ?').get(lastInsertRowid)); +export async function createProfile(name: string): Promise { + const inserted = await get<{ id: number }>( + 'INSERT INTO profiles (name, is_active, premium, data, updated_at) VALUES (?, 0, 1, ?, ?) RETURNING id', + [name, '{}', new Date().toISOString()], + ); + const id = inserted!.id; + const active = await get<{ n: number }>('SELECT COUNT(*) AS n FROM profiles WHERE is_active = 1'); + if ((active?.n ?? 0) === 0) await query('UPDATE profiles SET is_active = 1 WHERE id = ?', [id]); + return rowToProfile(await get('SELECT * FROM profiles WHERE id = ?', [id])); } -export function updateProfile(id: number, patch: { name?: string; premium?: boolean; data?: ProfileData }): Profile | null { - const db = getDb(); - const row = db.prepare('SELECT * FROM profiles WHERE id = ?').get(id) as any; +export async function updateProfile( + id: number, + patch: { name?: string; premium?: boolean; data?: ProfileData }, +): Promise { + const row = (await get('SELECT * FROM profiles WHERE id = ?', [id])) as any; if (!row) return null; - db.prepare('UPDATE profiles SET name = ?, premium = ?, data = ?, updated_at = ? WHERE id = ?').run( + await query('UPDATE profiles SET name = ?, premium = ?, data = ?, updated_at = ? WHERE id = ?', [ patch.name ?? row.name, patch.premium != null ? (patch.premium ? 1 : 0) : row.premium, patch.data ? JSON.stringify(patch.data) : row.data, new Date().toISOString(), id, - ); - return rowToProfile(db.prepare('SELECT * FROM profiles WHERE id = ?').get(id)); + ]); + return rowToProfile(await get('SELECT * FROM profiles WHERE id = ?', [id])); } -export function activateProfile(id: number): void { - const db = getDb(); - db.transaction(() => { - db.prepare('UPDATE profiles SET is_active = 0').run(); - db.prepare('UPDATE profiles SET is_active = 1 WHERE id = ?').run(id); - })(); +export async function activateProfile(id: number): Promise { + await transaction(async (tx) => { + await tx.query('UPDATE profiles SET is_active = 0'); + await tx.query('UPDATE profiles SET is_active = 1 WHERE id = ?', [id]); + }); } -export function deleteProfile(id: number): void { - getDb().prepare('DELETE FROM profiles WHERE id = ?').run(id); +export async function deleteProfile(id: number): Promise { + await query('DELETE FROM profiles WHERE id = ?', [id]); } /** Crafting categories that actually have recipes, for the profile editor UI. */ -export function craftableCategories(): { category: string; items: number }[] { - return getDb() - .prepare( - `SELECT i.crafting_category AS category, COUNT(DISTINCT r.output_item) AS items - FROM recipes r JOIN items i ON i.unique_name = r.output_item - WHERE i.crafting_category IS NOT NULL - GROUP BY i.crafting_category ORDER BY items DESC`, - ) - .all() as { category: string; items: number }[]; +export async function craftableCategories(): Promise<{ category: string; items: number }[]> { + return query( + `SELECT i.crafting_category AS category, COUNT(DISTINCT r.output_item) AS items + FROM recipes r JOIN items i ON i.unique_name = r.output_item + WHERE i.crafting_category IS NOT NULL + GROUP BY i.crafting_category ORDER BY items DESC`, + ); } diff --git a/server/src/config.ts b/server/src/config.ts index 35fbbf4..4ac936e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -6,8 +6,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** Server root directory (server/) */ export const SERVER_ROOT = path.resolve(__dirname, '..'); -/** Local data directory (git-ignored): SQLite db + downloaded game data */ -export const DATA_DIR = path.join(SERVER_ROOT, 'data'); +/** Local data directory (git-ignored): SQLite db + downloaded game data. + * Override with ALBION_DATA_DIR (e.g. a mounted Docker volume). */ +export const DATA_DIR = process.env.ALBION_DATA_DIR ?? path.join(SERVER_ROOT, 'data'); export const GAMEDATA_DIR = path.join(DATA_DIR, 'gamedata'); export const DB_PATH = path.join(DATA_DIR, 'app.db'); diff --git a/server/src/db/index.ts b/server/src/db/index.ts index 70f8df9..25e7777 100644 --- a/server/src/db/index.ts +++ b/server/src/db/index.ts @@ -1,7 +1,18 @@ -import Database from 'better-sqlite3'; -import fs from 'node:fs'; -import path from 'node:path'; -import { DB_PATH } from '../config.js'; +import pg from 'pg'; + +/** + * Postgres data layer. Uses a shared instance (the deployment's `backend` + * network) via DATABASE_URL, e.g. + * postgres://user:pass@postgres:5432/albion + * + * Queries are written with `?` placeholders and a small helper rewrites them to + * Postgres `$1,$2,...` so the SQL stays readable. All access is async. + */ + +// Postgres returns BIGINT/NUMERIC as strings by default; parse the ones we use +// (counts, prices, item_value) back to JS numbers. +pg.types.setTypeParser(20, (v) => (v == null ? null : Number(v))); // int8/bigint +pg.types.setTypeParser(1700, (v) => (v == null ? null : Number(v))); // numeric const SCHEMA = ` CREATE TABLE IF NOT EXISTS meta ( @@ -10,12 +21,12 @@ CREATE TABLE IF NOT EXISTS meta ( ); CREATE TABLE IF NOT EXISTS items ( - unique_name TEXT PRIMARY KEY, -- e.g. T4_BAG@1 - base_name TEXT NOT NULL, -- e.g. T4_BAG + unique_name TEXT PRIMARY KEY, + base_name TEXT NOT NULL, enchantment INTEGER NOT NULL DEFAULT 0, tier INTEGER, - weight REAL, - item_value INTEGER, + weight DOUBLE PRECISION, + item_value DOUBLE PRECISION, shop_category TEXT, shop_subcategory TEXT, crafting_category TEXT, @@ -25,21 +36,21 @@ CREATE INDEX IF NOT EXISTS idx_items_base ON items(base_name); CREATE INDEX IF NOT EXISTS idx_items_name_en ON items(name_en); CREATE TABLE IF NOT EXISTS recipes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id SERIAL PRIMARY KEY, output_item TEXT NOT NULL, - variant INTEGER NOT NULL DEFAULT 0, -- alternative recipe index for the same output - amount_crafted REAL NOT NULL DEFAULT 1, - silver_cost REAL NOT NULL DEFAULT 0, - focus_cost REAL NOT NULL DEFAULT 0, - source TEXT NOT NULL DEFAULT 'crafting' -- 'crafting' | 'upgrade' (enchanting) + variant INTEGER NOT NULL DEFAULT 0, + amount_crafted DOUBLE PRECISION NOT NULL DEFAULT 1, + silver_cost DOUBLE PRECISION NOT NULL DEFAULT 0, + focus_cost DOUBLE PRECISION NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'crafting' ); CREATE INDEX IF NOT EXISTS idx_recipes_output ON recipes(output_item); CREATE TABLE IF NOT EXISTS recipe_inputs ( recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, input_item TEXT NOT NULL, - count REAL NOT NULL, - max_return_amount REAL, -- 0 means the resource is never returned (e.g. artifacts) + count DOUBLE PRECISION NOT NULL, + max_return_amount DOUBLE PRECISION, PRIMARY KEY (recipe_id, input_item) ); CREATE INDEX IF NOT EXISTS idx_recipe_inputs_item ON recipe_inputs(input_item); @@ -48,31 +59,42 @@ CREATE TABLE IF NOT EXISTS market_prices ( item TEXT NOT NULL, city TEXT NOT NULL, quality INTEGER NOT NULL DEFAULT 1, - sell_price_min REAL, - sell_min_date TEXT, -- when AODP last observed this sell order (UTC ISO) - buy_price_max REAL, + sell_price_min DOUBLE PRECISION, + sell_min_date TEXT, + buy_price_max DOUBLE PRECISION, buy_max_date TEXT, fetched_at TEXT NOT NULL, PRIMARY KEY (item, city, quality) ); CREATE INDEX IF NOT EXISTS idx_market_prices_city ON market_prices(city); -CREATE TABLE IF NOT EXISTS watch_rules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, +CREATE TABLE IF NOT EXISTS price_overrides ( item TEXT NOT NULL, city TEXT NOT NULL, quality INTEGER NOT NULL DEFAULT 1, - condition TEXT NOT NULL, -- 'sell_below' | 'sell_above' | 'buy_above' - threshold REAL NOT NULL, + sell_price DOUBLE PRECISION, + buy_price DOUBLE PRECISION, + note TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (item, city, quality) +); + +CREATE TABLE IF NOT EXISTS watch_rules ( + id SERIAL PRIMARY KEY, + item TEXT NOT NULL, + city TEXT NOT NULL, + quality INTEGER NOT NULL DEFAULT 1, + condition TEXT NOT NULL, + threshold DOUBLE PRECISION NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS watch_alerts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id SERIAL PRIMARY KEY, rule_id INTEGER NOT NULL REFERENCES watch_rules(id) ON DELETE CASCADE, message TEXT NOT NULL, - price REAL, + price DOUBLE PRECISION, triggered_at TEXT NOT NULL, seen INTEGER NOT NULL DEFAULT 0 ); @@ -91,48 +113,93 @@ CREATE TABLE IF NOT EXISTS zone_connections ( ); CREATE TABLE IF NOT EXISTS profiles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id SERIAL PRIMARY KEY, name TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 0, premium INTEGER NOT NULL DEFAULT 1, - data TEXT NOT NULL DEFAULT '{}', -- JSON: destiny board levels per crafting category + data TEXT NOT NULL DEFAULT '{}', updated_at TEXT NOT NULL ); - -CREATE TABLE IF NOT EXISTS price_overrides ( - item TEXT NOT NULL, - city TEXT NOT NULL, - quality INTEGER NOT NULL DEFAULT 1, - sell_price REAL, - buy_price REAL, - note TEXT, - updated_at TEXT NOT NULL, - PRIMARY KEY (item, city, quality) -); `; -let db: Database.Database | null = null; +let pool: pg.Pool | null = null; -export function getDb(): Database.Database { - if (!db) { - fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); - db = new Database(DB_PATH); - db.pragma('journal_mode = WAL'); - db.pragma('foreign_keys = ON'); - db.exec(SCHEMA); +export function getPool(): pg.Pool { + if (!pool) { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error('DATABASE_URL is not set (e.g. postgres://user:pass@postgres:5432/albion)'); + } + pool = new pg.Pool({ connectionString, max: 10 }); } - return db; + return pool; } -export function getMeta(key: string): string | null { - const row = getDb().prepare('SELECT value FROM meta WHERE key = ?').get(key) as - | { value: string } - | undefined; +/** Rewrite `?` placeholders to Postgres `$1, $2, ...` (our SQL has no `?` in literals). */ +function toPg(sql: string): string { + let i = 0; + return sql.replace(/\?/g, () => `$${++i}`); +} + +export async function query(sql: string, params: unknown[] = []): Promise { + const res = await getPool().query(toPg(sql), params as any[]); + return res.rows as T[]; +} + +export async function get(sql: string, params: unknown[] = []): Promise { + const rows = await query(sql, params); + return rows[0] ?? null; +} + +export async function exec(sql: string): Promise { + await getPool().query(sql); +} + +/** Run a function inside a transaction with a dedicated client (same `?` helper). */ +export interface Tx { + query(sql: string, params?: unknown[]): Promise; + get(sql: string, params?: unknown[]): Promise; + exec(sql: string): Promise; +} + +export async function transaction(fn: (tx: Tx) => Promise): Promise { + const client = await getPool().connect(); + const tx: Tx = { + async query(sql: string, params: unknown[] = []) { + return (await client.query(toPg(sql), params as any[])).rows as T[]; + }, + async get(sql: string, params: unknown[] = []) { + return ((await client.query(toPg(sql), params as any[])).rows[0] as T) ?? null; + }, + async exec(sql: string) { + await client.query(sql); + }, + }; + try { + await client.query('BEGIN'); + const result = await fn(tx); + await client.query('COMMIT'); + return result; + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +} + +export async function initDb(): Promise { + await exec(SCHEMA); +} + +export async function getMeta(key: string): Promise { + const row = await get<{ value: string }>('SELECT value FROM meta WHERE key = ?', [key]); return row?.value ?? null; } -export function setMeta(key: string, value: string): void { - getDb() - .prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') - .run(key, value); +export async function setMeta(key: string, value: string): Promise { + await query( + 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', + [key, value], + ); } diff --git a/server/src/economy/config.ts b/server/src/economy/config.ts index e26648c..40c30ad 100644 --- a/server/src/economy/config.ts +++ b/server/src/economy/config.ts @@ -54,8 +54,8 @@ export const DEFAULT_ECONOMY_CONFIG: EconomyConfig = { defaultStationFeePer100: 400, }; -export function getEconomyConfig(): EconomyConfig { - const override = getMeta('economy_config'); +export async function getEconomyConfig(): Promise { + const override = await getMeta('economy_config'); if (!override) return DEFAULT_ECONOMY_CONFIG; try { return { ...DEFAULT_ECONOMY_CONFIG, ...(JSON.parse(override) as Partial) }; @@ -64,6 +64,6 @@ export function getEconomyConfig(): EconomyConfig { } } -export function setEconomyConfig(partial: Partial): void { - setMeta('economy_config', JSON.stringify(partial)); +export async function setEconomyConfig(partial: Partial): Promise { + await setMeta('economy_config', JSON.stringify(partial)); } diff --git a/server/src/economy/service.ts b/server/src/economy/service.ts index 6824da5..c96730e 100644 --- a/server/src/economy/service.ts +++ b/server/src/economy/service.ts @@ -1,4 +1,4 @@ -import { getDb } from '../db/index.js'; +import { getMeta, query } from '../db/index.js'; import { focusEfficiencyFor, getActiveProfile } from '../character/service.js'; import { getEconomyConfig } from './config.js'; import { @@ -19,68 +19,113 @@ function ageHours(iso: string | null): number | null { } /** - * DataProvider backed by SQLite. Prices for the requested city are preloaded - * into maps (quality 1, manual overrides taking precedence); items/recipes are - * loaded lazily with memoization. + * The economy engine's DataProvider is synchronous (it recurses deep for + * buy-vs-craft), but Postgres access is async. So we preload the whole item + + * recipe graph into memory once and cache it (invalidated when game data is + * re-parsed). Prices are loaded per city per request. */ -export function buildProvider(city: string): DataProvider { - const db = getDb(); +interface Graph { + items: Map; + namesEn: Map; + recipesByOutput: Map; +} - const sellMap = new Map(); - const buyMap = new Map(); - const priceRows = db - .prepare('SELECT item, sell_price_min, sell_min_date, buy_price_max, buy_max_date FROM market_prices WHERE city = ? AND quality = 1') - .all(city) as any[]; +let graphCache: { key: string; graph: Graph } | null = null; + +async function loadGraph(): Promise { + const key = (await getMeta('gamedata_parsed_at')) ?? 'none'; + if (graphCache && graphCache.key === key) return graphCache.graph; + + const itemRows = await query<{ + unique_name: string; + tier: number | null; + item_value: number | null; + crafting_category: string | null; + name_en: string | null; + }>('SELECT unique_name, tier, item_value, crafting_category, name_en FROM items'); + + const items = new Map(); + const namesEn = new Map(); + for (const r of itemRows) { + items.set(r.unique_name, { + uniqueName: r.unique_name, + tier: r.tier, + itemValue: r.item_value, + craftingCategory: r.crafting_category, + }); + if (r.name_en) namesEn.set(r.unique_name, r.name_en); + } + + const recipeRows = await query<{ + id: number; + output_item: string; + amount_crafted: number; + silver_cost: number; + focus_cost: number; + source: 'crafting' | 'upgrade'; + }>('SELECT id, output_item, amount_crafted, silver_cost, focus_cost, source FROM recipes'); + + const inputRows = await query<{ recipe_id: number; input_item: string; count: number; max_return_amount: number | null }>( + 'SELECT recipe_id, input_item, count, max_return_amount FROM recipe_inputs', + ); + const inputsByRecipe = new Map(); + for (const i of inputRows) { + if (!inputsByRecipe.has(i.recipe_id)) inputsByRecipe.set(i.recipe_id, []); + inputsByRecipe.get(i.recipe_id)!.push({ item: i.input_item, count: i.count, maxReturnAmount: i.max_return_amount }); + } + + const recipesByOutput = new Map(); + for (const r of recipeRows) { + const recipe: RecipeData = { + id: r.id, + outputItem: r.output_item, + amountCrafted: r.amount_crafted, + silverCost: r.silver_cost, + focusCost: r.focus_cost, + source: r.source, + inputs: inputsByRecipe.get(r.id) ?? [], + }; + if (!recipesByOutput.has(r.output_item)) recipesByOutput.set(r.output_item, []); + recipesByOutput.get(r.output_item)!.push(recipe); + } + + const graph: Graph = { items, namesEn, recipesByOutput }; + graphCache = { key, graph }; + return graph; +} + +async function loadCityPrices(city: string): Promise<{ sell: Map; buy: Map }> { + const sell = new Map(); + const buy = new Map(); + const priceRows = (await query( + 'SELECT item, sell_price_min, sell_min_date, buy_price_max, buy_max_date FROM market_prices WHERE city = ? AND quality = 1', + [city], + )) as any[]; for (const r of priceRows) { - if (r.sell_price_min != null) sellMap.set(r.item, { price: r.sell_price_min, ageHours: ageHours(r.sell_min_date), source: 'api' }); - if (r.buy_price_max != null) buyMap.set(r.item, { price: r.buy_price_max, ageHours: ageHours(r.buy_max_date), source: 'api' }); + if (r.sell_price_min != null) sell.set(r.item, { price: r.sell_price_min, ageHours: ageHours(r.sell_min_date), source: 'api' }); + if (r.buy_price_max != null) buy.set(r.item, { price: r.buy_price_max, ageHours: ageHours(r.buy_max_date), source: 'api' }); } - const overrideRows = db - .prepare('SELECT item, sell_price, buy_price, updated_at FROM price_overrides WHERE city = ? AND quality = 1') - .all(city) as any[]; + const overrideRows = (await query('SELECT item, sell_price, buy_price, updated_at FROM price_overrides WHERE city = ? AND quality = 1', [city])) as any[]; for (const o of overrideRows) { - if (o.sell_price != null) sellMap.set(o.item, { price: o.sell_price, ageHours: ageHours(o.updated_at), source: 'manual' }); - if (o.buy_price != null) buyMap.set(o.item, { price: o.buy_price, ageHours: ageHours(o.updated_at), source: 'manual' }); + if (o.sell_price != null) sell.set(o.item, { price: o.sell_price, ageHours: ageHours(o.updated_at), source: 'manual' }); + if (o.buy_price != null) buy.set(o.item, { price: o.buy_price, ageHours: ageHours(o.updated_at), source: 'manual' }); } + return { sell, buy }; +} - const itemStmt = db.prepare('SELECT unique_name, tier, item_value, crafting_category FROM items WHERE unique_name = ?'); - const recipesStmt = db.prepare('SELECT * FROM recipes WHERE output_item = ?'); - const inputsStmt = db.prepare('SELECT input_item, count, max_return_amount FROM recipe_inputs WHERE recipe_id = ?'); - - const itemCache = new Map(); - const recipeCache = new Map(); +export interface BuiltProvider extends DataProvider { + nameOf(item: string): string | null; +} +export async function buildProvider(city: string): Promise { + const graph = await loadGraph(); + const { sell, buy } = await loadCityPrices(city); return { - item(name: string): ItemData | null { - if (itemCache.has(name)) return itemCache.get(name)!; - const row = itemStmt.get(name) as any; - const data: ItemData | null = row - ? { uniqueName: row.unique_name, tier: row.tier, itemValue: row.item_value, craftingCategory: row.crafting_category } - : null; - itemCache.set(name, data); - return data; - }, - recipesFor(name: string): RecipeData[] { - if (recipeCache.has(name)) return recipeCache.get(name)!; - const rows = recipesStmt.all(name) as any[]; - const recipes: RecipeData[] = rows.map((r) => ({ - id: r.id, - outputItem: r.output_item, - amountCrafted: r.amount_crafted, - silverCost: r.silver_cost, - focusCost: r.focus_cost, - source: r.source, - inputs: (inputsStmt.all(r.id) as any[]).map((i) => ({ - item: i.input_item, - count: i.count, - maxReturnAmount: i.max_return_amount, - })), - })); - recipeCache.set(name, recipes); - return recipes; - }, - sellMin: (item) => sellMap.get(item) ?? null, - buyMax: (item) => buyMap.get(item) ?? null, + item: (name) => graph.items.get(name) ?? null, + recipesFor: (name) => graph.recipesByOutput.get(name) ?? [], + sellMin: (item) => sell.get(item) ?? null, + buyMax: (item) => buy.get(item) ?? null, + nameOf: (item) => graph.namesEn.get(item) ?? null, }; } @@ -88,7 +133,7 @@ export interface RankRequest { city: string; minTier?: number; maxTier?: number; - categories?: string[]; // crafting_category filter + categories?: string[]; useFocus?: boolean; focusEfficiency?: number; premium?: boolean; @@ -111,12 +156,12 @@ export interface RankResult { profileName: string | null; } -export function rankOpportunities(req: RankRequest): RankResult { - const cfg = getEconomyConfig(); - const provider = buildProvider(req.city); +export async function rankOpportunities(req: RankRequest): Promise { + const cfg = await getEconomyConfig(); + const provider = await buildProvider(req.city); // The active character profile supplies premium status and per-category focus // efficiency unless the request overrides them explicitly. - const profile = getActiveProfile(); + const profile = await getActiveProfile(); const opts: CalcOptions = { city: req.city, useFocus: req.useFocus ?? false, @@ -127,21 +172,26 @@ export function rankOpportunities(req: RankRequest): RankResult { maxCraftDepth: req.maxCraftDepth ?? 2, }; - const db = getDb(); const params: unknown[] = [req.minTier ?? 2, req.maxTier ?? 8]; let categoryFilter = ''; if (req.categories && req.categories.length > 0) { categoryFilter = ` AND i.crafting_category IN (${req.categories.map(() => '?').join(',')})`; params.push(...req.categories); } - const candidates = db - .prepare( - `SELECT r.id, r.output_item, i.name_en, i.tier, i.enchantment, i.crafting_category - FROM recipes r - JOIN items i ON i.unique_name = r.output_item - WHERE i.tier BETWEEN ? AND ?${categoryFilter}`, - ) - .all(...params) as { id: number; output_item: string; name_en: string | null; tier: number | null; enchantment: number; crafting_category: string | null }[]; + const candidates = await query<{ + id: number; + output_item: string; + name_en: string | null; + tier: number | null; + enchantment: number; + crafting_category: string | null; + }>( + `SELECT r.id, r.output_item, i.name_en, i.tier, i.enchantment, i.crafting_category + FROM recipes r + JOIN items i ON i.unique_name = r.output_item + WHERE i.tier BETWEEN ? AND ?${categoryFilter}`, + params, + ); const cache = new Map(); const results: RankedOpportunity[] = []; @@ -168,14 +218,10 @@ export function rankOpportunities(req: RankRequest): RankResult { results.sort((a, b) => b.profit - a.profit); const top = results.slice(0, req.limit ?? 200); - - // resolve input display names only for the returned rows - const nameStmt = db.prepare('SELECT name_en FROM items WHERE unique_name = ?'); for (const r of top) { for (const inp of r.inputs) { - if (r.inputNames[inp.item]) continue; - const it = nameStmt.get(inp.item) as { name_en: string | null } | undefined; - if (it?.name_en) r.inputNames[inp.item] = it.name_en; + const name = provider.nameOf(inp.item); + if (name) r.inputNames[inp.item] = name; } } return { opportunities: top, profileName: profile?.name ?? null }; diff --git a/server/src/gamedata/cli.ts b/server/src/gamedata/cli.ts index 99a7d5b..1d4e3cc 100644 --- a/server/src/gamedata/cli.ts +++ b/server/src/gamedata/cli.ts @@ -1,9 +1,11 @@ import { downloadGameData } from './download.js'; import { parseGameData } from './parse.js'; -import { getDb } from '../db/index.js'; +import { initDb, query } from '../db/index.js'; const force = process.argv.includes('--force'); +await initDb(); + console.log(`Downloading game data${force ? ' (forced refresh)' : ''}...`); const downloads = await downloadGameData(force); for (const d of downloads) { @@ -11,25 +13,22 @@ for (const d of downloads) { } console.log('Parsing into database...'); -const stats = parseGameData(); +const stats = await parseGameData(); console.log(` items: ${stats.items}, recipes: ${stats.recipes}, inputs: ${stats.recipeInputs}, localized names: ${stats.localizedNames}`); -// Sanity check: a well-known recipe (Adept's Bag = 8 T4 leather) -const db = getDb(); -const bag = db - .prepare( - `SELECT r.id, r.output_item, r.focus_cost, i.name_en - FROM recipes r JOIN items i ON i.unique_name = r.output_item - WHERE r.output_item = 'T4_BAG' AND r.source = 'crafting'`, - ) - .all() as any[]; +// Sanity check: a well-known recipe (Adept's Bag) +const bag = await query<{ id: number; output_item: string; focus_cost: number; name_en: string }>( + `SELECT r.id, r.output_item, r.focus_cost, i.name_en + FROM recipes r JOIN items i ON i.unique_name = r.output_item + WHERE r.output_item = 'T4_BAG' AND r.source = 'crafting'`, +); for (const r of bag) { - const inputs = db - .prepare('SELECT input_item, count, max_return_amount FROM recipe_inputs WHERE recipe_id = ?') - .all(r.id); + const inputs = await query('SELECT input_item, count, max_return_amount FROM recipe_inputs WHERE recipe_id = ?', [r.id]); console.log(`Sanity check — ${r.name_en} (${r.output_item}), focus ${r.focus_cost}:`, inputs); } if (bag.length === 0) { console.error('WARNING: T4_BAG recipe not found — parser needs adjustment.'); process.exitCode = 1; } + +process.exit(process.exitCode ?? 0); diff --git a/server/src/gamedata/parse.ts b/server/src/gamedata/parse.ts index 744120c..2afb369 100644 --- a/server/src/gamedata/parse.ts +++ b/server/src/gamedata/parse.ts @@ -1,5 +1,5 @@ import fs from 'node:fs'; -import { getDb, setMeta } from '../db/index.js'; +import { setMeta, transaction, type Tx } from '../db/index.js'; import { gamedataFilePath } from './download.js'; /** @@ -147,7 +147,21 @@ function walk(node: unknown, items: ParsedItem[], recipes: ParsedRecipe[]): void } } -export function parseGameData(): ParseStats { +/** Insert many rows in parameter-bounded chunks (Postgres caps at 65535 params/statement). */ +async function bulkInsert(tx: Tx, table: string, columns: string[], rows: unknown[][]): Promise { + if (rows.length === 0) return; + const maxParams = 60000; + const rowsPerChunk = Math.max(1, Math.floor(maxParams / columns.length)); + for (let start = 0; start < rows.length; start += rowsPerChunk) { + const chunk = rows.slice(start, start + rowsPerChunk); + const params: unknown[] = []; + // build $N placeholders directly; toPg() only rewrites `?`, so these pass through unchanged + const groups = chunk.map((row) => `(${row.map((v) => `$${params.push(v)}`).join(', ')})`); + await tx.query(`INSERT INTO ${table} (${columns.join(', ')}) VALUES ${groups.join(', ')}`, params); + } +} + +export async function parseGameData(): Promise { const itemsDump = JSON.parse(fs.readFileSync(gamedataFilePath('items.json'), 'utf8')); const localized = JSON.parse(fs.readFileSync(gamedataFilePath('items_localized.json'), 'utf8')) as Raw[]; @@ -162,64 +176,59 @@ export function parseGameData(): ParseStats { if (typeof un === 'string' && typeof name === 'string') namesEn.set(un, name); } - const db = getDb(); - const insertItem = db.prepare(` - INSERT OR REPLACE INTO items - (unique_name, base_name, enchantment, tier, weight, item_value, shop_category, shop_subcategory, crafting_category, name_en) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - const insertRecipe = db.prepare(` - INSERT INTO recipes (output_item, variant, amount_crafted, silver_cost, focus_cost, source) - VALUES (?, ?, ?, ?, ?, ?) - `); - const insertInput = db.prepare(` - INSERT OR REPLACE INTO recipe_inputs (recipe_id, input_item, count, max_return_amount) - VALUES (?, ?, ?, ?) - `); + // De-duplicate items by unique_name (last wins, matching the old INSERT OR REPLACE). + const itemByName = new Map(); + for (const it of items) itemByName.set(it.uniqueName, it); - let inputCount = 0; - db.transaction(() => { - db.exec('DELETE FROM recipe_inputs; DELETE FROM recipes; DELETE FROM items;'); - for (const it of items) { - // Enchanted names like T4_BAG@1 resolve their display name from the localized dump - // when present; fall back to the base item's name. - const nameEn = namesEn.get(it.uniqueName) ?? namesEn.get(it.baseName) ?? null; - insertItem.run( - it.uniqueName, - it.baseName, - it.enchantment, - it.tier, - it.weight, - it.itemValue, - it.shopCategory, - it.shopSubcategory, - it.craftingCategory, - nameEn, - ); + const itemRows = [...itemByName.values()].map((it) => [ + it.uniqueName, + it.baseName, + it.enchantment, + it.tier, + it.weight, + it.itemValue, + it.shopCategory, + it.shopSubcategory, + it.craftingCategory, + // Enchanted names resolve their display name from the localized dump, falling back to the base item. + namesEn.get(it.uniqueName) ?? namesEn.get(it.baseName) ?? null, + ]); + + // Assign explicit recipe ids so inputs can be bulk-inserted without RETURNING. + const recipeRows: unknown[][] = []; + const inputRows: unknown[][] = []; + recipes.forEach((r, i) => { + const id = i + 1; + recipeRows.push([id, r.outputItem, r.variant, r.amountCrafted, r.silverCost, r.focusCost, r.source]); + const seenInputs = new Set(); + for (const input of r.inputs) { + if (seenInputs.has(input.item)) continue; // PK is (recipe_id, input_item) + seenInputs.add(input.item); + inputRows.push([id, input.item, input.count, input.maxReturnAmount]); } - for (const r of recipes) { - const { lastInsertRowid } = insertRecipe.run( - r.outputItem, - r.variant, - r.amountCrafted, - r.silverCost, - r.focusCost, - r.source, - ); - for (const input of r.inputs) { - insertInput.run(lastInsertRowid, input.item, input.count, input.maxReturnAmount); - inputCount++; - } - } - })(); + }); + + await transaction(async (tx) => { + await tx.exec('DELETE FROM recipe_inputs; DELETE FROM recipes; DELETE FROM items;'); + await bulkInsert( + tx, + 'items', + ['unique_name', 'base_name', 'enchantment', 'tier', 'weight', 'item_value', 'shop_category', 'shop_subcategory', 'crafting_category', 'name_en'], + itemRows, + ); + await bulkInsert(tx, 'recipes', ['id', 'output_item', 'variant', 'amount_crafted', 'silver_cost', 'focus_cost', 'source'], recipeRows); + await bulkInsert(tx, 'recipe_inputs', ['recipe_id', 'input_item', 'count', 'max_return_amount'], inputRows); + // keep the SERIAL sequence ahead of our explicit ids for any future inserts + await tx.exec(`SELECT setval(pg_get_serial_sequence('recipes', 'id'), GREATEST((SELECT MAX(id) FROM recipes), 1))`); + }); const stats: ParseStats = { - items: items.length, - recipes: recipes.length, - recipeInputs: inputCount, + items: itemRows.length, + recipes: recipeRows.length, + recipeInputs: inputRows.length, localizedNames: namesEn.size, }; - setMeta('gamedata_parsed_at', new Date().toISOString()); - setMeta('gamedata_stats', JSON.stringify(stats)); + await setMeta('gamedata_parsed_at', new Date().toISOString()); + await setMeta('gamedata_stats', JSON.stringify(stats)); return stats; } diff --git a/server/src/index.ts b/server/src/index.ts index 67924f3..3e9e89c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,6 +1,11 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import Fastify from 'fastify'; import { API_PORT } from './config.js'; -import { getDb } from './db/index.js'; +import { registerStaticSpa } from './static.js'; +import { registerAuth } from './auth.js'; +import { initDb } from './db/index.js'; import { gamedataRoutes } from './routes/gamedata.js'; import { itemRoutes } from './routes/items.js'; import { marketRoutes } from './routes/market.js'; @@ -11,9 +16,36 @@ import { watchRoutes } from './routes/watch.js'; import { snifferRoutes } from './routes/sniffer.js'; import { startWatcherLoop } from './watcher/service.js'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Bind to 127.0.0.1 by default (safe local dev); set HOST=0.0.0.0 to expose (e.g. in Docker). +const HOST = process.env.HOST ?? '127.0.0.1'; +const PORT = Number(process.env.PORT ?? API_PORT); + const app = Fastify({ logger: { level: 'info' } }); -getDb(); // initialize schema on startup +await initDb(); // create schema on startup + +// First-boot convenience: if no game data is loaded yet, download + parse it in +// the background so a fresh deployment is usable without a manual step. +void (async () => { + try { + const { get } = await import('./db/index.js'); + const row = await get<{ n: number }>('SELECT COUNT(*) AS n FROM items'); + if ((row?.n ?? 0) === 0) { + app.log.info('No game data found — downloading and parsing (first boot)...'); + const { downloadGameData } = await import('./gamedata/download.js'); + const { parseGameData } = await import('./gamedata/parse.js'); + await downloadGameData(false); + const stats = await parseGameData(); + app.log.info(`Game data loaded: ${stats.items} items, ${stats.recipes} recipes.`); + } + } catch (e) { + app.log.error(`First-boot game data load failed: ${e instanceof Error ? e.message : e}`); + } +})(); + +registerAuth(app); app.get('/api/health', async () => ({ ok: true })); await app.register(gamedataRoutes); @@ -24,7 +56,14 @@ await app.register(profileRoutes); await app.register(travelRoutes); await app.register(watchRoutes); await app.register(snifferRoutes); -startWatcherLoop(); +await startWatcherLoop(); -await app.listen({ port: API_PORT, host: '127.0.0.1' }); -console.log(`API listening on http://127.0.0.1:${API_PORT}`); +// Serve the built web UI if present (production/Docker). In dev, Vite serves it. +const webDist = process.env.WEB_DIST ?? path.resolve(__dirname, '../../web/dist'); +if (fs.existsSync(path.join(webDist, 'index.html'))) { + registerStaticSpa(app, webDist); + app.log.info(`Serving web UI from ${webDist}`); +} + +await app.listen({ port: PORT, host: HOST }); +console.log(`API listening on http://${HOST}:${PORT}`); diff --git a/server/src/market/client.ts b/server/src/market/client.ts index 7be877e..11a3db3 100644 --- a/server/src/market/client.ts +++ b/server/src/market/client.ts @@ -7,12 +7,12 @@ import { getMeta } from '../db/index.js'; * instead set a proxy (Settings page, or the HTTPS_PROXY env var) and the app * routes its market requests through it. */ -function getProxyUrl(): string | null { - return getMeta('proxy_url') || process.env.HTTPS_PROXY || process.env.HTTP_PROXY || null; +async function getProxyUrl(): Promise { + return (await getMeta('proxy_url')) || process.env.HTTPS_PROXY || process.env.HTTP_PROXY || null; } async function proxyAwareFetch(url: string, timeoutMs: number): Promise<{ ok: boolean; status: number; json(): Promise }> { - const proxyUrl = getProxyUrl(); + const proxyUrl = await getProxyUrl(); const signal = AbortSignal.timeout(timeoutMs); if (proxyUrl) { return undiciFetch(url, { signal, dispatcher: new ProxyAgent(proxyUrl) }); diff --git a/server/src/market/service.ts b/server/src/market/service.ts index 5474b36..70ca760 100644 --- a/server/src/market/service.ts +++ b/server/src/market/service.ts @@ -1,4 +1,4 @@ -import { getDb, getMeta, setMeta } from '../db/index.js'; +import { get, getMeta, query, setMeta, transaction } from '../db/index.js'; import { AodpUnreachableError, CITIES, @@ -7,14 +7,14 @@ import { SERVER_HOSTS, } from './client.js'; -export function getAlbionServer(): string { - const s = getMeta('albion_server'); +export async function getAlbionServer(): Promise { + const s = await getMeta('albion_server'); return s && s in SERVER_HOSTS ? s : 'europe'; } -export function setAlbionServer(server: string): void { +export async function setAlbionServer(server: string): Promise { if (!(server in SERVER_HOSTS)) throw new Error(`unknown server: ${server}`); - setMeta('albion_server', server); + await setMeta('albion_server', server); } // --------------------------------------------------------------------------- @@ -45,44 +45,86 @@ export interface RefreshOptions { } /** Items worth pricing: everything that appears in a recipe (as output or input). */ -function selectRecipeRelevantItems(minTier: number, maxTier: number): string[] { - const rows = getDb() - .prepare( - `SELECT DISTINCT i.unique_name AS name - FROM items i - WHERE i.tier BETWEEN ? AND ? - AND ( - EXISTS (SELECT 1 FROM recipes r WHERE r.output_item = i.unique_name) - OR EXISTS (SELECT 1 FROM recipe_inputs ri WHERE ri.input_item = i.unique_name) - )`, - ) - .all(minTier, maxTier) as { name: string }[]; +async function selectRecipeRelevantItems(minTier: number, maxTier: number): Promise { + const rows = await query<{ name: string }>( + `SELECT DISTINCT i.unique_name AS name + FROM items i + WHERE i.tier BETWEEN ? AND ? + AND ( + EXISTS (SELECT 1 FROM recipes r WHERE r.output_item = i.unique_name) + OR EXISTS (SELECT 1 FROM recipe_inputs ri WHERE ri.input_item = i.unique_name) + )`, + [minTier, maxTier], + ); return rows.map((r) => r.name); } +const PRICE_COLUMNS = ['item', 'city', 'quality', 'sell_price_min', 'sell_min_date', 'buy_price_max', 'buy_max_date', 'fetched_at']; + +// AODP rows carry both sides of the book, so overwrite. Sniffed orders may carry +// only one side, so coalesce keeps the other side's last-known value. +const TAIL_OVERWRITE = ` + ON CONFLICT (item, city, quality) DO UPDATE SET + sell_price_min = excluded.sell_price_min, + sell_min_date = excluded.sell_min_date, + buy_price_max = excluded.buy_price_max, + buy_max_date = excluded.buy_max_date, + fetched_at = excluded.fetched_at`; +const TAIL_COALESCE = ` + ON CONFLICT (item, city, quality) DO UPDATE SET + sell_price_min = COALESCE(excluded.sell_price_min, market_prices.sell_price_min), + sell_min_date = CASE WHEN excluded.sell_price_min IS NOT NULL THEN excluded.sell_min_date ELSE market_prices.sell_min_date END, + buy_price_max = COALESCE(excluded.buy_price_max, market_prices.buy_price_max), + buy_max_date = CASE WHEN excluded.buy_price_max IS NOT NULL THEN excluded.buy_max_date ELSE market_prices.buy_max_date END, + fetched_at = excluded.fetched_at`; + +/** Chunked multi-row upsert into market_prices (returns rows written). */ +export async function upsertPriceRows(rows: unknown[][], mode: 'overwrite' | 'coalesce' = 'overwrite'): Promise { + if (rows.length === 0) return 0; + const tail = mode === 'coalesce' ? TAIL_COALESCE : TAIL_OVERWRITE; + const perChunk = Math.floor(60000 / PRICE_COLUMNS.length); + await transaction(async (tx) => { + for (let start = 0; start < rows.length; start += perChunk) { + const chunk = rows.slice(start, start + perChunk); + const params: unknown[] = []; + const groups = chunk.map((row) => `(${row.map((v) => `$${params.push(v)}`).join(', ')})`); + await tx.query(`INSERT INTO market_prices (${PRICE_COLUMNS.join(', ')}) VALUES ${groups.join(', ')}${tail}`, params); + } + }); + return rows.length; +} + export function startRefresh(opts: RefreshOptions): { started: boolean; reason?: string } { if (job.status === 'running') return { started: false, reason: 'a refresh is already running' }; - const items = - opts.items && opts.items.length > 0 - ? opts.items - : selectRecipeRelevantItems(opts.minTier ?? 2, opts.maxTier ?? 8); - if (items.length === 0) return { started: false, reason: 'no items match the selection' }; - - const cities = opts.cities && opts.cities.length > 0 ? opts.cities : [...CITIES]; job = { status: 'running', - totalChunks: Math.ceil(items.length / 100), + totalChunks: 0, doneChunks: 0, - itemCount: items.length, + itemCount: 0, rowsUpserted: 0, startedAt: new Date().toISOString(), }; void (async () => { try { + const items = + opts.items && opts.items.length > 0 + ? opts.items + : await selectRecipeRelevantItems(opts.minTier ?? 2, opts.maxTier ?? 8); + if (items.length === 0) { + job.status = 'error'; + job.error = 'no items match the selection'; + job.finishedAt = new Date().toISOString(); + return; + } + job.itemCount = items.length; + job.totalChunks = Math.ceil(items.length / 100); + + const cities = opts.cities && opts.cities.length > 0 ? opts.cities : [...CITIES]; + const server = await getAlbionServer(); const rows = await fetchPrices({ - server: getAlbionServer(), + server, items, cities, onProgress: (done, total) => { @@ -91,42 +133,27 @@ export function startRefresh(opts: RefreshOptions): { started: boolean; reason?: }, }); - const db = getDb(); - const upsert = db.prepare(` - INSERT INTO market_prices (item, city, quality, sell_price_min, sell_min_date, buy_price_max, buy_max_date, fetched_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(item, city, quality) DO UPDATE SET - sell_price_min = excluded.sell_price_min, - sell_min_date = excluded.sell_min_date, - buy_price_max = excluded.buy_price_max, - buy_max_date = excluded.buy_max_date, - fetched_at = excluded.fetched_at - `); const now = new Date().toISOString(); - let upserted = 0; - db.transaction(() => { - for (const r of rows) { - const sellDate = normalizeAodpDate(r.sell_price_min_date); - const buyDate = normalizeAodpDate(r.buy_price_max_date); - if (!sellDate && !buyDate) continue; // never observed anywhere - upsert.run( - r.item_id, - r.city, - r.quality, - r.sell_price_min > 0 ? r.sell_price_min : null, - sellDate, - r.buy_price_max > 0 ? r.buy_price_max : null, - buyDate, - now, - ); - upserted++; - } - })(); - - job.rowsUpserted = upserted; + const priceRows: unknown[][] = []; + for (const r of rows) { + const sellDate = normalizeAodpDate(r.sell_price_min_date); + const buyDate = normalizeAodpDate(r.buy_price_max_date); + if (!sellDate && !buyDate) continue; // never observed anywhere + priceRows.push([ + r.item_id, + r.city, + r.quality, + r.sell_price_min > 0 ? r.sell_price_min : null, + sellDate, + r.buy_price_max > 0 ? r.buy_price_max : null, + buyDate, + now, + ]); + } + job.rowsUpserted = await upsertPriceRows(priceRows); job.status = 'done'; job.finishedAt = new Date().toISOString(); - setMeta('market_last_refresh', job.finishedAt); + await setMeta('market_last_refresh', job.finishedAt); } catch (e) { job.status = 'error'; job.finishedAt = new Date().toISOString(); @@ -148,7 +175,7 @@ export interface MergedPrice { buyPrice: number | null; sellSource: 'api' | 'manual' | null; buySource: 'api' | 'manual' | null; - sellAgeHours: number | null; // null for manual overrides / missing + sellAgeHours: number | null; buyAgeHours: number | null; note: string | null; } @@ -160,10 +187,9 @@ function ageHours(iso: string | null): number | null { return Math.max(0, (Date.now() - t) / 3600000); } -export function getPricesForItem(item: string): MergedPrice[] { - const db = getDb(); - const api = db.prepare('SELECT * FROM market_prices WHERE item = ?').all(item) as any[]; - const overrides = db.prepare('SELECT * FROM price_overrides WHERE item = ?').all(item) as any[]; +export async function getPricesForItem(item: string): Promise { + const api = (await query('SELECT * FROM market_prices WHERE item = ?', [item])) as any[]; + const overrides = (await query('SELECT * FROM price_overrides WHERE item = ?', [item])) as any[]; const byKey = new Map(); for (const r of api) { @@ -210,40 +236,38 @@ export function getPricesForItem(item: string): MergedPrice[] { return [...byKey.values()].sort((a, b) => a.city.localeCompare(b.city) || a.quality - b.quality); } -export function setOverride(o: { +export async function setOverride(o: { item: string; city: string; quality?: number; sellPrice?: number | null; buyPrice?: number | null; note?: string | null; -}): void { - getDb() - .prepare( - `INSERT INTO price_overrides (item, city, quality, sell_price, buy_price, note, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(item, city, quality) DO UPDATE SET - sell_price = excluded.sell_price, - buy_price = excluded.buy_price, - note = excluded.note, - updated_at = excluded.updated_at`, - ) - .run(o.item, o.city, o.quality ?? 1, o.sellPrice ?? null, o.buyPrice ?? null, o.note ?? null, new Date().toISOString()); +}): Promise { + await query( + `INSERT INTO price_overrides (item, city, quality, sell_price, buy_price, note, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (item, city, quality) DO UPDATE SET + sell_price = excluded.sell_price, + buy_price = excluded.buy_price, + note = excluded.note, + updated_at = excluded.updated_at`, + [o.item, o.city, o.quality ?? 1, o.sellPrice ?? null, o.buyPrice ?? null, o.note ?? null, new Date().toISOString()], + ); } -export function deleteOverride(item: string, city: string, quality = 1): void { - getDb().prepare('DELETE FROM price_overrides WHERE item = ? AND city = ? AND quality = ?').run(item, city, quality); +export async function deleteOverride(item: string, city: string, quality = 1): Promise { + await query('DELETE FROM price_overrides WHERE item = ? AND city = ? AND quality = ?', [item, city, quality]); } -export function marketSummary() { - const db = getDb(); - const n = (sql: string) => (db.prepare(sql).get() as { n: number }).n; +export async function marketSummary() { + const n = async (sql: string) => (await get<{ n: number }>(sql))!.n; return { - server: getAlbionServer(), - lastRefresh: getMeta('market_last_refresh'), - pricedRows: n('SELECT COUNT(*) AS n FROM market_prices'), - pricedItems: n('SELECT COUNT(DISTINCT item) AS n FROM market_prices'), - overrides: n('SELECT COUNT(*) AS n FROM price_overrides'), + server: await getAlbionServer(), + lastRefresh: await getMeta('market_last_refresh'), + pricedRows: await n('SELECT COUNT(*) AS n FROM market_prices'), + pricedItems: await n('SELECT COUNT(DISTINCT item) AS n FROM market_prices'), + overrides: await n('SELECT COUNT(*) AS n FROM price_overrides'), job: getRefreshJob(), }; } diff --git a/server/src/routes/economy.ts b/server/src/routes/economy.ts index 4a52aef..c0d2509 100644 --- a/server/src/routes/economy.ts +++ b/server/src/routes/economy.ts @@ -6,15 +6,15 @@ export async function economyRoutes(app: FastifyInstance): Promise { app.get('/api/economy/config', async () => getEconomyConfig()); app.put('/api/economy/config', async (req) => { - setEconomyConfig((req.body ?? {}) as Partial); - return { ok: true, config: getEconomyConfig() }; + await setEconomyConfig((req.body ?? {}) as Partial); + return { ok: true, config: await getEconomyConfig() }; }); app.post('/api/economy/opportunities', async (req, reply) => { const body = (req.body ?? {}) as RankRequest; if (!body.city) return reply.code(400).send({ error: 'city is required' }); const started = Date.now(); - const result = rankOpportunities(body); + const result = await rankOpportunities(body); return { ...result, tookMs: Date.now() - started }; }); } diff --git a/server/src/routes/gamedata.ts b/server/src/routes/gamedata.ts index f3d4db3..d1ff55b 100644 --- a/server/src/routes/gamedata.ts +++ b/server/src/routes/gamedata.ts @@ -1,24 +1,23 @@ import type { FastifyInstance } from 'fastify'; -import { getDb, getMeta } from '../db/index.js'; +import { get, getMeta } from '../db/index.js'; import { downloadGameData } from '../gamedata/download.js'; import { parseGameData } from '../gamedata/parse.js'; export async function gamedataRoutes(app: FastifyInstance): Promise { app.get('/api/gamedata/status', async () => { - const db = getDb(); - const count = (table: string) => - (db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n; + const count = async (table: string) => + (await get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`))!.n; return { - parsedAt: getMeta('gamedata_parsed_at'), - items: count('items'), - recipes: count('recipes'), + parsedAt: await getMeta('gamedata_parsed_at'), + items: await count('items'), + recipes: await count('recipes'), }; }); app.post('/api/gamedata/refresh', async (req) => { const force = (req.query as { force?: string }).force === 'true'; const downloads = await downloadGameData(force); - const stats = parseGameData(); + const stats = await parseGameData(); return { downloads, stats }; }); } diff --git a/server/src/routes/items.ts b/server/src/routes/items.ts index edb52fb..2eb9bda 100644 --- a/server/src/routes/items.ts +++ b/server/src/routes/items.ts @@ -1,52 +1,46 @@ import type { FastifyInstance } from 'fastify'; -import { getDb } from '../db/index.js'; +import { get, query } from '../db/index.js'; export async function itemRoutes(app: FastifyInstance): Promise { app.get('/api/items/search', async (req) => { const { q = '' } = req.query as { q?: string }; - const query = q.trim(); - if (!query) return { results: [] }; - const results = getDb() - .prepare( - `SELECT unique_name, name_en, tier, enchantment, shop_category, shop_subcategory - FROM items - WHERE name_en LIKE ? OR unique_name LIKE ? - ORDER BY tier, enchantment, unique_name - LIMIT 50`, - ) - .all(`%${query}%`, `%${query.toUpperCase()}%`); + const queryStr = q.trim(); + if (!queryStr) return { results: [] }; + const results = await query( + `SELECT unique_name, name_en, tier, enchantment, shop_category, shop_subcategory + FROM items + WHERE name_en ILIKE ? OR unique_name ILIKE ? + ORDER BY tier, enchantment, unique_name + LIMIT 50`, + [`%${queryStr}%`, `%${queryStr}%`], + ); return { results }; }); app.get('/api/items/:uniqueName', async (req, reply) => { const { uniqueName } = req.params as { uniqueName: string }; - const db = getDb(); - const item = db.prepare('SELECT * FROM items WHERE unique_name = ?').get(uniqueName); + const item = await get('SELECT * FROM items WHERE unique_name = ?', [uniqueName]); if (!item) return reply.code(404).send({ error: 'item not found' }); - const recipes = db - .prepare('SELECT * FROM recipes WHERE output_item = ? ORDER BY source, variant') - .all(uniqueName) as any[]; + const recipes = (await query('SELECT * FROM recipes WHERE output_item = ? ORDER BY source, variant', [uniqueName])) as any[]; for (const r of recipes) { - r.inputs = db - .prepare( - `SELECT ri.input_item, ri.count, ri.max_return_amount, i.name_en - FROM recipe_inputs ri LEFT JOIN items i ON i.unique_name = ri.input_item - WHERE ri.recipe_id = ?`, - ) - .all(r.id); + r.inputs = await query( + `SELECT ri.input_item, ri.count, ri.max_return_amount, i.name_en + FROM recipe_inputs ri LEFT JOIN items i ON i.unique_name = ri.input_item + WHERE ri.recipe_id = ?`, + [r.id], + ); } - const usedIn = db - .prepare( - `SELECT DISTINCT r.output_item, i.name_en - FROM recipe_inputs ri - JOIN recipes r ON r.id = ri.recipe_id - LEFT JOIN items i ON i.unique_name = r.output_item - WHERE ri.input_item = ? - LIMIT 100`, - ) - .all(uniqueName); + const usedIn = await query( + `SELECT DISTINCT r.output_item, i.name_en + FROM recipe_inputs ri + JOIN recipes r ON r.id = ri.recipe_id + LEFT JOIN items i ON i.unique_name = r.output_item + WHERE ri.input_item = ? + LIMIT 100`, + [uniqueName], + ); return { item, recipes, usedIn }; }); diff --git a/server/src/routes/market.ts b/server/src/routes/market.ts index 094df6d..091c6ff 100644 --- a/server/src/routes/market.ts +++ b/server/src/routes/market.ts @@ -1,8 +1,9 @@ import type { FastifyInstance } from 'fastify'; -import { getDb, getMeta, setMeta } from '../db/index.js'; +import { getMeta, query, setMeta } from '../db/index.js'; import { CITIES, SERVER_HOSTS } from '../market/client.js'; import { deleteOverride, + getAlbionServer, getPricesForItem, getRefreshJob, marketSummary, @@ -32,7 +33,7 @@ export async function marketRoutes(app: FastifyInstance): Promise { app.get('/api/market/prices/:item', async (req) => { const { item } = req.params as { item: string }; - return { prices: getPricesForItem(item) }; + return { prices: await getPricesForItem(item) }; }); app.put('/api/market/override', async (req, reply) => { @@ -45,7 +46,7 @@ export async function marketRoutes(app: FastifyInstance): Promise { note?: string | null; }; if (!body?.item || !body?.city) return reply.code(400).send({ error: 'item and city are required' }); - setOverride({ + await setOverride({ item: body.item, city: body.city, quality: body.quality, @@ -59,15 +60,15 @@ export async function marketRoutes(app: FastifyInstance): Promise { app.delete('/api/market/override', async (req, reply) => { const { item, city, quality } = req.query as { item?: string; city?: string; quality?: string }; if (!item || !city) return reply.code(400).send({ error: 'item and city are required' }); - deleteOverride(item, city, quality ? Number(quality) : 1); + await deleteOverride(item, city, quality ? Number(quality) : 1); return { ok: true }; }); app.get('/api/settings', async () => ({ - albionServer: marketSummary().server, + albionServer: await getAlbionServer(), availableServers: Object.keys(SERVER_HOSTS), - proxyUrl: getMeta('proxy_url') ?? '', - gamePath: getMeta('game_path') ?? '', + proxyUrl: (await getMeta('proxy_url')) ?? '', + gamePath: (await getMeta('game_path')) ?? '', })); app.put('/api/settings', async (req, reply) => { @@ -78,24 +79,18 @@ export async function marketRoutes(app: FastifyInstance): Promise { }; if (albionServer) { try { - setAlbionServer(albionServer); + await setAlbionServer(albionServer); } catch (e) { return reply.code(400).send({ error: String(e) }); } } if (proxyUrl !== undefined) { - if (proxyUrl === '') { - getDb().prepare("DELETE FROM meta WHERE key = 'proxy_url'").run(); - } else { - setMeta('proxy_url', proxyUrl); - } + if (proxyUrl === '') await query("DELETE FROM meta WHERE key = 'proxy_url'"); + else await setMeta('proxy_url', proxyUrl); } if (gamePath !== undefined) { - if (gamePath === '') { - getDb().prepare("DELETE FROM meta WHERE key = 'game_path'").run(); - } else { - setMeta('game_path', gamePath); - } + if (gamePath === '') await query("DELETE FROM meta WHERE key = 'game_path'"); + else await setMeta('game_path', gamePath); } return { ok: true }; }); diff --git a/server/src/routes/profiles.ts b/server/src/routes/profiles.ts index b2e36ef..3cf0772 100644 --- a/server/src/routes/profiles.ts +++ b/server/src/routes/profiles.ts @@ -11,31 +11,31 @@ import { export async function profileRoutes(app: FastifyInstance): Promise { app.get('/api/profiles', async () => ({ - profiles: listProfiles(), - categories: craftableCategories(), + profiles: await listProfiles(), + categories: await craftableCategories(), })); app.post('/api/profiles', async (req, reply) => { const { name } = (req.body ?? {}) as { name?: string }; if (!name?.trim()) return reply.code(400).send({ error: 'name is required' }); - return { profile: createProfile(name.trim()) }; + return { profile: await createProfile(name.trim()) }; }); app.put('/api/profiles/:id', async (req, reply) => { const id = Number((req.params as { id: string }).id); const body = (req.body ?? {}) as { name?: string; premium?: boolean; data?: ProfileData }; - const profile = updateProfile(id, body); + const profile = await updateProfile(id, body); if (!profile) return reply.code(404).send({ error: 'profile not found' }); return { profile }; }); app.post('/api/profiles/:id/activate', async (req) => { - activateProfile(Number((req.params as { id: string }).id)); + await activateProfile(Number((req.params as { id: string }).id)); return { ok: true }; }); app.delete('/api/profiles/:id', async (req) => { - deleteProfile(Number((req.params as { id: string }).id)); + await deleteProfile(Number((req.params as { id: string }).id)); return { ok: true }; }); } diff --git a/server/src/routes/sniffer.ts b/server/src/routes/sniffer.ts index 8e2e1ac..857c416 100644 --- a/server/src/routes/sniffer.ts +++ b/server/src/routes/sniffer.ts @@ -1,9 +1,19 @@ import type { FastifyInstance } from 'fastify'; +import type { AlbionOrder } from '@albion/shared'; import { listInterfaces, snifferStatus, startSniffer, stopSniffer } from '../sniffer/service.js'; +import { storeOrders } from '../sniffer/store.js'; export async function snifferRoutes(app: FastifyInstance): Promise { app.get('/api/sniffer/status', async () => snifferStatus()); + // Ingest: the Windows agent pushes captured market orders here. + app.post('/api/ingest/orders', async (req, reply) => { + const body = (req.body ?? {}) as { orders?: AlbionOrder[] }; + if (!Array.isArray(body.orders)) return reply.code(400).send({ error: 'orders array required' }); + const { pricesStored } = await storeOrders(body.orders); + return { ok: true, ordersReceived: body.orders.length, pricesStored }; + }); + app.get('/api/sniffer/interfaces', async () => ({ interfaces: listInterfaces() })); app.post('/api/sniffer/start', async (req, reply) => { diff --git a/server/src/routes/travel.ts b/server/src/routes/travel.ts index 534acb5..cfc063d 100644 --- a/server/src/routes/travel.ts +++ b/server/src/routes/travel.ts @@ -1,24 +1,32 @@ import type { FastifyInstance } from 'fastify'; -import { importWorld } from '../travel/import.js'; +import { importWorldLocal, storeWorld } from '../travel/import.js'; import { findRoute, haulAdvisor, listZones, travelStatus, type TravelMode } from '../travel/route.js'; +import type { ParsedWorld } from '@albion/shared'; export async function travelRoutes(app: FastifyInstance): Promise { app.get('/api/travel/status', async () => travelStatus()); app.post('/api/travel/import', async (_req, reply) => { try { - return importWorld(); + return await importWorldLocal(); } catch (e) { return reply.code(400).send({ error: e instanceof Error ? e.message : String(e) }); } }); - app.get('/api/travel/zones', async () => ({ zones: listZones() })); + // Ingest: the Windows agent pushes a parsed world map here (game runs on the agent's PC). + app.post('/api/ingest/world', async (req, reply) => { + const body = (req.body ?? {}) as { world?: ParsedWorld }; + if (!body.world?.zones?.length) return reply.code(400).send({ error: 'world.zones required' }); + return storeWorld(body.world, 'agent'); + }); + + app.get('/api/travel/zones', async () => ({ zones: await listZones() })); app.post('/api/travel/route', async (req, reply) => { const { from, to, mode } = (req.body ?? {}) as { from?: string; to?: string; mode?: TravelMode }; if (!from || !to) return reply.code(400).send({ error: 'from and to are required' }); - const result = findRoute(from, to, mode === 'all' ? 'all' : 'safe'); + const result = await findRoute(from, to, mode === 'all' ? 'all' : 'safe'); if ('error' in result) return reply.code(400).send(result); return result; }); @@ -32,6 +40,6 @@ export async function travelRoutes(app: FastifyInstance): Promise { maxTier?: number; }; if (!from || !to) return reply.code(400).send({ error: 'from and to are required' }); - return { items: haulAdvisor(from, to, maxWeight ?? 1500, minTier ?? 2, maxTier ?? 8) }; + return { items: await haulAdvisor(from, to, maxWeight ?? 1500, minTier ?? 2, maxTier ?? 8) }; }); } diff --git a/server/src/routes/watch.ts b/server/src/routes/watch.ts index 19daa0a..1fdc762 100644 --- a/server/src/routes/watch.ts +++ b/server/src/routes/watch.ts @@ -14,7 +14,7 @@ import { } from '../watcher/service.js'; export async function watchRoutes(app: FastifyInstance): Promise { - app.get('/api/watch/rules', async () => ({ rules: listRules(), status: getWatcherStatus() })); + app.get('/api/watch/rules', async () => ({ rules: await listRules(), status: getWatcherStatus() })); app.post('/api/watch/rules', async (req, reply) => { const body = (req.body ?? {}) as { @@ -27,28 +27,28 @@ export async function watchRoutes(app: FastifyInstance): Promise { if (!body.item || !body.city || !body.condition || body.threshold == null) { return reply.code(400).send({ error: 'item, city, condition and threshold are required' }); } - createRule({ item: body.item, city: body.city, quality: body.quality, condition: body.condition, threshold: body.threshold }); + await createRule({ item: body.item, city: body.city, quality: body.quality, condition: body.condition, threshold: body.threshold }); return { ok: true }; }); app.delete('/api/watch/rules/:id', async (req) => { - deleteRule(Number((req.params as { id: string }).id)); + await deleteRule(Number((req.params as { id: string }).id)); return { ok: true }; }); app.post('/api/watch/rules/:id/enabled', async (req) => { const { enabled } = (req.body ?? {}) as { enabled?: boolean }; - setRuleEnabled(Number((req.params as { id: string }).id), enabled ?? true); + await setRuleEnabled(Number((req.params as { id: string }).id), enabled ?? true); return { ok: true }; }); app.get('/api/watch/alerts', async (req) => { const unseenOnly = (req.query as { unseen?: string }).unseen === 'true'; - return { alerts: listAlerts(unseenOnly) }; + return { alerts: await listAlerts(unseenOnly) }; }); app.post('/api/watch/alerts/seen', async () => { - markAlertsSeen(); + await markAlertsSeen(); return { ok: true }; }); @@ -57,8 +57,8 @@ export async function watchRoutes(app: FastifyInstance): Promise { app.put('/api/watch/interval', async (req, reply) => { const { minutes } = (req.body ?? {}) as { minutes?: number }; if (!minutes || minutes < 2) return reply.code(400).send({ error: 'minutes must be >= 2' }); - setMeta('watch_interval_min', String(minutes)); - startWatcherLoop(); + await setMeta('watch_interval_min', String(minutes)); + await startWatcherLoop(); return { ok: true }; }); } diff --git a/server/src/sniffer/service.ts b/server/src/sniffer/service.ts index eaf4d5d..5e51493 100644 --- a/server/src/sniffer/service.ts +++ b/server/src/sniffer/service.ts @@ -1,50 +1,12 @@ -import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; -import fs from 'node:fs'; -import { getDb } from '../db/index.js'; -import { PhotonPacketParser } from 'photon-packet-parser'; +import { findTshark, listInterfaces, startCapture, type CaptureHandle } from '@albion/shared'; +import { storeOrders, type CapturedOrder } from './store.js'; /** - * Passive sniffer for the user's OWN Albion client traffic (UDP 5056). - * Capture is done by spawning tshark (Wireshark CLI, which bundles Npcap on - * Windows) so no native Node modules are needed; payloads are decoded with the - * pure-JS Photon protocol parser. Read-only observation — the SBI-tolerated - * category (same technique as the Albion Data Project client). No memory - * reading, no injection, no automation. - * - * Consumers: - * 1. Market orders: whenever the player opens a marketplace in game, the - * auction responses contain order JSON — we detect them by shape (any - * string parameter parsing as JSON with UnitPriceSilver), which survives - * patches, and feed fresh firsthand prices into market_prices. - * 2. Event-code debug log: counts photon event codes seen, so destiny-board - * related codes can be identified in a live session and added later. + * LOCAL sniffer: used only when the server runs on the same PC as the game. + * When the server is deployed, the Windows agent captures instead and pushes + * results to /api/ingest/*. Capture + Photon decoding live in @albion/shared. */ -const TSHARK_CANDIDATES = [ - 'C:\\Program Files\\Wireshark\\tshark.exe', - 'C:\\Program Files (x86)\\Wireshark\\tshark.exe', -]; - -/** Market LocationId -> city (protocol ids, not cluster ids). Editable if a patch changes them. */ -const MARKET_LOCATIONS: Record = { - '0007': 'Thetford', - '7': 'Thetford', - '1002': 'Lymhurst', - '2004': 'Bridgewatch', - '3005': 'Caerleon', - '3008': 'Martlock', - '4002': 'Fort Sterling', - '5003': 'Brecilien', - '3003': 'Black Market', -}; - -export function findTshark(): string | null { - for (const p of TSHARK_CANDIDATES) if (fs.existsSync(p)) return p; - const which = spawnSync('where', ['tshark'], { encoding: 'utf8' }); - const found = which.stdout?.split(/\r?\n/).find((l) => l.trim().endsWith('tshark.exe')); - return found?.trim() ?? null; -} - export interface SnifferStatus { tsharkPath: string | null; running: boolean; @@ -58,27 +20,7 @@ export interface SnifferStatus { recentOrders: CapturedOrder[]; } -export interface CapturedOrder { - item: string; - city: string; - quality: number; - auctionType: string; - unitPriceSilver: number; - amount: number; - seenAt: string; -} - -interface AlbionOrder { - UnitPriceSilver: number; - Amount: number; - AuctionType: string; - ItemTypeId: string; - EnchantmentLevel: number; - QualityLevel: number; - LocationId: string | number; -} - -let proc: ChildProcess | null = null; +let handle: CaptureHandle | null = null; const state = { packets: 0, photonEvents: 0, @@ -90,195 +32,66 @@ const state = { recentOrders: [] as CapturedOrder[], }; -export function listInterfaces(): { id: string; name: string }[] { - const tshark = findTshark(); - if (!tshark) return []; - const res = spawnSync(tshark, ['-D'], { encoding: 'utf8', timeout: 15000 }); - return (res.stdout ?? '') - .split(/\r?\n/) - .filter(Boolean) - .map((line) => { - const m = line.match(/^(\d+)\.\s+(.*)$/); - return m ? { id: m[1], name: m[2] } : null; - }) - .filter((x): x is { id: string; name: string } => x != null); -} - -function toItemId(o: AlbionOrder): string { - if (o.ItemTypeId.includes('@')) return o.ItemTypeId; - return o.EnchantmentLevel > 0 ? `${o.ItemTypeId}@${o.EnchantmentLevel}` : o.ItemTypeId; -} - -/** Group captured orders into best sell (min) / best buy (max) and store them as fresh prices. */ -function storeOrders(orders: AlbionOrder[]): void { - const best = new Map(); - for (const o of orders) { - const city = MARKET_LOCATIONS[String(o.LocationId)]; - if (!city) continue; - const item = toItemId(o); - const quality = Math.max(1, o.QualityLevel || 1); - const price = o.UnitPriceSilver / 10000; // protocol prices are silver * 10000 - const key = `${item}|${city}|${quality}`; - const cur = best.get(key) ?? { sell: null, buy: null, item, city, quality }; - if (o.AuctionType === 'offer') cur.sell = cur.sell == null ? price : Math.min(cur.sell, price); - else if (o.AuctionType === 'request') cur.buy = cur.buy == null ? price : Math.max(cur.buy, price); - best.set(key, cur); - - state.recentOrders.unshift({ - item, - city, - quality, - auctionType: o.AuctionType, - unitPriceSilver: price, - amount: o.Amount, - seenAt: new Date().toISOString(), - }); - } - state.recentOrders = state.recentOrders.slice(0, 50); - - const db = getDb(); - const now = new Date().toISOString(); - const upsert = db.prepare(` - INSERT INTO market_prices (item, city, quality, sell_price_min, sell_min_date, buy_price_max, buy_max_date, fetched_at) - VALUES (@item, @city, @quality, @sell, @sellDate, @buy, @buyDate, @now) - ON CONFLICT(item, city, quality) DO UPDATE SET - sell_price_min = COALESCE(excluded.sell_price_min, market_prices.sell_price_min), - sell_min_date = CASE WHEN excluded.sell_price_min IS NOT NULL THEN excluded.sell_min_date ELSE market_prices.sell_min_date END, - buy_price_max = COALESCE(excluded.buy_price_max, market_prices.buy_price_max), - buy_max_date = CASE WHEN excluded.buy_price_max IS NOT NULL THEN excluded.buy_max_date ELSE market_prices.buy_max_date END, - fetched_at = excluded.fetched_at - `); - db.transaction(() => { - for (const b of best.values()) { - upsert.run({ - item: b.item, - city: b.city, - quality: b.quality, - sell: b.sell, - sellDate: b.sell != null ? now : null, - buy: b.buy, - buyDate: b.buy != null ? now : null, - now, - }); - state.pricesStored++; - } - })(); -} - -/** Recursively hunt for market-order JSON strings in photon parameters. */ -function extractOrders(value: unknown, out: AlbionOrder[], depth = 0): void { - if (depth > 4 || value == null) return; - if (typeof value === 'string') { - if (value.length > 20 && value.includes('UnitPriceSilver')) { - try { - const parsed = JSON.parse(value); - if (parsed && typeof parsed === 'object' && 'UnitPriceSilver' in parsed) out.push(parsed as AlbionOrder); - } catch { - /* not JSON */ - } - } - return; - } - if (Array.isArray(value)) { - for (const v of value) extractOrders(v, out, depth + 1); - return; - } - if (typeof value === 'object') { - for (const v of Object.values(value)) extractOrders(v, out, depth + 1); - } -} - -function handlePhotonMessage(data: any, isEvent: boolean): void { - if (isEvent) { - state.photonEvents++; - const code = data?.code ?? data?.Code; - if (typeof code === 'number') { - state.eventCodeCounts.set(code, (state.eventCodeCounts.get(code) ?? 0) + 1); - } - } - const orders: AlbionOrder[] = []; - extractOrders(data?.parameters ?? data?.Parameters ?? data, orders); - if (orders.length > 0) { - state.marketOrdersSeen += orders.length; - try { - storeOrders(orders); - } catch (e) { - state.lastError = `storing sniffed orders failed: ${e instanceof Error ? e.message : e}`; - } - } -} +export { listInterfaces }; export function startSniffer(interfaceIds?: string[]): { started: boolean; error?: string } { - if (proc) return { started: false, error: 'sniffer already running' }; - const tshark = findTshark(); - if (!tshark) { - return { - started: false, - error: - 'tshark.exe not found. Install Wireshark (https://www.wireshark.org) with the bundled Npcap driver — the sniffer uses it to read your own game traffic passively.', - }; - } + if (handle) return { started: false, error: 'sniffer already running' }; - const parser = new PhotonPacketParser(); - parser.on('event', (d: any) => handlePhotonMessage(d, true)); - parser.on('response', (d: any) => handlePhotonMessage(d, false)); - parser.on('request', () => undefined); - parser.on('error', () => undefined); - - const ifaces = interfaceIds && interfaceIds.length > 0 ? interfaceIds : listInterfaces().map((i) => i.id); - const ifaceArgs = ifaces.flatMap((i) => ['-i', i]); - const args = [...ifaceArgs, '-l', '-n', '-q', '-f', 'udp port 5056', '-T', 'fields', '-e', 'data']; - - proc = spawn(tshark, args, { windowsHide: true }); - state.packets = 0; - state.photonEvents = 0; - state.marketOrdersSeen = 0; - state.pricesStored = 0; - state.startedAt = new Date().toISOString(); - state.lastError = null; + Object.assign(state, { + packets: 0, + photonEvents: 0, + marketOrdersSeen: 0, + pricesStored: 0, + startedAt: new Date().toISOString(), + lastError: null, + recentOrders: [], + }); state.eventCodeCounts.clear(); - state.recentOrders = []; - let pending = ''; - proc.stdout!.on('data', (chunk: Buffer) => { - pending += chunk.toString('ascii'); - const lines = pending.split('\n'); - pending = lines.pop() ?? ''; - for (const line of lines) { - const hex = line.trim().replace(/:/g, ''); - if (!hex || hex.length < 24) continue; - state.packets++; - try { - parser.handle(Buffer.from(hex, 'hex')); - } catch { - /* malformed packet — ignore */ - } - } - }); - proc.stderr!.on('data', (chunk: Buffer) => { - const msg = chunk.toString('utf8').trim(); - // tshark prints capture stats to stderr; only keep real errors - if (msg && !/^Capturing on/i.test(msg) && !/^\d+ packets?/i.test(msg)) state.lastError = msg.slice(0, 400); - }); - proc.on('exit', (code) => { - if (code !== 0 && code != null) state.lastError = state.lastError ?? `tshark exited with code ${code}`; - proc = null; - }); + const result = startCapture( + { + onPacket: () => { + state.packets++; + }, + onEventCode: (code) => { + state.photonEvents++; + state.eventCodeCounts.set(code, (state.eventCodeCounts.get(code) ?? 0) + 1); + }, + onOrders: (orders) => { + state.marketOrdersSeen += orders.length; + void storeOrders(orders) + .then(({ pricesStored, recent }) => { + state.pricesStored += pricesStored; + state.recentOrders = [...recent, ...state.recentOrders].slice(0, 50); + }) + .catch((e) => { + state.lastError = `storing sniffed orders failed: ${e instanceof Error ? e.message : e}`; + }); + }, + onError: (message) => { + state.lastError = message; + }, + }, + interfaceIds, + ); + if ('error' in result) { + state.startedAt = null; + return { started: false, error: result.error }; + } + handle = result; return { started: true }; } export function stopSniffer(): void { - if (proc) { - proc.kill(); - proc = null; - } + handle?.stop(); + handle = null; } export function snifferStatus(): SnifferStatus { return { tsharkPath: findTshark(), - running: proc != null, + running: handle != null, packets: state.packets, photonEvents: state.photonEvents, marketOrdersSeen: state.marketOrdersSeen, diff --git a/server/src/sniffer/store.ts b/server/src/sniffer/store.ts new file mode 100644 index 0000000..eaedb2f --- /dev/null +++ b/server/src/sniffer/store.ts @@ -0,0 +1,56 @@ +import { MARKET_LOCATIONS, orderItemId, type AlbionOrder } from '@albion/shared'; +import { upsertPriceRows } from '../market/service.js'; + +export interface StoredOrderStats { + pricesStored: number; + recent: CapturedOrder[]; +} + +export interface CapturedOrder { + item: string; + city: string; + quality: number; + auctionType: string; + unitPriceSilver: number; + amount: number; + seenAt: string; +} + +/** + * Turn raw captured market orders into best sell (min) / best buy (max) prices + * and store them as fresh market data. Shared by the local sniffer and the + * agent-ingest endpoint. Sniffed prices are firsthand (fresher than AODP) but + * still ranked below manual overrides by the read layer. + */ +export async function storeOrders(orders: AlbionOrder[]): Promise { + const best = new Map(); + const recent: CapturedOrder[] = []; + for (const o of orders) { + const city = MARKET_LOCATIONS[String(o.LocationId)]; + if (!city) continue; + const item = orderItemId(o); + const quality = Math.max(1, o.QualityLevel || 1); + const price = o.UnitPriceSilver / 10000; // protocol prices are silver * 10000 + const key = `${item}|${city}|${quality}`; + const cur = best.get(key) ?? { sell: null, buy: null, item, city, quality }; + if (o.AuctionType === 'offer') cur.sell = cur.sell == null ? price : Math.min(cur.sell, price); + else if (o.AuctionType === 'request') cur.buy = cur.buy == null ? price : Math.max(cur.buy, price); + best.set(key, cur); + recent.push({ item, city, quality, auctionType: o.AuctionType, unitPriceSilver: price, amount: o.Amount, seenAt: new Date().toISOString() }); + } + + // Reuse the market upsert (COALESCE semantics: sniffed side updates, other side kept). + const now = new Date().toISOString(); + const rows = [...best.values()].map((b) => [ + b.item, + b.city, + b.quality, + b.sell, + b.sell != null ? now : null, + b.buy, + b.buy != null ? now : null, + now, + ]); + const pricesStored = await upsertPriceRows(rows, 'coalesce'); + return { pricesStored, recent: recent.slice(0, 50) }; +} diff --git a/server/src/static.ts b/server/src/static.ts new file mode 100644 index 0000000..9822628 --- /dev/null +++ b/server/src/static.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { FastifyInstance } from 'fastify'; + +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.map': 'application/json; charset=utf-8', +}; + +/** + * Serve a built SPA (web/dist) with a strict path-traversal guard: the resolved + * file must stay inside the dist root, otherwise fall back to index.html. Avoids + * a static-file plugin dependency and its recurring path-traversal CVEs. + */ +export function registerStaticSpa(app: FastifyInstance, distDir: string): void { + const root = path.resolve(distDir); + const indexHtml = path.join(root, 'index.html'); + + const sendFile = (reply: any, filePath: string) => { + const ext = path.extname(filePath).toLowerCase(); + reply.header('Content-Type', MIME[ext] ?? 'application/octet-stream'); + return reply.send(fs.createReadStream(filePath)); + }; + + app.setNotFoundHandler((req, reply) => { + if (req.method !== 'GET' && req.method !== 'HEAD') return reply.code(404).send({ error: 'not found' }); + if (req.url.startsWith('/api/')) return reply.code(404).send({ error: 'not found' }); + + const urlPath = decodeURIComponent(req.url.split('?')[0]); + const candidate = path.resolve(root, '.' + urlPath); + // guard: resolved path must be within root + if ((candidate === root || candidate.startsWith(root + path.sep)) && fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return sendFile(reply, candidate); + } + return sendFile(reply, indexHtml); // SPA fallback + }); +} diff --git a/server/src/travel/import.ts b/server/src/travel/import.ts index 0fef97d..7154239 100644 --- a/server/src/travel/import.ts +++ b/server/src/travel/import.ts @@ -1,32 +1,16 @@ -// @ts-expect-error des.js ships no types; it's a small pure-JS DES used because -// OpenSSL 3 (Node 17+) removed legacy single-DES from node:crypto -import des from 'des.js'; import fs from 'node:fs'; import path from 'node:path'; -import zlib from 'node:zlib'; -import { XMLParser } from 'fast-xml-parser'; -import { getDb, getMeta, setMeta } from '../db/index.js'; +import { decryptBin, parseWorldXml, WORLD_BIN_SUBPATH, type ParsedWorld } from '@albion/shared'; +import { getMeta, setMeta, transaction } from '../db/index.js'; /** - * The world map (zones + portals) comes from the game's own files: - * \game\Albion-Online_Data\StreamingAssets\GameData\cluster\world.bin - * .bin files are DES-encrypted, gzip-compressed XML. The key/IV are public - * knowledge (ao-id-extractor, Portaler, Statistics Analysis Tool all use them); - * we only read the file, never modify the game. + * World map (zones + portal connections) storage. Two entry points: + * - importWorldLocal(): read the game's world.bin from THIS machine's disk + * (used when the server runs on the gaming PC). + * - storeWorld(): persist a ParsedWorld sent by the Windows agent (used when + * the server is deployed and the game runs elsewhere). + * Parsing/decryption lives in @albion/shared so the agent reuses it verbatim. */ -const BIN_KEY = Buffer.from([48, 239, 114, 71, 66, 242, 4, 50]); -const BIN_IV = Buffer.from([14, 166, 220, 137, 219, 237, 220, 79]); - -export function decryptBin(encrypted: Buffer): Buffer { - const decipher = des.CBC.instantiate(des.DES).create({ type: 'decrypt', key: BIN_KEY, iv: BIN_IV }); - let compressed = Buffer.from([...decipher.update(encrypted), ...decipher.final()]); - // strip PKCS7 padding (des.js does not remove it) - const pad = compressed[compressed.length - 1]; - if (pad >= 1 && pad <= 8) compressed = compressed.subarray(0, compressed.length - pad); - return zlib.gunzipSync(compressed); -} - -const WORLD_BIN_SUBPATH = path.join('game', 'Albion-Online_Data', 'StreamingAssets', 'GameData', 'cluster', 'world.bin'); const COMMON_GAME_DIRS = [ 'E:\\albion', @@ -35,147 +19,61 @@ const COMMON_GAME_DIRS = [ 'C:\\Program Files (x86)\\Steam\\steamapps\\common\\Albion Online', ]; -export function findWorldBin(): string | null { - const configured = getMeta('game_path'); +export async function findWorldBin(): Promise { + const configured = await getMeta('game_path'); const candidates = configured ? [configured, ...COMMON_GAME_DIRS] : COMMON_GAME_DIRS; for (const dir of candidates) { const direct = path.join(dir, WORLD_BIN_SUBPATH); if (fs.existsSync(direct)) return direct; - // allow pointing straight at .../GameData or the cluster dir or the bin itself - const alternatives = [dir, path.join(dir, 'cluster', 'world.bin'), path.join(dir, 'world.bin')]; - for (const alt of alternatives) { - if (alt.endsWith('world.bin') && fs.existsSync(alt)) return alt; - } + if (dir.endsWith('world.bin') && fs.existsSync(dir)) return dir; } return null; } -export function zoneColor(type: string): string { - const t = type.toUpperCase(); - if (t.startsWith('PLAYERCITY')) return 'city'; - if (t.startsWith('SAFEAREA')) return 'blue'; - if (t.includes('YELLOW')) return 'yellow'; - if (t.includes('RED')) return 'red'; - if (t.includes('BLACK')) return 'black'; - if (t.startsWith('TUNNEL')) return 'road'; - return 'other'; -} - -interface RawExit { - '@id'?: string; - '@targetid'?: string; -} - -interface RawCluster { - '@id'?: string; - '@displayname'?: string; - '@type'?: string; - exits?: { exit?: RawExit | RawExit[] }; -} - -function asArray(v: T | T[] | undefined): T[] { - if (v === undefined || v === null) return []; - return Array.isArray(v) ? v : [v]; -} - export interface WorldImportStats { zones: number; connections: number; - worldBinPath: string; + source: string; } -export function importWorld(): WorldImportStats { - const binPath = findWorldBin(); +export async function storeWorld(world: ParsedWorld, source: string): Promise { + await transaction(async (tx) => { + await tx.exec('DELETE FROM zone_connections; DELETE FROM zones;'); + // de-dup edges (both directions collapse to one sorted pair) + const edgeSet = new Set(); + for (const [a, b] of world.connections) edgeSet.add([a, b].sort().join('|')); + + // chunked bulk inserts + const zoneRows = world.zones.map((z) => [z.albionId, z.name, z.type, z.color]); + for (let i = 0; i < zoneRows.length; i += 5000) { + const chunk = zoneRows.slice(i, i + 5000); + const params: unknown[] = []; + const groups = chunk.map((r) => `(${r.map((v) => `$${params.push(v)}`).join(', ')})`); + await tx.query(`INSERT INTO zones (albion_id, name, type, color) VALUES ${groups.join(', ')} ON CONFLICT (albion_id) DO NOTHING`, params); + } + const edges = [...edgeSet].map((e) => e.split('|')); + for (let i = 0; i < edges.length; i += 10000) { + const chunk = edges.slice(i, i + 10000); + const params: unknown[] = []; + const groups = chunk.map((r) => `(${r.map((v) => `$${params.push(v)}`).join(', ')})`); + await tx.query(`INSERT INTO zone_connections (zone_a, zone_b) VALUES ${groups.join(', ')} ON CONFLICT (zone_a, zone_b) DO NOTHING`, params); + } + }); + await setMeta('world_imported_at', new Date().toISOString()); + await setMeta('world_source', source); + return { zones: world.zones.length, connections: world.connections.length, source }; +} + +/** Read + parse world.bin from this machine's game install. */ +export async function importWorldLocal(): Promise { + const binPath = await findWorldBin(); if (!binPath) { throw new Error( - 'world.bin not found. Set your Albion Online game folder in Settings ' + - '(the folder that contains the "game" directory).', + 'world.bin not found on this machine. Set your Albion Online game folder in Settings, ' + + 'or push it from the Windows agent if the game runs on another PC.', ); } - const xml = decryptBin(fs.readFileSync(binPath)).toString('utf8'); - const parser = new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: '@', - isArray: (name) => name === 'cluster' || name === 'exit', - }); - const doc = parser.parse(xml); - const clusters: RawCluster[] = doc?.world?.clusters?.cluster ?? []; - if (clusters.length === 0) throw new Error('no clusters found in world.bin — format may have changed'); - - // Same filtering approach as Portaler: overworld zones only. Roads of Avalon - // (TUNNEL_*) have dynamic connections, so they are excluded from routing. - const zones = clusters.filter((c) => { - const type = (c['@type'] ?? '').toUpperCase(); - const name = c['@displayname'] ?? ''; - const id = c['@id'] ?? ''; - return ( - (type.startsWith('OPENPVP') || type.startsWith('SAFEAREA') || type.startsWith('PLAYERCITY') || type.startsWith('PASSAGE')) && - !!id && - !!name && - !name.toLowerCase().includes('debug') && - !type.includes('DUNGEON_') && - !type.includes('EXPEDITION_') && - !type.includes('ARENA_') && - !type.includes('_NOFURNITURE') && - type !== 'PASSAGE_SAFEAREA' - ); - }); - - const zoneIds = new Set(zones.map((z) => z['@id']!)); - - // Exits reference each other by GUID: exit X's targetid is the GUID of the - // matching exit in the neighboring zone. Map every exit GUID to its zone, - // then resolve each targetid to the neighbor. - const exitOwner = new Map(); - for (const z of zones) { - for (const e of asArray(z.exits?.exit)) { - const id = e['@id']; - if (id) exitOwner.set(String(id).slice(0, 36), z['@id']!); - } - } - - const edges = new Set(); - for (const z of zones) { - for (const e of asArray(z.exits?.exit)) { - const targetId = e['@targetid']; - if (!targetId) continue; - const neighbor = exitOwner.get(String(targetId).slice(0, 36)); - if (!neighbor || neighbor === z['@id'] || !zoneIds.has(neighbor)) continue; - const [a, b] = [z['@id']!, neighbor].sort(); - edges.add(`${a}|${b}`); - } - } - - const db = getDb(); - db.exec(` - CREATE TABLE IF NOT EXISTS zones ( - albion_id TEXT PRIMARY KEY, - name TEXT NOT NULL, - type TEXT NOT NULL, - color TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS zone_connections ( - zone_a TEXT NOT NULL, - zone_b TEXT NOT NULL, - PRIMARY KEY (zone_a, zone_b) - ); - `); - - const insertZone = db.prepare('INSERT OR REPLACE INTO zones (albion_id, name, type, color) VALUES (?, ?, ?, ?)'); - const insertEdge = db.prepare('INSERT OR REPLACE INTO zone_connections (zone_a, zone_b) VALUES (?, ?)'); - db.transaction(() => { - db.exec('DELETE FROM zone_connections; DELETE FROM zones;'); - for (const z of zones) { - insertZone.run(z['@id'], z['@displayname'], z['@type'], zoneColor(z['@type'] ?? '')); - } - for (const edge of edges) { - const [a, b] = edge.split('|'); - insertEdge.run(a, b); - } - })(); - - setMeta('world_imported_at', new Date().toISOString()); - setMeta('world_bin_path', binPath); - return { zones: zones.length, connections: edges.size, worldBinPath: binPath }; + const world = parseWorldXml(xml); + return storeWorld(world, `local:${binPath}`); } diff --git a/server/src/travel/route.ts b/server/src/travel/route.ts index b53217a..944aa90 100644 --- a/server/src/travel/route.ts +++ b/server/src/travel/route.ts @@ -1,4 +1,4 @@ -import { getDb, getMeta } from '../db/index.js'; +import { get, getMeta, query } from '../db/index.js'; import { getActiveProfile } from '../character/service.js'; import { getEconomyConfig } from '../economy/config.js'; import { buildProvider } from '../economy/service.js'; @@ -17,14 +17,13 @@ const SAFE_COLORS = new Set(['city', 'blue', 'yellow']); /** Roads of Avalon connections are dynamic; never route through them. */ const ROUTABLE_COLORS = new Set(['city', 'blue', 'yellow', 'red', 'black', 'other']); -function loadGraph(): { zones: Map; adj: Map } { - const db = getDb(); +async function loadGraph(): Promise<{ zones: Map; adj: Map }> { const zones = new Map(); - for (const r of db.prepare('SELECT * FROM zones').all() as any[]) { + for (const r of (await query('SELECT * FROM zones')) as any[]) { zones.set(r.albion_id, { albionId: r.albion_id, name: r.name, type: r.type, color: r.color }); } const adj = new Map(); - for (const r of db.prepare('SELECT * FROM zone_connections').all() as any[]) { + for (const r of (await query('SELECT * FROM zone_connections')) as any[]) { if (!adj.has(r.zone_a)) adj.set(r.zone_a, []); if (!adj.has(r.zone_b)) adj.set(r.zone_b, []); adj.get(r.zone_a)!.push(r.zone_b); @@ -40,8 +39,8 @@ export interface RouteResult { dangerZones: number; // red + black on the path } -export function findRoute(fromName: string, toName: string, mode: TravelMode): RouteResult | { error: string } { - const { zones, adj } = loadGraph(); +export async function findRoute(fromName: string, toName: string, mode: TravelMode): Promise { + const { zones, adj } = await loadGraph(); if (zones.size === 0) return { error: 'world map not imported — run the import on the Travel page' }; const byName = new Map(); @@ -86,8 +85,8 @@ export function findRoute(fromName: string, toName: string, mode: TravelMode): R return { found: true, path, zonesTraversed: path.length - 1, dangerZones }; } -export function listZones(): Zone[] { - return (getDb().prepare('SELECT * FROM zones ORDER BY name').all() as any[]).map((r) => ({ +export async function listZones(): Promise { + return ((await query('SELECT * FROM zones ORDER BY name')) as any[]).map((r) => ({ albionId: r.albion_id, name: r.name, type: r.type, @@ -95,20 +94,15 @@ export function listZones(): Zone[] { })); } -export function travelStatus() { - const db = getDb(); - const hasTables = db - .prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='zones'") - .get() as { n: number }; - const zoneCount = hasTables.n > 0 ? (db.prepare('SELECT COUNT(*) AS n FROM zones').get() as { n: number }).n : 0; - const connCount = - hasTables.n > 0 && zoneCount > 0 ? (db.prepare('SELECT COUNT(*) AS n FROM zone_connections').get() as { n: number }).n : 0; +export async function travelStatus() { + const zoneCount = (await get<{ n: number }>('SELECT COUNT(*) AS n FROM zones'))!.n; + const connCount = zoneCount > 0 ? (await get<{ n: number }>('SELECT COUNT(*) AS n FROM zone_connections'))!.n : 0; return { zones: zoneCount, connections: connCount, - importedAt: getMeta('world_imported_at'), - worldBinPath: getMeta('world_bin_path'), - gamePath: getMeta('game_path'), + importedAt: await getMeta('world_imported_at'), + worldSource: await getMeta('world_source'), + gamePath: await getMeta('game_path'), }; } @@ -128,18 +122,19 @@ export interface HaulItem { profitPerWeight: number; } -export function haulAdvisor(from: string, to: string, maxWeight: number, minTier = 2, maxTier = 8): HaulItem[] { - const cfg = getEconomyConfig(); - const premium = getActiveProfile()?.premium ?? true; +export async function haulAdvisor(from: string, to: string, maxWeight: number, minTier = 2, maxTier = 8): Promise { + const cfg = await getEconomyConfig(); + const premium = (await getActiveProfile())?.premium ?? true; const tax = premium ? cfg.salesTaxPremium : cfg.salesTaxNoPremium; const feeRate = tax + cfg.listingFee; - const origin = buildProvider(from); - const dest = buildProvider(to); + const origin = await buildProvider(from); + const dest = await buildProvider(to); - const items = getDb() - .prepare('SELECT unique_name, name_en, tier, weight FROM items WHERE weight IS NOT NULL AND weight > 0 AND tier BETWEEN ? AND ?') - .all(minTier, maxTier) as { unique_name: string; name_en: string | null; tier: number | null; weight: number }[]; + const items = (await query( + 'SELECT unique_name, name_en, tier, weight FROM items WHERE weight IS NOT NULL AND weight > 0 AND tier BETWEEN ? AND ?', + [minTier, maxTier], + )) as { unique_name: string; name_en: string | null; tier: number | null; weight: number }[]; const results: HaulItem[] = []; for (const it of items) { diff --git a/server/src/watcher/service.ts b/server/src/watcher/service.ts index d1e36ca..2fc9b7d 100644 --- a/server/src/watcher/service.ts +++ b/server/src/watcher/service.ts @@ -1,6 +1,6 @@ -import { getDb, getMeta, setMeta } from '../db/index.js'; +import { get, getMeta, query, setMeta } from '../db/index.js'; import { fetchPrices, normalizeAodpDate } from '../market/client.js'; -import { getAlbionServer, getPricesForItem } from '../market/service.js'; +import { getAlbionServer, getPricesForItem, upsertPriceRows } from '../market/service.js'; export type WatchCondition = 'sell_below' | 'sell_above' | 'buy_above'; @@ -22,13 +22,11 @@ const CONDITION_LABELS: Record `buy orders at ${Math.round(p).toLocaleString()} (above ${r.threshold.toLocaleString()}) — instant-sell opportunity`, }; -export function listRules(): WatchRule[] { +export async function listRules(): Promise { return ( - getDb() - .prepare( - `SELECT w.*, i.name_en FROM watch_rules w LEFT JOIN items i ON i.unique_name = w.item ORDER BY w.id DESC`, - ) - .all() as any[] + (await query( + `SELECT w.*, i.name_en FROM watch_rules w LEFT JOIN items i ON i.unique_name = w.item ORDER BY w.id DESC`, + )) as any[] ).map((r) => ({ id: r.id, item: r.item, @@ -42,35 +40,44 @@ export function listRules(): WatchRule[] { })); } -export function createRule(rule: { item: string; city: string; quality?: number; condition: WatchCondition; threshold: number }): void { - getDb() - .prepare('INSERT INTO watch_rules (item, city, quality, condition, threshold, enabled, created_at) VALUES (?, ?, ?, ?, ?, 1, ?)') - .run(rule.item, rule.city, rule.quality ?? 1, rule.condition, rule.threshold, new Date().toISOString()); +export async function createRule(rule: { + item: string; + city: string; + quality?: number; + condition: WatchCondition; + threshold: number; +}): Promise { + await query('INSERT INTO watch_rules (item, city, quality, condition, threshold, enabled, created_at) VALUES (?, ?, ?, ?, ?, 1, ?)', [ + rule.item, + rule.city, + rule.quality ?? 1, + rule.condition, + rule.threshold, + new Date().toISOString(), + ]); } -export function deleteRule(id: number): void { - getDb().prepare('DELETE FROM watch_rules WHERE id = ?').run(id); +export async function deleteRule(id: number): Promise { + await query('DELETE FROM watch_rules WHERE id = ?', [id]); } -export function setRuleEnabled(id: number, enabled: boolean): void { - getDb().prepare('UPDATE watch_rules SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, id); +export async function setRuleEnabled(id: number, enabled: boolean): Promise { + await query('UPDATE watch_rules SET enabled = ? WHERE id = ?', [enabled ? 1 : 0, id]); } -export function listAlerts(unseenOnly = false): any[] { - return getDb() - .prepare( - `SELECT a.*, w.item, w.city, i.name_en - FROM watch_alerts a - JOIN watch_rules w ON w.id = a.rule_id - LEFT JOIN items i ON i.unique_name = w.item - ${unseenOnly ? 'WHERE a.seen = 0' : ''} - ORDER BY a.id DESC LIMIT 100`, - ) - .all(); +export async function listAlerts(unseenOnly = false): Promise { + return query( + `SELECT a.*, w.item, w.city, i.name_en + FROM watch_alerts a + JOIN watch_rules w ON w.id = a.rule_id + LEFT JOIN items i ON i.unique_name = w.item + ${unseenOnly ? 'WHERE a.seen = 0' : ''} + ORDER BY a.id DESC LIMIT 100`, + ); } -export function markAlertsSeen(): void { - getDb().prepare('UPDATE watch_alerts SET seen = 1 WHERE seen = 0').run(); +export async function markAlertsSeen(): Promise { + await query('UPDATE watch_alerts SET seen = 1 WHERE seen = 0'); } // --------------------------------------------------------------------------- @@ -84,19 +91,15 @@ export interface PollResult { let lastPoll: PollResult | null = null; let timer: NodeJS.Timeout | null = null; +let intervalMinutes = 15; export function getWatcherStatus() { - return { - lastPoll, - intervalMinutes: Number(getMeta('watch_interval_min') ?? 15), - running: timer != null, - }; + return { lastPoll, intervalMinutes, running: timer != null }; } /** Fetch fresh prices for all watched item/city pairs and evaluate the rules. */ export async function pollWatches(): Promise { - const db = getDb(); - const rules = listRules().filter((r) => r.enabled); + const rules = (await listRules()).filter((r) => r.enabled); const result: PollResult = { checkedRules: rules.length, newAlerts: 0, at: new Date().toISOString() }; if (rules.length === 0) { lastPoll = result; @@ -106,39 +109,35 @@ export async function pollWatches(): Promise { try { const items = [...new Set(rules.map((r) => r.item))]; const cities = [...new Set(rules.map((r) => r.city))]; - const rows = await fetchPrices({ server: getAlbionServer(), items, cities }); + const rows = await fetchPrices({ server: await getAlbionServer(), items, cities }); - const upsert = db.prepare(` - INSERT INTO market_prices (item, city, quality, sell_price_min, sell_min_date, buy_price_max, buy_max_date, fetched_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(item, city, quality) DO UPDATE SET - sell_price_min = excluded.sell_price_min, - sell_min_date = excluded.sell_min_date, - buy_price_max = excluded.buy_price_max, - buy_max_date = excluded.buy_max_date, - fetched_at = excluded.fetched_at - `); const now = new Date().toISOString(); - db.transaction(() => { - for (const r of rows) { - const sellDate = normalizeAodpDate(r.sell_price_min_date); - const buyDate = normalizeAodpDate(r.buy_price_max_date); - if (!sellDate && !buyDate) continue; - upsert.run(r.item_id, r.city, r.quality, r.sell_price_min > 0 ? r.sell_price_min : null, sellDate, r.buy_price_max > 0 ? r.buy_price_max : null, buyDate, now); - } - })(); + const priceRows: unknown[][] = []; + for (const r of rows) { + const sellDate = normalizeAodpDate(r.sell_price_min_date); + const buyDate = normalizeAodpDate(r.buy_price_max_date); + if (!sellDate && !buyDate) continue; + priceRows.push([ + r.item_id, + r.city, + r.quality, + r.sell_price_min > 0 ? r.sell_price_min : null, + sellDate, + r.buy_price_max > 0 ? r.buy_price_max : null, + buyDate, + now, + ]); + } + await upsertPriceRows(priceRows); } catch (e) { result.error = e instanceof Error ? e.message : String(e); lastPoll = result; - setMeta('watch_last_error', result.error); + await setMeta('watch_last_error', result.error); return result; } - const insertAlert = db.prepare('INSERT INTO watch_alerts (rule_id, message, price, triggered_at, seen) VALUES (?, ?, ?, ?, 0)'); - const hasUnseen = db.prepare('SELECT COUNT(*) AS n FROM watch_alerts WHERE rule_id = ? AND seen = 0'); - for (const rule of rules) { - const prices = getPricesForItem(rule.item).filter((p) => p.city === rule.city && p.quality === rule.quality); + const prices = (await getPricesForItem(rule.item)).filter((p) => p.city === rule.city && p.quality === rule.quality); if (prices.length === 0) continue; const p = prices[0]; let triggered = false; @@ -155,9 +154,14 @@ export async function pollWatches(): Promise { } if (!triggered) continue; // don't stack duplicate alerts while one is still unread - const unseen = hasUnseen.get(rule.id) as { n: number }; + const unseen = (await get<{ n: number }>('SELECT COUNT(*) AS n FROM watch_alerts WHERE rule_id = ? AND seen = 0', [rule.id]))!; if (unseen.n > 0) continue; - insertAlert.run(rule.id, CONDITION_LABELS[rule.condition](rule, price), price, new Date().toISOString()); + await query('INSERT INTO watch_alerts (rule_id, message, price, triggered_at, seen) VALUES (?, ?, ?, ?, 0)', [ + rule.id, + CONDITION_LABELS[rule.condition](rule, price), + price, + new Date().toISOString(), + ]); result.newAlerts++; } @@ -165,11 +169,11 @@ export async function pollWatches(): Promise { return result; } -export function startWatcherLoop(): void { +export async function startWatcherLoop(): Promise { if (timer) clearInterval(timer); - const minutes = Math.max(2, Number(getMeta('watch_interval_min') ?? 15)); + intervalMinutes = Math.max(2, Number((await getMeta('watch_interval_min')) ?? 15)); timer = setInterval(() => { void pollWatches().catch(() => undefined); - }, minutes * 60_000); + }, intervalMinutes * 60_000); timer.unref?.(); } diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..ada27c5 --- /dev/null +++ b/shared/package.json @@ -0,0 +1,23 @@ +{ + "name": "@albion/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "typescript": "^5.8.0" + }, + "dependencies": { + "des.js": "^1.1.0", + "fast-xml-parser": "^5.11.0", + "photon-packet-parser": "^0.0.19" + } +} diff --git a/shared/src/capture.ts b/shared/src/capture.ts new file mode 100644 index 0000000..8ce3bf5 --- /dev/null +++ b/shared/src/capture.ts @@ -0,0 +1,157 @@ +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import { PhotonPacketParser } from 'photon-packet-parser'; + +/** + * Passive capture of the player's OWN Albion client traffic (UDP 5056), by + * spawning tshark (Wireshark CLI, which bundles Npcap on Windows) — no native + * Node modules needed. Payloads are decoded with the pure-JS Photon parser. + * Read-only observation — the SBI-tolerated category (same technique as the + * Albion Data Project client). No memory reading, no injection, no automation. + */ + +const TSHARK_CANDIDATES = [ + 'C:\\Program Files\\Wireshark\\tshark.exe', + 'C:\\Program Files (x86)\\Wireshark\\tshark.exe', +]; + +/** Market LocationId -> city (protocol ids, not cluster ids). */ +export const MARKET_LOCATIONS: Record = { + '0007': 'Thetford', + '7': 'Thetford', + '1002': 'Lymhurst', + '2004': 'Bridgewatch', + '3005': 'Caerleon', + '3008': 'Martlock', + '4002': 'Fort Sterling', + '5003': 'Brecilien', + '3003': 'Black Market', +}; + +export interface AlbionOrder { + UnitPriceSilver: number; + Amount: number; + AuctionType: string; + ItemTypeId: string; + EnchantmentLevel: number; + QualityLevel: number; + LocationId: string | number; +} + +export function orderItemId(o: AlbionOrder): string { + if (o.ItemTypeId.includes('@')) return o.ItemTypeId; + return o.EnchantmentLevel > 0 ? `${o.ItemTypeId}@${o.EnchantmentLevel}` : o.ItemTypeId; +} + +export function findTshark(): string | null { + for (const p of TSHARK_CANDIDATES) if (fs.existsSync(p)) return p; + const which = spawnSync('where', ['tshark'], { encoding: 'utf8' }); + const found = which.stdout?.split(/\r?\n/).find((l) => l.trim().endsWith('tshark.exe')); + return found?.trim() ?? null; +} + +export function listInterfaces(): { id: string; name: string }[] { + const tshark = findTshark(); + if (!tshark) return []; + const res = spawnSync(tshark, ['-D'], { encoding: 'utf8', timeout: 15000 }); + return (res.stdout ?? '') + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + const m = line.match(/^(\d+)\.\s+(.*)$/); + return m ? { id: m[1], name: m[2] } : null; + }) + .filter((x): x is { id: string; name: string } => x != null); +} + +/** Recursively hunt for market-order JSON strings in photon parameters (patch-proof: detected by shape, not event code). */ +export function extractOrders(value: unknown, out: AlbionOrder[], depth = 0): void { + if (depth > 4 || value == null) return; + if (typeof value === 'string') { + if (value.length > 20 && value.includes('UnitPriceSilver')) { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === 'object' && 'UnitPriceSilver' in parsed) out.push(parsed as AlbionOrder); + } catch { + /* not JSON */ + } + } + return; + } + if (Array.isArray(value)) { + for (const v of value) extractOrders(v, out, depth + 1); + return; + } + if (typeof value === 'object') { + for (const v of Object.values(value)) extractOrders(v, out, depth + 1); + } +} + +export interface CaptureCallbacks { + onPacket?: () => void; + onEventCode?: (code: number) => void; + onOrders?: (orders: AlbionOrder[]) => void; + onError?: (message: string) => void; +} + +export interface CaptureHandle { + stop: () => void; +} + +export function startCapture(callbacks: CaptureCallbacks, interfaceIds?: string[]): CaptureHandle | { error: string } { + const tshark = findTshark(); + if (!tshark) { + return { + error: + 'tshark.exe not found. Install Wireshark (https://www.wireshark.org) with the bundled Npcap driver — the sniffer uses it to read your own game traffic passively.', + }; + } + + const parser = new PhotonPacketParser(); + const handleMessage = (data: any, isEvent: boolean) => { + if (isEvent) { + const code = data?.code ?? data?.Code; + if (typeof code === 'number') callbacks.onEventCode?.(code); + } + const orders: AlbionOrder[] = []; + extractOrders(data?.parameters ?? data?.Parameters ?? data, orders); + if (orders.length > 0) callbacks.onOrders?.(orders); + }; + parser.on('event', (d: any) => handleMessage(d, true)); + parser.on('response', (d: any) => handleMessage(d, false)); + parser.on('request', () => undefined); + parser.on('error', () => undefined); + + const ifaces = interfaceIds && interfaceIds.length > 0 ? interfaceIds : listInterfaces().map((i) => i.id); + const ifaceArgs = ifaces.flatMap((i) => ['-i', i]); + const args = [...ifaceArgs, '-l', '-n', '-q', '-f', 'udp port 5056', '-T', 'fields', '-e', 'data']; + + const proc: ChildProcess = spawn(tshark, args, { windowsHide: true }); + + let pending = ''; + proc.stdout!.on('data', (chunk: Buffer) => { + pending += chunk.toString('ascii'); + const lines = pending.split('\n'); + pending = lines.pop() ?? ''; + for (const line of lines) { + const hex = line.trim().replace(/:/g, ''); + if (!hex || hex.length < 24) continue; + callbacks.onPacket?.(); + try { + parser.handle(Buffer.from(hex, 'hex')); + } catch { + /* malformed packet — ignore */ + } + } + }); + proc.stderr!.on('data', (chunk: Buffer) => { + const msg = chunk.toString('utf8').trim(); + // tshark prints capture stats to stderr; only surface real errors + if (msg && !/^Capturing on/i.test(msg) && !/^\d+ packets?/i.test(msg)) callbacks.onError?.(msg.slice(0, 400)); + }); + proc.on('exit', (code) => { + if (code !== 0 && code != null) callbacks.onError?.(`tshark exited with code ${code}`); + }); + + return { stop: () => proc.kill() }; +} diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 0000000..bfced50 --- /dev/null +++ b/shared/src/index.ts @@ -0,0 +1,19 @@ +export { + decryptBin, + parseWorldXml, + zoneColor, + WORLD_BIN_SUBPATH, + type ParsedWorld, + type WorldZone, +} from './worldParse.js'; +export { + extractOrders, + findTshark, + listInterfaces, + MARKET_LOCATIONS, + orderItemId, + startCapture, + type AlbionOrder, + type CaptureCallbacks, + type CaptureHandle, +} from './capture.js'; diff --git a/shared/src/worldParse.ts b/shared/src/worldParse.ts new file mode 100644 index 0000000..1bda0da --- /dev/null +++ b/shared/src/worldParse.ts @@ -0,0 +1,140 @@ +// @ts-expect-error des.js ships no types; pure-JS DES is used because OpenSSL 3 +// (Node 17+) removed legacy single-DES from node:crypto +import des from 'des.js'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { XMLParser } from 'fast-xml-parser'; + +/** + * Parsing for the game's world map file: + * \game\Albion-Online_Data\StreamingAssets\GameData\cluster\world.bin + * .bin files are DES-encrypted, gzip-compressed XML. The key/IV are public + * knowledge (ao-id-extractor, Portaler, Statistics Analysis Tool all use them); + * we only read the file, never modify the game. + */ +const BIN_KEY = Buffer.from([48, 239, 114, 71, 66, 242, 4, 50]); +const BIN_IV = Buffer.from([14, 166, 220, 137, 219, 237, 220, 79]); + +export const WORLD_BIN_SUBPATH = path.join( + 'game', + 'Albion-Online_Data', + 'StreamingAssets', + 'GameData', + 'cluster', + 'world.bin', +); + +export function decryptBin(encrypted: Buffer): Buffer { + const decipher = des.CBC.instantiate(des.DES).create({ type: 'decrypt', key: BIN_KEY, iv: BIN_IV }); + let compressed = Buffer.from([...decipher.update(encrypted), ...decipher.final()]); + // strip PKCS7 padding (des.js does not remove it) + const pad = compressed[compressed.length - 1]; + if (pad >= 1 && pad <= 8) compressed = compressed.subarray(0, compressed.length - pad); + return zlib.gunzipSync(compressed); +} + +export function zoneColor(type: string): string { + const t = type.toUpperCase(); + if (t.startsWith('PLAYERCITY')) return 'city'; + if (t.startsWith('SAFEAREA')) return 'blue'; + if (t.includes('YELLOW')) return 'yellow'; + if (t.includes('RED')) return 'red'; + if (t.includes('BLACK')) return 'black'; + if (t.startsWith('TUNNEL')) return 'road'; + return 'other'; +} + +export interface WorldZone { + albionId: string; + name: string; + type: string; + color: string; +} + +export interface ParsedWorld { + zones: WorldZone[]; + connections: [string, string][]; +} + +interface RawExit { + '@id'?: string; + '@targetid'?: string; +} + +interface RawCluster { + '@id'?: string; + '@displayname'?: string; + '@type'?: string; + exits?: { exit?: RawExit | RawExit[] }; +} + +function asArray(v: T | T[] | undefined): T[] { + if (v === undefined || v === null) return []; + return Array.isArray(v) ? v : [v]; +} + +/** Parse decrypted world.xml into overworld zones + portal connections. */ +export function parseWorldXml(xml: string): ParsedWorld { + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@', + isArray: (name) => name === 'cluster' || name === 'exit', + }); + const doc = parser.parse(xml); + const clusters: RawCluster[] = doc?.world?.clusters?.cluster ?? []; + if (clusters.length === 0) throw new Error('no clusters found in world.bin — format may have changed'); + + // Same filtering approach as Portaler: overworld zones only. Roads of Avalon + // (TUNNEL_*) have dynamic connections, so they are excluded from routing. + const zones = clusters.filter((c) => { + const type = (c['@type'] ?? '').toUpperCase(); + const name = c['@displayname'] ?? ''; + const id = c['@id'] ?? ''; + return ( + (type.startsWith('OPENPVP') || type.startsWith('SAFEAREA') || type.startsWith('PLAYERCITY') || type.startsWith('PASSAGE')) && + !!id && + !!name && + !name.toLowerCase().includes('debug') && + !type.includes('DUNGEON_') && + !type.includes('EXPEDITION_') && + !type.includes('ARENA_') && + !type.includes('_NOFURNITURE') && + type !== 'PASSAGE_SAFEAREA' + ); + }); + + const zoneIds = new Set(zones.map((z) => z['@id']!)); + + // Exits reference each other by GUID: exit X's targetid is the GUID of the + // matching exit in the neighboring zone. Map every exit GUID to its zone, + // then resolve each targetid to the neighbor. + const exitOwner = new Map(); + for (const z of zones) { + for (const e of asArray(z.exits?.exit)) { + const id = e['@id']; + if (id) exitOwner.set(String(id).slice(0, 36), z['@id']!); + } + } + + const edges = new Set(); + for (const z of zones) { + for (const e of asArray(z.exits?.exit)) { + const targetId = e['@targetid']; + if (!targetId) continue; + const neighbor = exitOwner.get(String(targetId).slice(0, 36)); + if (!neighbor || neighbor === z['@id'] || !zoneIds.has(neighbor)) continue; + const [a, b] = [z['@id']!, neighbor].sort(); + edges.add(`${a}|${b}`); + } + } + + return { + zones: zones.map((z) => ({ + albionId: z['@id']!, + name: z['@displayname']!, + type: z['@type'] ?? '', + color: zoneColor(z['@type'] ?? ''), + })), + connections: [...edges].map((e) => e.split('|') as [string, string]), + }; +} diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..81bbd4b --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 1b2880f..0566b69 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import ItemsPage from './pages/ItemsPage'; import GameDataPage from './pages/GameDataPage'; import MarketPage from './pages/MarketPage'; @@ -8,6 +8,7 @@ import CharacterPage from './pages/CharacterPage'; import TravelPage from './pages/TravelPage'; import WatchPage from './pages/WatchPage'; import SnifferPage from './pages/SnifferPage'; +import { getApiKey, onUnauthorized, setApiKey } from './apiKey'; const PAGES = [ { id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage }, @@ -21,9 +22,37 @@ const PAGES = [ { id: 'settings', label: 'Settings', component: SettingsPage }, ] as const; +function ApiKeyGate({ onSaved }: { onSaved: () => void }) { + const [key, setKey] = useState(getApiKey()); + return ( +
+

API key required

+

+ This server is protected. Enter the API key (the ALBION_API_KEY configured on the server) to + continue. It's stored only in this browser. +

+
+ setKey(e.target.value)} /> + +
+
+ ); +} + export default function App() { - const [page, setPage] = useState('items'); - const Active = PAGES.find((p) => p.id === page)?.component ?? ItemsPage; + const [page, setPage] = useState('opportunities'); + const [locked, setLocked] = useState(false); + const Active = PAGES.find((p) => p.id === page)?.component ?? OpportunitiesPage; + + useEffect(() => { + onUnauthorized(() => setLocked(true)); + // probe: if the server needs a key and we don't have a valid one, lock + fetch('/api/settings').then((r) => { + if (r.status === 401) setLocked(true); + }); + }, []); + + if (locked) return { setLocked(false); location.reload(); }} />; return (
diff --git a/web/src/apiKey.ts b/web/src/apiKey.ts new file mode 100644 index 0000000..478f94b --- /dev/null +++ b/web/src/apiKey.ts @@ -0,0 +1,41 @@ +/** + * When the server runs behind ALBION_API_KEY (any public deployment), the web + * UI must send it too. We store the key in localStorage and transparently add + * `Authorization: Bearer ` to same-origin /api requests via a fetch wrapper, + * so individual pages keep calling fetch('/api/...') unchanged. + * + * On localhost with no key configured, everything works with an empty key. + */ +const STORAGE_KEY = 'albion_api_key'; + +export function getApiKey(): string { + return localStorage.getItem(STORAGE_KEY) ?? ''; +} + +export function setApiKey(key: string): void { + if (key) localStorage.setItem(STORAGE_KEY, key); + else localStorage.removeItem(STORAGE_KEY); +} + +let unauthorizedHandler: (() => void) | null = null; +export function onUnauthorized(handler: () => void): void { + unauthorizedHandler = handler; +} + +export function installFetchAuth(): void { + const original = window.fetch.bind(window); + window.fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url; + const isApi = url.startsWith('/api/') || url.includes(`${location.origin}/api/`); + const key = getApiKey(); + let nextInit = init; + if (isApi && key) { + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); + if (!headers.has('Authorization')) headers.set('Authorization', `Bearer ${key}`); + nextInit = { ...init, headers }; + } + const res = await original(input as any, nextInit); + if (isApi && res.status === 401) unauthorizedHandler?.(); + return res; + }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 8f7da6f..440467b 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,8 +1,11 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; +import { installFetchAuth } from './apiKey'; import './styles.css'; +installFetchAuth(); + ReactDOM.createRoot(document.getElementById('root')!).render(