Client/server split, Postgres, auth, Windows agent, Docker deploy
All checks were successful
Deploy / deploy (push) Successful in 3m58s

- Extract world-parse + packet capture into @albion/shared (reused by
  server and a new standalone agent workspace).
- Migrate the data layer from SQLite/better-sqlite3 to shared Postgres
  (async pg with a ?->$n helper; economy engine preloads the item/recipe
  graph so its sync recursion still works).
- API-key auth (ALBION_API_KEY) on all /api routes except health; web UI
  sends it via a fetch wrapper + key-entry gate; server serves the built
  UI with a path-traversal-safe static handler (drops @fastify/static).
- Windows companion agent: pushes world map + captured market orders to
  /api/ingest/*; runs on the gaming PC where the game traffic lives.
- Docker + docker-compose (external proxy + backend networks for Nginx
  Proxy Manager and shared Postgres) and a Forgejo deploy workflow.
- Validated end-to-end against Postgres 16 and a full compose run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Заид Омар Медхат 2026-08-23 04:10:08 +05:00
parent 9c4a2a8ed3
commit 337dac78ba
48 changed files with 2090 additions and 1388 deletions

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
node_modules
**/node_modules
**/dist
server/data
.git
*.log
.env

10
.env.example Normal file
View file

@ -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

View file

@ -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

46
Dockerfile Normal file
View file

@ -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"]

View file

@ -1,46 +1,76 @@
# Albion Economy Assistant # 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 install
npm run gamedata # first time only: download items/recipes (~40 MB) npm run gamedata # download items/recipes into Postgres (first time)
npm run dev # starts API (port 4310) + UI 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. The repo ships a `Dockerfile`, `docker-compose.yml`, and `.forgejo/workflows/deploy.yml`.
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. 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;`
4. **Travel Planner page** — click *Import world map* (reads zone connections from your game's files, read-only). 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 <key>` (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 ## 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. - **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** — 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. - **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 (drops below / rises above / buy orders above) checked on an interval. - **Market Watch** — price alert rules 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. - **Character** — destiny board profiles (mastery/spec per category) that drive focus costs and taxes.
- **Item Browser / Game Data** — recipe explorer; re-download game data after patches. - **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 ## After a game patch
- *Game Data* page → re-download game data. - *Game Data* page → re-download game data (or the agent/`npm run gamedata`).
- *Travel Planner* → re-import the world map. - *Travel Planner* → re-import the world map (or `npm run agent -- world`).
- If balance values changed (return rates, taxes, city bonuses), edit them via `PUT /api/economy/config` — all game constants are config, not code. - Balance constants (return rates, taxes, city bonuses) are editable via `PUT /api/economy/config` — 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
## Tests ## Tests
``` ```
npm test npm test # economy math (vitest); the engine is pure, no DB needed
``` ```

47
agent/README.md Normal file
View file

@ -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.

18
agent/package.json Normal file
View file

@ -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"
}
}

36
agent/src/api.ts Normal file
View file

@ -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<any> {
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<any> {
return fetch(`${this.cfg.serverUrl}/api/health`, { signal: AbortSignal.timeout(10000) }).then((r) => r.json());
}
sendWorld(world: ParsedWorld): Promise<any> {
return this.post('/api/ingest/world', { world });
}
sendOrders(orders: AlbionOrder[]): Promise<any> {
return this.post('/api/ingest/orders', { orders });
}
}

54
agent/src/config.ts Normal file
View file

@ -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<AgentConfig> = {};
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));
}

143
agent/src/index.ts Normal file
View file

@ -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<void> {
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<void> {
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();

16
agent/tsconfig.json Normal file
View file

@ -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"]
}

38
docker-compose.yml Normal file
View file

@ -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

638
package-lock.json generated
View file

@ -8,17 +8,39 @@
"name": "albion-economy-assistant", "name": "albion-economy-assistant",
"version": "0.1.0", "version": "0.1.0",
"workspaces": [ "workspaces": [
"shared",
"server", "server",
"web" "web",
"agent"
], ],
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "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": { "node_modules/@albion/server": {
"resolved": "server", "resolved": "server",
"link": true "link": true
}, },
"node_modules/@albion/shared": {
"resolved": "shared",
"link": true
},
"node_modules/@albion/web": { "node_modules/@albion/web": {
"resolved": "web", "resolved": "web",
"link": true "link": true
@ -1345,16 +1367,6 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@ -1390,6 +1402,18 @@
"undici-types": "~7.18.0" "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": { "node_modules/@types/react": {
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@ -1687,26 +1711,6 @@
"fastq": "^1.17.1" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.11.18", "version": "2.11.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz",
@ -1720,40 +1724,6 @@
"node": ">=6.0.0" "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": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", "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": "^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": { "node_modules/buffercursor": {
"version": "0.0.12", "version": "0.0.12",
"resolved": "https://registry.npmjs.org/buffercursor/-/buffercursor-0.0.12.tgz", "resolved": "https://registry.npmjs.org/buffercursor/-/buffercursor-0.0.12.tgz",
@ -1911,12 +1857,6 @@
"node": ">= 16" "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": { "node_modules/cliui": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "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": { "node_modules/deep-eql": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@ -2053,15 +1978,6 @@
"node": ">=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": { "node_modules/dequal": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@ -2081,15 +1997,6 @@
"minimalistic-assert": "^1.0.0" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.412", "version": "1.5.412",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz",
@ -2104,15 +2011,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/es-module-lexer": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@ -2182,15 +2080,6 @@
"@types/estree": "^1.0.0" "@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": { "node_modules/expect-type": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", "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": { "node_modules/find-my-way": {
"version": "9.9.0", "version": "9.9.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz",
@ -2402,12 +2285,6 @@
"node": ">=20" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -2443,12 +2320,6 @@
"node": "6.* || 8.* || >= 10.*" "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": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -2459,38 +2330,12 @@
"node": ">=8" "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": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ipaddr.js": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz",
@ -2644,39 +2489,12 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/minimalistic-assert": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "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": "^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": { "node_modules/node-releases": {
"version": "2.0.53", "version": "2.0.53",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
@ -2752,15 +2540,6 @@
"node": ">=14.0.0" "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": { "node_modules/path-expression-matcher": {
"version": "1.6.2", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
@ -2793,6 +2572,95 @@
"node": ">= 14.16" "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": { "node_modules/photon-packet-parser": {
"version": "0.0.19", "version": "0.0.19",
"resolved": "https://registry.npmjs.org/photon-packet-parser/-/photon-packet-parser-0.0.19.tgz", "resolved": "https://registry.npmjs.org/photon-packet-parser/-/photon-packet-parser-0.0.19.tgz",
@ -2888,31 +2756,43 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prebuild-install": { "node_modules/postgres-array": {
"version": "7.1.3", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "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", "license": "MIT",
"dependencies": { "dependencies": {
"detect-libc": "^2.0.0", "xtend": "^4.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"
}, },
"engines": { "engines": {
"node": ">=10" "node": ">=0.10.0"
} }
}, },
"node_modules/process-warning": { "node_modules/process-warning": {
@ -2931,37 +2811,12 @@
], ],
"license": "MIT" "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": { "node_modules/quick-format-unescaped": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT" "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": { "node_modules/react": {
"version": "19.2.8", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@ -2993,20 +2848,6 @@
"node": ">=0.10.0" "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": { "node_modules/real-require": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
@ -3116,26 +2957,6 @@
"tslib": "^2.1.0" "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": { "node_modules/safe-regex2": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
@ -3225,51 +3046,6 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/sonic-boom": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@ -3312,15 +3088,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -3349,15 +3116,6 @@
"node": ">=8" "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": { "node_modules/strip-literal": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", "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" "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": { "node_modules/thread-stream": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
@ -3561,18 +3291,6 @@
"fsevents": "~2.3.3" "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -3634,12 +3352,6 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/verror": {
"version": "1.10.1", "version": "1.10.1",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
@ -3860,12 +3572,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "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": { "node_modules/xml-naming": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
@ -3881,6 +3587,15 @@
"node": ">=16.0.0" "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": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@ -3931,21 +3646,32 @@
"name": "@albion/server", "name": "@albion/server",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"better-sqlite3": "^12.2.0", "@albion/shared": "*",
"des.js": "^1.1.0",
"fast-xml-parser": "^5.11.0",
"fastify": "^5.4.0", "fastify": "^5.4.0",
"photon-packet-parser": "^0.0.19", "pg": "^8.13.1",
"undici": "^8.10.0" "undici": "^8.10.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"@types/pg": "^8.11.10",
"tsx": "^4.20.0", "tsx": "^4.20.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^3.2.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": { "web": {
"name": "@albion/web", "name": "@albion/web",
"version": "0.1.0", "version": "0.1.0",

View file

@ -4,14 +4,17 @@
"private": true, "private": true,
"description": "Albion Online economy assistant: profit calculator, production chain ranker, travel planner, market watcher", "description": "Albion Online economy assistant: profit calculator, production chain ranker, travel planner, market watcher",
"workspaces": [ "workspaces": [
"shared",
"server", "server",
"web" "web",
"agent"
], ],
"scripts": { "scripts": {
"dev": "concurrently -n server,web -c blue,green \"npm:dev:server\" \"npm:dev:web\"", "dev": "concurrently -n server,web -c blue,green \"npm:dev:server\" \"npm:dev:web\"",
"dev:server": "npm run dev -w server", "dev:server": "npm run dev -w server",
"dev:web": "npm run dev -w web", "dev:web": "npm run dev -w web",
"gamedata": "npm run gamedata -w server", "gamedata": "npm run gamedata -w server",
"agent": "npm run start -w agent",
"test": "npm run test -w server" "test": "npm run test -w server"
}, },
"devDependencies": { "devDependencies": {

View file

@ -11,16 +11,14 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^12.2.0", "@albion/shared": "*",
"des.js": "^1.1.0",
"fast-xml-parser": "^5.11.0",
"fastify": "^5.4.0", "fastify": "^5.4.0",
"photon-packet-parser": "^0.0.19", "pg": "^8.13.1",
"undici": "^8.10.0" "undici": "^8.10.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"@types/pg": "^8.11.10",
"tsx": "^4.20.0", "tsx": "^4.20.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^3.2.0" "vitest": "^3.2.0"

32
server/src/auth.ts Normal file
View file

@ -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 <key>`
* or `?apiKey=<key>`. 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' });
}
});
}

View file

@ -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 * 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 }; return { id: r.id, name: r.name, isActive: !!r.is_active, premium: !!r.premium, data, updatedAt: r.updated_at };
} }
export function listProfiles(): Profile[] { export async function listProfiles(): Promise<Profile[]> {
return (getDb().prepare('SELECT * FROM profiles ORDER BY id').all() as any[]).map(rowToProfile); return (await query('SELECT * FROM profiles ORDER BY id')).map(rowToProfile);
} }
export function getActiveProfile(): Profile | null { export async function getActiveProfile(): Promise<Profile | null> {
const row = getDb().prepare('SELECT * FROM profiles WHERE is_active = 1 LIMIT 1').get(); const row = await get('SELECT * FROM profiles WHERE is_active = 1 LIMIT 1');
return row ? rowToProfile(row) : null; return row ? rowToProfile(row) : null;
} }
export function createProfile(name: string): Profile { export async function createProfile(name: string): Promise<Profile> {
const db = getDb(); const inserted = await get<{ id: number }>(
const { lastInsertRowid } = db 'INSERT INTO profiles (name, is_active, premium, data, updated_at) VALUES (?, 0, 1, ?, ?) RETURNING id',
.prepare('INSERT INTO profiles (name, is_active, premium, data, updated_at) VALUES (?, 0, 1, ?, ?)') [name, '{}', new Date().toISOString()],
.run(name, '{}', new Date().toISOString()); );
const hasActive = db.prepare('SELECT COUNT(*) AS n FROM profiles WHERE is_active = 1').get() as { n: number }; const id = inserted!.id;
if (hasActive.n === 0) db.prepare('UPDATE profiles SET is_active = 1 WHERE id = ?').run(lastInsertRowid); const active = await get<{ n: number }>('SELECT COUNT(*) AS n FROM profiles WHERE is_active = 1');
return rowToProfile(db.prepare('SELECT * FROM profiles WHERE id = ?').get(lastInsertRowid)); 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 { export async function updateProfile(
const db = getDb(); id: number,
const row = db.prepare('SELECT * FROM profiles WHERE id = ?').get(id) as any; patch: { name?: string; premium?: boolean; data?: ProfileData },
): Promise<Profile | null> {
const row = (await get('SELECT * FROM profiles WHERE id = ?', [id])) as any;
if (!row) return null; 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.name ?? row.name,
patch.premium != null ? (patch.premium ? 1 : 0) : row.premium, patch.premium != null ? (patch.premium ? 1 : 0) : row.premium,
patch.data ? JSON.stringify(patch.data) : row.data, patch.data ? JSON.stringify(patch.data) : row.data,
new Date().toISOString(), new Date().toISOString(),
id, 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 { export async function activateProfile(id: number): Promise<void> {
const db = getDb(); await transaction(async (tx) => {
db.transaction(() => { await tx.query('UPDATE profiles SET is_active = 0');
db.prepare('UPDATE profiles SET is_active = 0').run(); await tx.query('UPDATE profiles SET is_active = 1 WHERE id = ?', [id]);
db.prepare('UPDATE profiles SET is_active = 1 WHERE id = ?').run(id); });
})();
} }
export function deleteProfile(id: number): void { export async function deleteProfile(id: number): Promise<void> {
getDb().prepare('DELETE FROM profiles WHERE id = ?').run(id); await query('DELETE FROM profiles WHERE id = ?', [id]);
} }
/** Crafting categories that actually have recipes, for the profile editor UI. */ /** Crafting categories that actually have recipes, for the profile editor UI. */
export function craftableCategories(): { category: string; items: number }[] { export async function craftableCategories(): Promise<{ category: string; items: number }[]> {
return getDb() return query(
.prepare( `SELECT i.crafting_category AS category, COUNT(DISTINCT r.output_item) AS items
`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
FROM recipes r JOIN items i ON i.unique_name = r.output_item WHERE i.crafting_category IS NOT NULL
WHERE i.crafting_category IS NOT NULL GROUP BY i.crafting_category ORDER BY items DESC`,
GROUP BY i.crafting_category ORDER BY items DESC`, );
)
.all() as { category: string; items: number }[];
} }

View file

@ -6,8 +6,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** Server root directory (server/) */ /** Server root directory (server/) */
export const SERVER_ROOT = path.resolve(__dirname, '..'); export const SERVER_ROOT = path.resolve(__dirname, '..');
/** Local data directory (git-ignored): SQLite db + downloaded game data */ /** Local data directory (git-ignored): SQLite db + downloaded game data.
export const DATA_DIR = path.join(SERVER_ROOT, '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 GAMEDATA_DIR = path.join(DATA_DIR, 'gamedata');
export const DB_PATH = path.join(DATA_DIR, 'app.db'); export const DB_PATH = path.join(DATA_DIR, 'app.db');

View file

@ -1,7 +1,18 @@
import Database from 'better-sqlite3'; import pg from 'pg';
import fs from 'node:fs';
import path from 'node:path'; /**
import { DB_PATH } from '../config.js'; * 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 = ` const SCHEMA = `
CREATE TABLE IF NOT EXISTS meta ( CREATE TABLE IF NOT EXISTS meta (
@ -10,12 +21,12 @@ CREATE TABLE IF NOT EXISTS meta (
); );
CREATE TABLE IF NOT EXISTS items ( CREATE TABLE IF NOT EXISTS items (
unique_name TEXT PRIMARY KEY, -- e.g. T4_BAG@1 unique_name TEXT PRIMARY KEY,
base_name TEXT NOT NULL, -- e.g. T4_BAG base_name TEXT NOT NULL,
enchantment INTEGER NOT NULL DEFAULT 0, enchantment INTEGER NOT NULL DEFAULT 0,
tier INTEGER, tier INTEGER,
weight REAL, weight DOUBLE PRECISION,
item_value INTEGER, item_value DOUBLE PRECISION,
shop_category TEXT, shop_category TEXT,
shop_subcategory TEXT, shop_subcategory TEXT,
crafting_category 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 INDEX IF NOT EXISTS idx_items_name_en ON items(name_en);
CREATE TABLE IF NOT EXISTS recipes ( CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
output_item TEXT NOT NULL, output_item TEXT NOT NULL,
variant INTEGER NOT NULL DEFAULT 0, -- alternative recipe index for the same output variant INTEGER NOT NULL DEFAULT 0,
amount_crafted REAL NOT NULL DEFAULT 1, amount_crafted DOUBLE PRECISION NOT NULL DEFAULT 1,
silver_cost REAL NOT NULL DEFAULT 0, silver_cost DOUBLE PRECISION NOT NULL DEFAULT 0,
focus_cost REAL NOT NULL DEFAULT 0, focus_cost DOUBLE PRECISION NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'crafting' -- 'crafting' | 'upgrade' (enchanting) source TEXT NOT NULL DEFAULT 'crafting'
); );
CREATE INDEX IF NOT EXISTS idx_recipes_output ON recipes(output_item); CREATE INDEX IF NOT EXISTS idx_recipes_output ON recipes(output_item);
CREATE TABLE IF NOT EXISTS recipe_inputs ( CREATE TABLE IF NOT EXISTS recipe_inputs (
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
input_item TEXT NOT NULL, input_item TEXT NOT NULL,
count REAL NOT NULL, count DOUBLE PRECISION NOT NULL,
max_return_amount REAL, -- 0 means the resource is never returned (e.g. artifacts) max_return_amount DOUBLE PRECISION,
PRIMARY KEY (recipe_id, input_item) PRIMARY KEY (recipe_id, input_item)
); );
CREATE INDEX IF NOT EXISTS idx_recipe_inputs_item ON recipe_inputs(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, item TEXT NOT NULL,
city TEXT NOT NULL, city TEXT NOT NULL,
quality INTEGER NOT NULL DEFAULT 1, quality INTEGER NOT NULL DEFAULT 1,
sell_price_min REAL, sell_price_min DOUBLE PRECISION,
sell_min_date TEXT, -- when AODP last observed this sell order (UTC ISO) sell_min_date TEXT,
buy_price_max REAL, buy_price_max DOUBLE PRECISION,
buy_max_date TEXT, buy_max_date TEXT,
fetched_at TEXT NOT NULL, fetched_at TEXT NOT NULL,
PRIMARY KEY (item, city, quality) PRIMARY KEY (item, city, quality)
); );
CREATE INDEX IF NOT EXISTS idx_market_prices_city ON market_prices(city); CREATE INDEX IF NOT EXISTS idx_market_prices_city ON market_prices(city);
CREATE TABLE IF NOT EXISTS watch_rules ( CREATE TABLE IF NOT EXISTS price_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item TEXT NOT NULL, item TEXT NOT NULL,
city TEXT NOT NULL, city TEXT NOT NULL,
quality INTEGER NOT NULL DEFAULT 1, quality INTEGER NOT NULL DEFAULT 1,
condition TEXT NOT NULL, -- 'sell_below' | 'sell_above' | 'buy_above' sell_price DOUBLE PRECISION,
threshold REAL NOT NULL, 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, enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS watch_alerts ( 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, rule_id INTEGER NOT NULL REFERENCES watch_rules(id) ON DELETE CASCADE,
message TEXT NOT NULL, message TEXT NOT NULL,
price REAL, price DOUBLE PRECISION,
triggered_at TEXT NOT NULL, triggered_at TEXT NOT NULL,
seen INTEGER NOT NULL DEFAULT 0 seen INTEGER NOT NULL DEFAULT 0
); );
@ -91,48 +113,93 @@ CREATE TABLE IF NOT EXISTS zone_connections (
); );
CREATE TABLE IF NOT EXISTS profiles ( CREATE TABLE IF NOT EXISTS profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 0,
premium INTEGER NOT NULL DEFAULT 1, 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 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 { export function getPool(): pg.Pool {
if (!db) { if (!pool) {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); const connectionString = process.env.DATABASE_URL;
db = new Database(DB_PATH); if (!connectionString) {
db.pragma('journal_mode = WAL'); throw new Error('DATABASE_URL is not set (e.g. postgres://user:pass@postgres:5432/albion)');
db.pragma('foreign_keys = ON'); }
db.exec(SCHEMA); pool = new pg.Pool({ connectionString, max: 10 });
} }
return db; return pool;
} }
export function getMeta(key: string): string | null { /** Rewrite `?` placeholders to Postgres `$1, $2, ...` (our SQL has no `?` in literals). */
const row = getDb().prepare('SELECT value FROM meta WHERE key = ?').get(key) as function toPg(sql: string): string {
| { value: string } let i = 0;
| undefined; return sql.replace(/\?/g, () => `$${++i}`);
}
export async function query<T = any>(sql: string, params: unknown[] = []): Promise<T[]> {
const res = await getPool().query(toPg(sql), params as any[]);
return res.rows as T[];
}
export async function get<T = any>(sql: string, params: unknown[] = []): Promise<T | null> {
const rows = await query<T>(sql, params);
return rows[0] ?? null;
}
export async function exec(sql: string): Promise<void> {
await getPool().query(sql);
}
/** Run a function inside a transaction with a dedicated client (same `?` helper). */
export interface Tx {
query<T = any>(sql: string, params?: unknown[]): Promise<T[]>;
get<T = any>(sql: string, params?: unknown[]): Promise<T | null>;
exec(sql: string): Promise<void>;
}
export async function transaction<R>(fn: (tx: Tx) => Promise<R>): Promise<R> {
const client = await getPool().connect();
const tx: Tx = {
async query<T = any>(sql: string, params: unknown[] = []) {
return (await client.query(toPg(sql), params as any[])).rows as T[];
},
async get<T = any>(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<void> {
await exec(SCHEMA);
}
export async function getMeta(key: string): Promise<string | null> {
const row = await get<{ value: string }>('SELECT value FROM meta WHERE key = ?', [key]);
return row?.value ?? null; return row?.value ?? null;
} }
export function setMeta(key: string, value: string): void { export async function setMeta(key: string, value: string): Promise<void> {
getDb() await query(
.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
.run(key, value); [key, value],
);
} }

View file

@ -54,8 +54,8 @@ export const DEFAULT_ECONOMY_CONFIG: EconomyConfig = {
defaultStationFeePer100: 400, defaultStationFeePer100: 400,
}; };
export function getEconomyConfig(): EconomyConfig { export async function getEconomyConfig(): Promise<EconomyConfig> {
const override = getMeta('economy_config'); const override = await getMeta('economy_config');
if (!override) return DEFAULT_ECONOMY_CONFIG; if (!override) return DEFAULT_ECONOMY_CONFIG;
try { try {
return { ...DEFAULT_ECONOMY_CONFIG, ...(JSON.parse(override) as Partial<EconomyConfig>) }; return { ...DEFAULT_ECONOMY_CONFIG, ...(JSON.parse(override) as Partial<EconomyConfig>) };
@ -64,6 +64,6 @@ export function getEconomyConfig(): EconomyConfig {
} }
} }
export function setEconomyConfig(partial: Partial<EconomyConfig>): void { export async function setEconomyConfig(partial: Partial<EconomyConfig>): Promise<void> {
setMeta('economy_config', JSON.stringify(partial)); await setMeta('economy_config', JSON.stringify(partial));
} }

View file

@ -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 { focusEfficiencyFor, getActiveProfile } from '../character/service.js';
import { getEconomyConfig } from './config.js'; import { getEconomyConfig } from './config.js';
import { import {
@ -19,68 +19,113 @@ function ageHours(iso: string | null): number | null {
} }
/** /**
* DataProvider backed by SQLite. Prices for the requested city are preloaded * The economy engine's DataProvider is synchronous (it recurses deep for
* into maps (quality 1, manual overrides taking precedence); items/recipes are * buy-vs-craft), but Postgres access is async. So we preload the whole item +
* loaded lazily with memoization. * 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 { interface Graph {
const db = getDb(); items: Map<string, ItemData>;
namesEn: Map<string, string>;
recipesByOutput: Map<string, RecipeData[]>;
}
const sellMap = new Map<string, PricePoint>(); let graphCache: { key: string; graph: Graph } | null = null;
const buyMap = new Map<string, PricePoint>();
const priceRows = db async function loadGraph(): Promise<Graph> {
.prepare('SELECT item, sell_price_min, sell_min_date, buy_price_max, buy_max_date FROM market_prices WHERE city = ? AND quality = 1') const key = (await getMeta('gamedata_parsed_at')) ?? 'none';
.all(city) as any[]; 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<string, ItemData>();
const namesEn = new Map<string, string>();
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<number, { item: string; count: number; maxReturnAmount: number | null }[]>();
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<string, RecipeData[]>();
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<string, PricePoint>; buy: Map<string, PricePoint> }> {
const sell = new Map<string, PricePoint>();
const buy = new Map<string, PricePoint>();
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) { 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.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) buyMap.set(r.item, { price: r.buy_price_max, ageHours: ageHours(r.buy_max_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 const overrideRows = (await query('SELECT item, sell_price, buy_price, updated_at FROM price_overrides WHERE city = ? AND quality = 1', [city])) as any[];
.prepare('SELECT item, sell_price, buy_price, updated_at FROM price_overrides WHERE city = ? AND quality = 1')
.all(city) as any[];
for (const o of overrideRows) { 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.sell_price != null) sell.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.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 = ?'); export interface BuiltProvider extends DataProvider {
const recipesStmt = db.prepare('SELECT * FROM recipes WHERE output_item = ?'); nameOf(item: string): string | null;
const inputsStmt = db.prepare('SELECT input_item, count, max_return_amount FROM recipe_inputs WHERE recipe_id = ?'); }
const itemCache = new Map<string, ItemData | null>();
const recipeCache = new Map<string, RecipeData[]>();
export async function buildProvider(city: string): Promise<BuiltProvider> {
const graph = await loadGraph();
const { sell, buy } = await loadCityPrices(city);
return { return {
item(name: string): ItemData | null { item: (name) => graph.items.get(name) ?? null,
if (itemCache.has(name)) return itemCache.get(name)!; recipesFor: (name) => graph.recipesByOutput.get(name) ?? [],
const row = itemStmt.get(name) as any; sellMin: (item) => sell.get(item) ?? null,
const data: ItemData | null = row buyMax: (item) => buy.get(item) ?? null,
? { uniqueName: row.unique_name, tier: row.tier, itemValue: row.item_value, craftingCategory: row.crafting_category } nameOf: (item) => graph.namesEn.get(item) ?? null,
: 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,
}; };
} }
@ -88,7 +133,7 @@ export interface RankRequest {
city: string; city: string;
minTier?: number; minTier?: number;
maxTier?: number; maxTier?: number;
categories?: string[]; // crafting_category filter categories?: string[];
useFocus?: boolean; useFocus?: boolean;
focusEfficiency?: number; focusEfficiency?: number;
premium?: boolean; premium?: boolean;
@ -111,12 +156,12 @@ export interface RankResult {
profileName: string | null; profileName: string | null;
} }
export function rankOpportunities(req: RankRequest): RankResult { export async function rankOpportunities(req: RankRequest): Promise<RankResult> {
const cfg = getEconomyConfig(); const cfg = await getEconomyConfig();
const provider = buildProvider(req.city); const provider = await buildProvider(req.city);
// The active character profile supplies premium status and per-category focus // The active character profile supplies premium status and per-category focus
// efficiency unless the request overrides them explicitly. // efficiency unless the request overrides them explicitly.
const profile = getActiveProfile(); const profile = await getActiveProfile();
const opts: CalcOptions = { const opts: CalcOptions = {
city: req.city, city: req.city,
useFocus: req.useFocus ?? false, useFocus: req.useFocus ?? false,
@ -127,21 +172,26 @@ export function rankOpportunities(req: RankRequest): RankResult {
maxCraftDepth: req.maxCraftDepth ?? 2, maxCraftDepth: req.maxCraftDepth ?? 2,
}; };
const db = getDb();
const params: unknown[] = [req.minTier ?? 2, req.maxTier ?? 8]; const params: unknown[] = [req.minTier ?? 2, req.maxTier ?? 8];
let categoryFilter = ''; let categoryFilter = '';
if (req.categories && req.categories.length > 0) { if (req.categories && req.categories.length > 0) {
categoryFilter = ` AND i.crafting_category IN (${req.categories.map(() => '?').join(',')})`; categoryFilter = ` AND i.crafting_category IN (${req.categories.map(() => '?').join(',')})`;
params.push(...req.categories); params.push(...req.categories);
} }
const candidates = db const candidates = await query<{
.prepare( id: number;
`SELECT r.id, r.output_item, i.name_en, i.tier, i.enchantment, i.crafting_category output_item: string;
FROM recipes r name_en: string | null;
JOIN items i ON i.unique_name = r.output_item tier: number | null;
WHERE i.tier BETWEEN ? AND ?${categoryFilter}`, enchantment: number;
) crafting_category: string | null;
.all(...params) as { 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<string, AcquisitionResult | null>(); const cache = new Map<string, AcquisitionResult | null>();
const results: RankedOpportunity[] = []; const results: RankedOpportunity[] = [];
@ -168,14 +218,10 @@ export function rankOpportunities(req: RankRequest): RankResult {
results.sort((a, b) => b.profit - a.profit); results.sort((a, b) => b.profit - a.profit);
const top = results.slice(0, req.limit ?? 200); 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 r of top) {
for (const inp of r.inputs) { for (const inp of r.inputs) {
if (r.inputNames[inp.item]) continue; const name = provider.nameOf(inp.item);
const it = nameStmt.get(inp.item) as { name_en: string | null } | undefined; if (name) r.inputNames[inp.item] = name;
if (it?.name_en) r.inputNames[inp.item] = it.name_en;
} }
} }
return { opportunities: top, profileName: profile?.name ?? null }; return { opportunities: top, profileName: profile?.name ?? null };

View file

@ -1,9 +1,11 @@
import { downloadGameData } from './download.js'; import { downloadGameData } from './download.js';
import { parseGameData } from './parse.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'); const force = process.argv.includes('--force');
await initDb();
console.log(`Downloading game data${force ? ' (forced refresh)' : ''}...`); console.log(`Downloading game data${force ? ' (forced refresh)' : ''}...`);
const downloads = await downloadGameData(force); const downloads = await downloadGameData(force);
for (const d of downloads) { for (const d of downloads) {
@ -11,25 +13,22 @@ for (const d of downloads) {
} }
console.log('Parsing into database...'); 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}`); 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) // Sanity check: a well-known recipe (Adept's Bag)
const db = getDb(); const bag = await query<{ id: number; output_item: string; focus_cost: number; name_en: string }>(
const bag = db `SELECT r.id, r.output_item, r.focus_cost, i.name_en
.prepare( FROM recipes r JOIN items i ON i.unique_name = r.output_item
`SELECT r.id, r.output_item, r.focus_cost, i.name_en WHERE r.output_item = 'T4_BAG' AND r.source = 'crafting'`,
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[];
for (const r of bag) { for (const r of bag) {
const inputs = db const inputs = await query('SELECT input_item, count, max_return_amount FROM recipe_inputs WHERE recipe_id = ?', [r.id]);
.prepare('SELECT input_item, count, max_return_amount FROM recipe_inputs WHERE recipe_id = ?')
.all(r.id);
console.log(`Sanity check — ${r.name_en} (${r.output_item}), focus ${r.focus_cost}:`, inputs); console.log(`Sanity check — ${r.name_en} (${r.output_item}), focus ${r.focus_cost}:`, inputs);
} }
if (bag.length === 0) { if (bag.length === 0) {
console.error('WARNING: T4_BAG recipe not found — parser needs adjustment.'); console.error('WARNING: T4_BAG recipe not found — parser needs adjustment.');
process.exitCode = 1; process.exitCode = 1;
} }
process.exit(process.exitCode ?? 0);

View file

@ -1,5 +1,5 @@
import fs from 'node:fs'; 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'; 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<void> {
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<ParseStats> {
const itemsDump = JSON.parse(fs.readFileSync(gamedataFilePath('items.json'), 'utf8')); const itemsDump = JSON.parse(fs.readFileSync(gamedataFilePath('items.json'), 'utf8'));
const localized = JSON.parse(fs.readFileSync(gamedataFilePath('items_localized.json'), 'utf8')) as Raw[]; 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); if (typeof un === 'string' && typeof name === 'string') namesEn.set(un, name);
} }
const db = getDb(); // De-duplicate items by unique_name (last wins, matching the old INSERT OR REPLACE).
const insertItem = db.prepare(` const itemByName = new Map<string, ParsedItem>();
INSERT OR REPLACE INTO items for (const it of items) itemByName.set(it.uniqueName, it);
(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 (?, ?, ?, ?)
`);
let inputCount = 0; const itemRows = [...itemByName.values()].map((it) => [
db.transaction(() => { it.uniqueName,
db.exec('DELETE FROM recipe_inputs; DELETE FROM recipes; DELETE FROM items;'); it.baseName,
for (const it of items) { it.enchantment,
// Enchanted names like T4_BAG@1 resolve their display name from the localized dump it.tier,
// when present; fall back to the base item's name. it.weight,
const nameEn = namesEn.get(it.uniqueName) ?? namesEn.get(it.baseName) ?? null; it.itemValue,
insertItem.run( it.shopCategory,
it.uniqueName, it.shopSubcategory,
it.baseName, it.craftingCategory,
it.enchantment, // Enchanted names resolve their display name from the localized dump, falling back to the base item.
it.tier, namesEn.get(it.uniqueName) ?? namesEn.get(it.baseName) ?? null,
it.weight, ]);
it.itemValue,
it.shopCategory, // Assign explicit recipe ids so inputs can be bulk-inserted without RETURNING.
it.shopSubcategory, const recipeRows: unknown[][] = [];
it.craftingCategory, const inputRows: unknown[][] = [];
nameEn, 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<string>();
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, await transaction(async (tx) => {
r.variant, await tx.exec('DELETE FROM recipe_inputs; DELETE FROM recipes; DELETE FROM items;');
r.amountCrafted, await bulkInsert(
r.silverCost, tx,
r.focusCost, 'items',
r.source, ['unique_name', 'base_name', 'enchantment', 'tier', 'weight', 'item_value', 'shop_category', 'shop_subcategory', 'crafting_category', 'name_en'],
); itemRows,
for (const input of r.inputs) { );
insertInput.run(lastInsertRowid, input.item, input.count, input.maxReturnAmount); await bulkInsert(tx, 'recipes', ['id', 'output_item', 'variant', 'amount_crafted', 'silver_cost', 'focus_cost', 'source'], recipeRows);
inputCount++; 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 = { const stats: ParseStats = {
items: items.length, items: itemRows.length,
recipes: recipes.length, recipes: recipeRows.length,
recipeInputs: inputCount, recipeInputs: inputRows.length,
localizedNames: namesEn.size, localizedNames: namesEn.size,
}; };
setMeta('gamedata_parsed_at', new Date().toISOString()); await setMeta('gamedata_parsed_at', new Date().toISOString());
setMeta('gamedata_stats', JSON.stringify(stats)); await setMeta('gamedata_stats', JSON.stringify(stats));
return stats; return stats;
} }

View file

@ -1,6 +1,11 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Fastify from 'fastify'; import Fastify from 'fastify';
import { API_PORT } from './config.js'; 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 { gamedataRoutes } from './routes/gamedata.js';
import { itemRoutes } from './routes/items.js'; import { itemRoutes } from './routes/items.js';
import { marketRoutes } from './routes/market.js'; import { marketRoutes } from './routes/market.js';
@ -11,9 +16,36 @@ import { watchRoutes } from './routes/watch.js';
import { snifferRoutes } from './routes/sniffer.js'; import { snifferRoutes } from './routes/sniffer.js';
import { startWatcherLoop } from './watcher/service.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' } }); 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 })); app.get('/api/health', async () => ({ ok: true }));
await app.register(gamedataRoutes); await app.register(gamedataRoutes);
@ -24,7 +56,14 @@ await app.register(profileRoutes);
await app.register(travelRoutes); await app.register(travelRoutes);
await app.register(watchRoutes); await app.register(watchRoutes);
await app.register(snifferRoutes); await app.register(snifferRoutes);
startWatcherLoop(); await startWatcherLoop();
await app.listen({ port: API_PORT, host: '127.0.0.1' }); // Serve the built web UI if present (production/Docker). In dev, Vite serves it.
console.log(`API listening on http://127.0.0.1:${API_PORT}`); 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}`);

View file

@ -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 * instead set a proxy (Settings page, or the HTTPS_PROXY env var) and the app
* routes its market requests through it. * routes its market requests through it.
*/ */
function getProxyUrl(): string | null { async function getProxyUrl(): Promise<string | null> {
return getMeta('proxy_url') || process.env.HTTPS_PROXY || process.env.HTTP_PROXY || null; 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<unknown> }> { async function proxyAwareFetch(url: string, timeoutMs: number): Promise<{ ok: boolean; status: number; json(): Promise<unknown> }> {
const proxyUrl = getProxyUrl(); const proxyUrl = await getProxyUrl();
const signal = AbortSignal.timeout(timeoutMs); const signal = AbortSignal.timeout(timeoutMs);
if (proxyUrl) { if (proxyUrl) {
return undiciFetch(url, { signal, dispatcher: new ProxyAgent(proxyUrl) }); return undiciFetch(url, { signal, dispatcher: new ProxyAgent(proxyUrl) });

View file

@ -1,4 +1,4 @@
import { getDb, getMeta, setMeta } from '../db/index.js'; import { get, getMeta, query, setMeta, transaction } from '../db/index.js';
import { import {
AodpUnreachableError, AodpUnreachableError,
CITIES, CITIES,
@ -7,14 +7,14 @@ import {
SERVER_HOSTS, SERVER_HOSTS,
} from './client.js'; } from './client.js';
export function getAlbionServer(): string { export async function getAlbionServer(): Promise<string> {
const s = getMeta('albion_server'); const s = await getMeta('albion_server');
return s && s in SERVER_HOSTS ? s : 'europe'; return s && s in SERVER_HOSTS ? s : 'europe';
} }
export function setAlbionServer(server: string): void { export async function setAlbionServer(server: string): Promise<void> {
if (!(server in SERVER_HOSTS)) throw new Error(`unknown server: ${server}`); 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). */ /** Items worth pricing: everything that appears in a recipe (as output or input). */
function selectRecipeRelevantItems(minTier: number, maxTier: number): string[] { async function selectRecipeRelevantItems(minTier: number, maxTier: number): Promise<string[]> {
const rows = getDb() const rows = await query<{ name: string }>(
.prepare( `SELECT DISTINCT i.unique_name AS name
`SELECT DISTINCT i.unique_name AS name FROM items i
FROM items i WHERE i.tier BETWEEN ? AND ?
WHERE i.tier BETWEEN ? AND ? AND (
AND ( EXISTS (SELECT 1 FROM recipes r WHERE r.output_item = i.unique_name)
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)
OR EXISTS (SELECT 1 FROM recipe_inputs ri WHERE ri.input_item = i.unique_name) )`,
)`, [minTier, maxTier],
) );
.all(minTier, maxTier) as { name: string }[];
return rows.map((r) => r.name); 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<number> {
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 } { export function startRefresh(opts: RefreshOptions): { started: boolean; reason?: string } {
if (job.status === 'running') return { started: false, reason: 'a refresh is already running' }; 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 = { job = {
status: 'running', status: 'running',
totalChunks: Math.ceil(items.length / 100), totalChunks: 0,
doneChunks: 0, doneChunks: 0,
itemCount: items.length, itemCount: 0,
rowsUpserted: 0, rowsUpserted: 0,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
}; };
void (async () => { void (async () => {
try { 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({ const rows = await fetchPrices({
server: getAlbionServer(), server,
items, items,
cities, cities,
onProgress: (done, total) => { 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(); const now = new Date().toISOString();
let upserted = 0; const priceRows: unknown[][] = [];
db.transaction(() => { for (const r of rows) {
for (const r of rows) { const sellDate = normalizeAodpDate(r.sell_price_min_date);
const sellDate = normalizeAodpDate(r.sell_price_min_date); const buyDate = normalizeAodpDate(r.buy_price_max_date);
const buyDate = normalizeAodpDate(r.buy_price_max_date); if (!sellDate && !buyDate) continue; // never observed anywhere
if (!sellDate && !buyDate) continue; // never observed anywhere priceRows.push([
upsert.run( r.item_id,
r.item_id, r.city,
r.city, r.quality,
r.quality, r.sell_price_min > 0 ? r.sell_price_min : null,
r.sell_price_min > 0 ? r.sell_price_min : null, sellDate,
sellDate, r.buy_price_max > 0 ? r.buy_price_max : null,
r.buy_price_max > 0 ? r.buy_price_max : null, buyDate,
buyDate, now,
now, ]);
); }
upserted++; job.rowsUpserted = await upsertPriceRows(priceRows);
}
})();
job.rowsUpserted = upserted;
job.status = 'done'; job.status = 'done';
job.finishedAt = new Date().toISOString(); job.finishedAt = new Date().toISOString();
setMeta('market_last_refresh', job.finishedAt); await setMeta('market_last_refresh', job.finishedAt);
} catch (e) { } catch (e) {
job.status = 'error'; job.status = 'error';
job.finishedAt = new Date().toISOString(); job.finishedAt = new Date().toISOString();
@ -148,7 +175,7 @@ export interface MergedPrice {
buyPrice: number | null; buyPrice: number | null;
sellSource: 'api' | 'manual' | null; sellSource: 'api' | 'manual' | null;
buySource: 'api' | 'manual' | null; buySource: 'api' | 'manual' | null;
sellAgeHours: number | null; // null for manual overrides / missing sellAgeHours: number | null;
buyAgeHours: number | null; buyAgeHours: number | null;
note: string | null; note: string | null;
} }
@ -160,10 +187,9 @@ function ageHours(iso: string | null): number | null {
return Math.max(0, (Date.now() - t) / 3600000); return Math.max(0, (Date.now() - t) / 3600000);
} }
export function getPricesForItem(item: string): MergedPrice[] { export async function getPricesForItem(item: string): Promise<MergedPrice[]> {
const db = getDb(); const api = (await query('SELECT * FROM market_prices WHERE item = ?', [item])) as any[];
const api = db.prepare('SELECT * FROM market_prices WHERE item = ?').all(item) as any[]; const overrides = (await query('SELECT * FROM price_overrides WHERE item = ?', [item])) as any[];
const overrides = db.prepare('SELECT * FROM price_overrides WHERE item = ?').all(item) as any[];
const byKey = new Map<string, MergedPrice>(); const byKey = new Map<string, MergedPrice>();
for (const r of api) { 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); 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; item: string;
city: string; city: string;
quality?: number; quality?: number;
sellPrice?: number | null; sellPrice?: number | null;
buyPrice?: number | null; buyPrice?: number | null;
note?: string | null; note?: string | null;
}): void { }): Promise<void> {
getDb() await query(
.prepare( `INSERT INTO price_overrides (item, city, quality, sell_price, buy_price, note, updated_at)
`INSERT INTO price_overrides (item, city, quality, sell_price, buy_price, note, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (item, city, quality) DO UPDATE SET
ON CONFLICT(item, city, quality) DO UPDATE SET sell_price = excluded.sell_price,
sell_price = excluded.sell_price, buy_price = excluded.buy_price,
buy_price = excluded.buy_price, note = excluded.note,
note = excluded.note, updated_at = excluded.updated_at`,
updated_at = excluded.updated_at`, [o.item, o.city, o.quality ?? 1, o.sellPrice ?? null, o.buyPrice ?? null, o.note ?? null, new Date().toISOString()],
) );
.run(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 { export async function deleteOverride(item: string, city: string, quality = 1): Promise<void> {
getDb().prepare('DELETE FROM price_overrides WHERE item = ? AND city = ? AND quality = ?').run(item, city, quality); await query('DELETE FROM price_overrides WHERE item = ? AND city = ? AND quality = ?', [item, city, quality]);
} }
export function marketSummary() { export async function marketSummary() {
const db = getDb(); const n = async (sql: string) => (await get<{ n: number }>(sql))!.n;
const n = (sql: string) => (db.prepare(sql).get() as { n: number }).n;
return { return {
server: getAlbionServer(), server: await getAlbionServer(),
lastRefresh: getMeta('market_last_refresh'), lastRefresh: await getMeta('market_last_refresh'),
pricedRows: n('SELECT COUNT(*) AS n FROM market_prices'), pricedRows: await n('SELECT COUNT(*) AS n FROM market_prices'),
pricedItems: n('SELECT COUNT(DISTINCT item) AS n FROM market_prices'), pricedItems: await n('SELECT COUNT(DISTINCT item) AS n FROM market_prices'),
overrides: n('SELECT COUNT(*) AS n FROM price_overrides'), overrides: await n('SELECT COUNT(*) AS n FROM price_overrides'),
job: getRefreshJob(), job: getRefreshJob(),
}; };
} }

View file

@ -6,15 +6,15 @@ export async function economyRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/economy/config', async () => getEconomyConfig()); app.get('/api/economy/config', async () => getEconomyConfig());
app.put('/api/economy/config', async (req) => { app.put('/api/economy/config', async (req) => {
setEconomyConfig((req.body ?? {}) as Partial<EconomyConfig>); await setEconomyConfig((req.body ?? {}) as Partial<EconomyConfig>);
return { ok: true, config: getEconomyConfig() }; return { ok: true, config: await getEconomyConfig() };
}); });
app.post('/api/economy/opportunities', async (req, reply) => { app.post('/api/economy/opportunities', async (req, reply) => {
const body = (req.body ?? {}) as RankRequest; const body = (req.body ?? {}) as RankRequest;
if (!body.city) return reply.code(400).send({ error: 'city is required' }); if (!body.city) return reply.code(400).send({ error: 'city is required' });
const started = Date.now(); const started = Date.now();
const result = rankOpportunities(body); const result = await rankOpportunities(body);
return { ...result, tookMs: Date.now() - started }; return { ...result, tookMs: Date.now() - started };
}); });
} }

View file

@ -1,24 +1,23 @@
import type { FastifyInstance } from 'fastify'; 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 { downloadGameData } from '../gamedata/download.js';
import { parseGameData } from '../gamedata/parse.js'; import { parseGameData } from '../gamedata/parse.js';
export async function gamedataRoutes(app: FastifyInstance): Promise<void> { export async function gamedataRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/gamedata/status', async () => { app.get('/api/gamedata/status', async () => {
const db = getDb(); const count = async (table: string) =>
const count = (table: string) => (await get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`))!.n;
(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
return { return {
parsedAt: getMeta('gamedata_parsed_at'), parsedAt: await getMeta('gamedata_parsed_at'),
items: count('items'), items: await count('items'),
recipes: count('recipes'), recipes: await count('recipes'),
}; };
}); });
app.post('/api/gamedata/refresh', async (req) => { app.post('/api/gamedata/refresh', async (req) => {
const force = (req.query as { force?: string }).force === 'true'; const force = (req.query as { force?: string }).force === 'true';
const downloads = await downloadGameData(force); const downloads = await downloadGameData(force);
const stats = parseGameData(); const stats = await parseGameData();
return { downloads, stats }; return { downloads, stats };
}); });
} }

View file

@ -1,52 +1,46 @@
import type { FastifyInstance } from 'fastify'; 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<void> { export async function itemRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/items/search', async (req) => { app.get('/api/items/search', async (req) => {
const { q = '' } = req.query as { q?: string }; const { q = '' } = req.query as { q?: string };
const query = q.trim(); const queryStr = q.trim();
if (!query) return { results: [] }; if (!queryStr) return { results: [] };
const results = getDb() const results = await query(
.prepare( `SELECT unique_name, name_en, tier, enchantment, shop_category, shop_subcategory
`SELECT unique_name, name_en, tier, enchantment, shop_category, shop_subcategory FROM items
FROM items WHERE name_en ILIKE ? OR unique_name ILIKE ?
WHERE name_en LIKE ? OR unique_name LIKE ? ORDER BY tier, enchantment, unique_name
ORDER BY tier, enchantment, unique_name LIMIT 50`,
LIMIT 50`, [`%${queryStr}%`, `%${queryStr}%`],
) );
.all(`%${query}%`, `%${query.toUpperCase()}%`);
return { results }; return { results };
}); });
app.get('/api/items/:uniqueName', async (req, reply) => { app.get('/api/items/:uniqueName', async (req, reply) => {
const { uniqueName } = req.params as { uniqueName: string }; const { uniqueName } = req.params as { uniqueName: string };
const db = getDb(); const item = await get('SELECT * FROM items WHERE unique_name = ?', [uniqueName]);
const item = db.prepare('SELECT * FROM items WHERE unique_name = ?').get(uniqueName);
if (!item) return reply.code(404).send({ error: 'item not found' }); if (!item) return reply.code(404).send({ error: 'item not found' });
const recipes = db const recipes = (await query('SELECT * FROM recipes WHERE output_item = ? ORDER BY source, variant', [uniqueName])) as any[];
.prepare('SELECT * FROM recipes WHERE output_item = ? ORDER BY source, variant')
.all(uniqueName) as any[];
for (const r of recipes) { for (const r of recipes) {
r.inputs = db r.inputs = await query(
.prepare( `SELECT ri.input_item, ri.count, ri.max_return_amount, i.name_en
`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
FROM recipe_inputs ri LEFT JOIN items i ON i.unique_name = ri.input_item WHERE ri.recipe_id = ?`,
WHERE ri.recipe_id = ?`, [r.id],
) );
.all(r.id);
} }
const usedIn = db const usedIn = await query(
.prepare( `SELECT DISTINCT r.output_item, i.name_en
`SELECT DISTINCT r.output_item, i.name_en FROM recipe_inputs ri
FROM recipe_inputs ri JOIN recipes r ON r.id = ri.recipe_id
JOIN recipes r ON r.id = ri.recipe_id LEFT JOIN items i ON i.unique_name = r.output_item
LEFT JOIN items i ON i.unique_name = r.output_item WHERE ri.input_item = ?
WHERE ri.input_item = ? LIMIT 100`,
LIMIT 100`, [uniqueName],
) );
.all(uniqueName);
return { item, recipes, usedIn }; return { item, recipes, usedIn };
}); });

View file

@ -1,8 +1,9 @@
import type { FastifyInstance } from 'fastify'; 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 { CITIES, SERVER_HOSTS } from '../market/client.js';
import { import {
deleteOverride, deleteOverride,
getAlbionServer,
getPricesForItem, getPricesForItem,
getRefreshJob, getRefreshJob,
marketSummary, marketSummary,
@ -32,7 +33,7 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/market/prices/:item', async (req) => { app.get('/api/market/prices/:item', async (req) => {
const { item } = req.params as { item: string }; const { item } = req.params as { item: string };
return { prices: getPricesForItem(item) }; return { prices: await getPricesForItem(item) };
}); });
app.put('/api/market/override', async (req, reply) => { app.put('/api/market/override', async (req, reply) => {
@ -45,7 +46,7 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
note?: string | null; note?: string | null;
}; };
if (!body?.item || !body?.city) return reply.code(400).send({ error: 'item and city are required' }); if (!body?.item || !body?.city) return reply.code(400).send({ error: 'item and city are required' });
setOverride({ await setOverride({
item: body.item, item: body.item,
city: body.city, city: body.city,
quality: body.quality, quality: body.quality,
@ -59,15 +60,15 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
app.delete('/api/market/override', async (req, reply) => { app.delete('/api/market/override', async (req, reply) => {
const { item, city, quality } = req.query as { item?: string; city?: string; quality?: string }; 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' }); 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 }; return { ok: true };
}); });
app.get('/api/settings', async () => ({ app.get('/api/settings', async () => ({
albionServer: marketSummary().server, albionServer: await getAlbionServer(),
availableServers: Object.keys(SERVER_HOSTS), availableServers: Object.keys(SERVER_HOSTS),
proxyUrl: getMeta('proxy_url') ?? '', proxyUrl: (await getMeta('proxy_url')) ?? '',
gamePath: getMeta('game_path') ?? '', gamePath: (await getMeta('game_path')) ?? '',
})); }));
app.put('/api/settings', async (req, reply) => { app.put('/api/settings', async (req, reply) => {
@ -78,24 +79,18 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
}; };
if (albionServer) { if (albionServer) {
try { try {
setAlbionServer(albionServer); await setAlbionServer(albionServer);
} catch (e) { } catch (e) {
return reply.code(400).send({ error: String(e) }); return reply.code(400).send({ error: String(e) });
} }
} }
if (proxyUrl !== undefined) { if (proxyUrl !== undefined) {
if (proxyUrl === '') { if (proxyUrl === '') await query("DELETE FROM meta WHERE key = 'proxy_url'");
getDb().prepare("DELETE FROM meta WHERE key = 'proxy_url'").run(); else await setMeta('proxy_url', proxyUrl);
} else {
setMeta('proxy_url', proxyUrl);
}
} }
if (gamePath !== undefined) { if (gamePath !== undefined) {
if (gamePath === '') { if (gamePath === '') await query("DELETE FROM meta WHERE key = 'game_path'");
getDb().prepare("DELETE FROM meta WHERE key = 'game_path'").run(); else await setMeta('game_path', gamePath);
} else {
setMeta('game_path', gamePath);
}
} }
return { ok: true }; return { ok: true };
}); });

View file

@ -11,31 +11,31 @@ import {
export async function profileRoutes(app: FastifyInstance): Promise<void> { export async function profileRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/profiles', async () => ({ app.get('/api/profiles', async () => ({
profiles: listProfiles(), profiles: await listProfiles(),
categories: craftableCategories(), categories: await craftableCategories(),
})); }));
app.post('/api/profiles', async (req, reply) => { app.post('/api/profiles', async (req, reply) => {
const { name } = (req.body ?? {}) as { name?: string }; const { name } = (req.body ?? {}) as { name?: string };
if (!name?.trim()) return reply.code(400).send({ error: 'name is required' }); 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) => { app.put('/api/profiles/:id', async (req, reply) => {
const id = Number((req.params as { id: string }).id); const id = Number((req.params as { id: string }).id);
const body = (req.body ?? {}) as { name?: string; premium?: boolean; data?: ProfileData }; 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' }); if (!profile) return reply.code(404).send({ error: 'profile not found' });
return { profile }; return { profile };
}); });
app.post('/api/profiles/:id/activate', async (req) => { 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 }; return { ok: true };
}); });
app.delete('/api/profiles/:id', async (req) => { 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 }; return { ok: true };
}); });
} }

View file

@ -1,9 +1,19 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { AlbionOrder } from '@albion/shared';
import { listInterfaces, snifferStatus, startSniffer, stopSniffer } from '../sniffer/service.js'; import { listInterfaces, snifferStatus, startSniffer, stopSniffer } from '../sniffer/service.js';
import { storeOrders } from '../sniffer/store.js';
export async function snifferRoutes(app: FastifyInstance): Promise<void> { export async function snifferRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/sniffer/status', async () => snifferStatus()); 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.get('/api/sniffer/interfaces', async () => ({ interfaces: listInterfaces() }));
app.post('/api/sniffer/start', async (req, reply) => { app.post('/api/sniffer/start', async (req, reply) => {

View file

@ -1,24 +1,32 @@
import type { FastifyInstance } from 'fastify'; 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 { findRoute, haulAdvisor, listZones, travelStatus, type TravelMode } from '../travel/route.js';
import type { ParsedWorld } from '@albion/shared';
export async function travelRoutes(app: FastifyInstance): Promise<void> { export async function travelRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/travel/status', async () => travelStatus()); app.get('/api/travel/status', async () => travelStatus());
app.post('/api/travel/import', async (_req, reply) => { app.post('/api/travel/import', async (_req, reply) => {
try { try {
return importWorld(); return await importWorldLocal();
} catch (e) { } catch (e) {
return reply.code(400).send({ error: e instanceof Error ? e.message : String(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) => { app.post('/api/travel/route', async (req, reply) => {
const { from, to, mode } = (req.body ?? {}) as { from?: string; to?: string; mode?: TravelMode }; 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' }); 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); if ('error' in result) return reply.code(400).send(result);
return result; return result;
}); });
@ -32,6 +40,6 @@ export async function travelRoutes(app: FastifyInstance): Promise<void> {
maxTier?: number; maxTier?: number;
}; };
if (!from || !to) return reply.code(400).send({ error: 'from and to are required' }); 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) };
}); });
} }

View file

@ -14,7 +14,7 @@ import {
} from '../watcher/service.js'; } from '../watcher/service.js';
export async function watchRoutes(app: FastifyInstance): Promise<void> { export async function watchRoutes(app: FastifyInstance): Promise<void> {
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) => { app.post('/api/watch/rules', async (req, reply) => {
const body = (req.body ?? {}) as { const body = (req.body ?? {}) as {
@ -27,28 +27,28 @@ export async function watchRoutes(app: FastifyInstance): Promise<void> {
if (!body.item || !body.city || !body.condition || body.threshold == null) { if (!body.item || !body.city || !body.condition || body.threshold == null) {
return reply.code(400).send({ error: 'item, city, condition and threshold are required' }); 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 }; return { ok: true };
}); });
app.delete('/api/watch/rules/:id', async (req) => { 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 }; return { ok: true };
}); });
app.post('/api/watch/rules/:id/enabled', async (req) => { app.post('/api/watch/rules/:id/enabled', async (req) => {
const { enabled } = (req.body ?? {}) as { enabled?: boolean }; 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 }; return { ok: true };
}); });
app.get('/api/watch/alerts', async (req) => { app.get('/api/watch/alerts', async (req) => {
const unseenOnly = (req.query as { unseen?: string }).unseen === 'true'; 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 () => { app.post('/api/watch/alerts/seen', async () => {
markAlertsSeen(); await markAlertsSeen();
return { ok: true }; return { ok: true };
}); });
@ -57,8 +57,8 @@ export async function watchRoutes(app: FastifyInstance): Promise<void> {
app.put('/api/watch/interval', async (req, reply) => { app.put('/api/watch/interval', async (req, reply) => {
const { minutes } = (req.body ?? {}) as { minutes?: number }; const { minutes } = (req.body ?? {}) as { minutes?: number };
if (!minutes || minutes < 2) return reply.code(400).send({ error: 'minutes must be >= 2' }); if (!minutes || minutes < 2) return reply.code(400).send({ error: 'minutes must be >= 2' });
setMeta('watch_interval_min', String(minutes)); await setMeta('watch_interval_min', String(minutes));
startWatcherLoop(); await startWatcherLoop();
return { ok: true }; return { ok: true };
}); });
} }

View file

@ -1,50 +1,12 @@
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; import { findTshark, listInterfaces, startCapture, type CaptureHandle } from '@albion/shared';
import fs from 'node:fs'; import { storeOrders, type CapturedOrder } from './store.js';
import { getDb } from '../db/index.js';
import { PhotonPacketParser } from 'photon-packet-parser';
/** /**
* Passive sniffer for the user's OWN Albion client traffic (UDP 5056). * LOCAL sniffer: used only when the server runs on the same PC as the game.
* Capture is done by spawning tshark (Wireshark CLI, which bundles Npcap on * When the server is deployed, the Windows agent captures instead and pushes
* Windows) so no native Node modules are needed; payloads are decoded with the * results to /api/ingest/*. Capture + Photon decoding live in @albion/shared.
* 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.
*/ */
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<string, string> = {
'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 { export interface SnifferStatus {
tsharkPath: string | null; tsharkPath: string | null;
running: boolean; running: boolean;
@ -58,27 +20,7 @@ export interface SnifferStatus {
recentOrders: CapturedOrder[]; recentOrders: CapturedOrder[];
} }
export interface CapturedOrder { let handle: CaptureHandle | null = null;
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;
const state = { const state = {
packets: 0, packets: 0,
photonEvents: 0, photonEvents: 0,
@ -90,195 +32,66 @@ const state = {
recentOrders: [] as CapturedOrder[], recentOrders: [] as CapturedOrder[],
}; };
export function listInterfaces(): { id: string; name: string }[] { export { listInterfaces };
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<string, { sell: number | null; buy: number | null; item: string; city: string; quality: number }>();
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 function startSniffer(interfaceIds?: string[]): { started: boolean; error?: string } { export function startSniffer(interfaceIds?: string[]): { started: boolean; error?: string } {
if (proc) return { started: false, error: 'sniffer already running' }; if (handle) 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.',
};
}
const parser = new PhotonPacketParser(); Object.assign(state, {
parser.on('event', (d: any) => handlePhotonMessage(d, true)); packets: 0,
parser.on('response', (d: any) => handlePhotonMessage(d, false)); photonEvents: 0,
parser.on('request', () => undefined); marketOrdersSeen: 0,
parser.on('error', () => undefined); pricesStored: 0,
startedAt: new Date().toISOString(),
const ifaces = interfaceIds && interfaceIds.length > 0 ? interfaceIds : listInterfaces().map((i) => i.id); lastError: null,
const ifaceArgs = ifaces.flatMap((i) => ['-i', i]); recentOrders: [],
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;
state.eventCodeCounts.clear(); state.eventCodeCounts.clear();
state.recentOrders = [];
let pending = ''; const result = startCapture(
proc.stdout!.on('data', (chunk: Buffer) => { {
pending += chunk.toString('ascii'); onPacket: () => {
const lines = pending.split('\n'); state.packets++;
pending = lines.pop() ?? ''; },
for (const line of lines) { onEventCode: (code) => {
const hex = line.trim().replace(/:/g, ''); state.photonEvents++;
if (!hex || hex.length < 24) continue; state.eventCodeCounts.set(code, (state.eventCodeCounts.get(code) ?? 0) + 1);
state.packets++; },
try { onOrders: (orders) => {
parser.handle(Buffer.from(hex, 'hex')); state.marketOrdersSeen += orders.length;
} catch { void storeOrders(orders)
/* malformed packet — ignore */ .then(({ pricesStored, recent }) => {
} state.pricesStored += pricesStored;
} state.recentOrders = [...recent, ...state.recentOrders].slice(0, 50);
}); })
proc.stderr!.on('data', (chunk: Buffer) => { .catch((e) => {
const msg = chunk.toString('utf8').trim(); state.lastError = `storing sniffed orders failed: ${e instanceof Error ? e.message : e}`;
// 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); },
}); onError: (message) => {
proc.on('exit', (code) => { state.lastError = message;
if (code !== 0 && code != null) state.lastError = state.lastError ?? `tshark exited with code ${code}`; },
proc = null; },
}); interfaceIds,
);
if ('error' in result) {
state.startedAt = null;
return { started: false, error: result.error };
}
handle = result;
return { started: true }; return { started: true };
} }
export function stopSniffer(): void { export function stopSniffer(): void {
if (proc) { handle?.stop();
proc.kill(); handle = null;
proc = null;
}
} }
export function snifferStatus(): SnifferStatus { export function snifferStatus(): SnifferStatus {
return { return {
tsharkPath: findTshark(), tsharkPath: findTshark(),
running: proc != null, running: handle != null,
packets: state.packets, packets: state.packets,
photonEvents: state.photonEvents, photonEvents: state.photonEvents,
marketOrdersSeen: state.marketOrdersSeen, marketOrdersSeen: state.marketOrdersSeen,

View file

@ -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<StoredOrderStats> {
const best = new Map<string, { sell: number | null; buy: number | null; item: string; city: string; quality: number }>();
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) };
}

46
server/src/static.ts Normal file
View file

@ -0,0 +1,46 @@
import fs from 'node:fs';
import path from 'node:path';
import type { FastifyInstance } from 'fastify';
const MIME: Record<string, string> = {
'.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
});
}

View file

@ -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 fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import zlib from 'node:zlib'; import { decryptBin, parseWorldXml, WORLD_BIN_SUBPATH, type ParsedWorld } from '@albion/shared';
import { XMLParser } from 'fast-xml-parser'; import { getMeta, setMeta, transaction } from '../db/index.js';
import { getDb, getMeta, setMeta } from '../db/index.js';
/** /**
* The world map (zones + portals) comes from the game's own files: * World map (zones + portal connections) storage. Two entry points:
* <game folder>\game\Albion-Online_Data\StreamingAssets\GameData\cluster\world.bin * - importWorldLocal(): read the game's world.bin from THIS machine's disk
* .bin files are DES-encrypted, gzip-compressed XML. The key/IV are public * (used when the server runs on the gaming PC).
* knowledge (ao-id-extractor, Portaler, Statistics Analysis Tool all use them); * - storeWorld(): persist a ParsedWorld sent by the Windows agent (used when
* we only read the file, never modify the game. * 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 = [ const COMMON_GAME_DIRS = [
'E:\\albion', 'E:\\albion',
@ -35,147 +19,61 @@ const COMMON_GAME_DIRS = [
'C:\\Program Files (x86)\\Steam\\steamapps\\common\\Albion Online', 'C:\\Program Files (x86)\\Steam\\steamapps\\common\\Albion Online',
]; ];
export function findWorldBin(): string | null { export async function findWorldBin(): Promise<string | null> {
const configured = getMeta('game_path'); const configured = await getMeta('game_path');
const candidates = configured ? [configured, ...COMMON_GAME_DIRS] : COMMON_GAME_DIRS; const candidates = configured ? [configured, ...COMMON_GAME_DIRS] : COMMON_GAME_DIRS;
for (const dir of candidates) { for (const dir of candidates) {
const direct = path.join(dir, WORLD_BIN_SUBPATH); const direct = path.join(dir, WORLD_BIN_SUBPATH);
if (fs.existsSync(direct)) return direct; if (fs.existsSync(direct)) return direct;
// allow pointing straight at .../GameData or the cluster dir or the bin itself if (dir.endsWith('world.bin') && fs.existsSync(dir)) return dir;
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;
}
} }
return null; 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<T>(v: T | T[] | undefined): T[] {
if (v === undefined || v === null) return [];
return Array.isArray(v) ? v : [v];
}
export interface WorldImportStats { export interface WorldImportStats {
zones: number; zones: number;
connections: number; connections: number;
worldBinPath: string; source: string;
} }
export function importWorld(): WorldImportStats { export async function storeWorld(world: ParsedWorld, source: string): Promise<WorldImportStats> {
const binPath = findWorldBin(); 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<string>();
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<WorldImportStats> {
const binPath = await findWorldBin();
if (!binPath) { if (!binPath) {
throw new Error( throw new Error(
'world.bin not found. Set your Albion Online game folder in Settings ' + 'world.bin not found on this machine. Set your Albion Online game folder in Settings, ' +
'(the folder that contains the "game" directory).', 'or push it from the Windows agent if the game runs on another PC.',
); );
} }
const xml = decryptBin(fs.readFileSync(binPath)).toString('utf8'); const xml = decryptBin(fs.readFileSync(binPath)).toString('utf8');
const parser = new XMLParser({ const world = parseWorldXml(xml);
ignoreAttributes: false, return storeWorld(world, `local:${binPath}`);
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<string, string>();
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<string>();
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 };
} }

View file

@ -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 { getActiveProfile } from '../character/service.js';
import { getEconomyConfig } from '../economy/config.js'; import { getEconomyConfig } from '../economy/config.js';
import { buildProvider } from '../economy/service.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. */ /** Roads of Avalon connections are dynamic; never route through them. */
const ROUTABLE_COLORS = new Set(['city', 'blue', 'yellow', 'red', 'black', 'other']); const ROUTABLE_COLORS = new Set(['city', 'blue', 'yellow', 'red', 'black', 'other']);
function loadGraph(): { zones: Map<string, Zone>; adj: Map<string, string[]> } { async function loadGraph(): Promise<{ zones: Map<string, Zone>; adj: Map<string, string[]> }> {
const db = getDb();
const zones = new Map<string, Zone>(); const zones = new Map<string, Zone>();
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 }); zones.set(r.albion_id, { albionId: r.albion_id, name: r.name, type: r.type, color: r.color });
} }
const adj = new Map<string, string[]>(); const adj = new Map<string, string[]>();
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_a)) adj.set(r.zone_a, []);
if (!adj.has(r.zone_b)) adj.set(r.zone_b, []); if (!adj.has(r.zone_b)) adj.set(r.zone_b, []);
adj.get(r.zone_a)!.push(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 dangerZones: number; // red + black on the path
} }
export function findRoute(fromName: string, toName: string, mode: TravelMode): RouteResult | { error: string } { export async function findRoute(fromName: string, toName: string, mode: TravelMode): Promise<RouteResult | { error: string }> {
const { zones, adj } = loadGraph(); const { zones, adj } = await loadGraph();
if (zones.size === 0) return { error: 'world map not imported — run the import on the Travel page' }; if (zones.size === 0) return { error: 'world map not imported — run the import on the Travel page' };
const byName = new Map<string, string>(); const byName = new Map<string, string>();
@ -86,8 +85,8 @@ export function findRoute(fromName: string, toName: string, mode: TravelMode): R
return { found: true, path, zonesTraversed: path.length - 1, dangerZones }; return { found: true, path, zonesTraversed: path.length - 1, dangerZones };
} }
export function listZones(): Zone[] { export async function listZones(): Promise<Zone[]> {
return (getDb().prepare('SELECT * FROM zones ORDER BY name').all() as any[]).map((r) => ({ return ((await query('SELECT * FROM zones ORDER BY name')) as any[]).map((r) => ({
albionId: r.albion_id, albionId: r.albion_id,
name: r.name, name: r.name,
type: r.type, type: r.type,
@ -95,20 +94,15 @@ export function listZones(): Zone[] {
})); }));
} }
export function travelStatus() { export async function travelStatus() {
const db = getDb(); const zoneCount = (await get<{ n: number }>('SELECT COUNT(*) AS n FROM zones'))!.n;
const hasTables = db const connCount = zoneCount > 0 ? (await get<{ n: number }>('SELECT COUNT(*) AS n FROM zone_connections'))!.n : 0;
.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;
return { return {
zones: zoneCount, zones: zoneCount,
connections: connCount, connections: connCount,
importedAt: getMeta('world_imported_at'), importedAt: await getMeta('world_imported_at'),
worldBinPath: getMeta('world_bin_path'), worldSource: await getMeta('world_source'),
gamePath: getMeta('game_path'), gamePath: await getMeta('game_path'),
}; };
} }
@ -128,18 +122,19 @@ export interface HaulItem {
profitPerWeight: number; profitPerWeight: number;
} }
export function haulAdvisor(from: string, to: string, maxWeight: number, minTier = 2, maxTier = 8): HaulItem[] { export async function haulAdvisor(from: string, to: string, maxWeight: number, minTier = 2, maxTier = 8): Promise<HaulItem[]> {
const cfg = getEconomyConfig(); const cfg = await getEconomyConfig();
const premium = getActiveProfile()?.premium ?? true; const premium = (await getActiveProfile())?.premium ?? true;
const tax = premium ? cfg.salesTaxPremium : cfg.salesTaxNoPremium; const tax = premium ? cfg.salesTaxPremium : cfg.salesTaxNoPremium;
const feeRate = tax + cfg.listingFee; const feeRate = tax + cfg.listingFee;
const origin = buildProvider(from); const origin = await buildProvider(from);
const dest = buildProvider(to); const dest = await buildProvider(to);
const items = getDb() const items = (await query(
.prepare('SELECT unique_name, name_en, tier, weight FROM items WHERE weight IS NOT NULL AND weight > 0 AND tier BETWEEN ? AND ?') '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 }[]; [minTier, maxTier],
)) as { unique_name: string; name_en: string | null; tier: number | null; weight: number }[];
const results: HaulItem[] = []; const results: HaulItem[] = [];
for (const it of items) { for (const it of items) {

View file

@ -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 { 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'; export type WatchCondition = 'sell_below' | 'sell_above' | 'buy_above';
@ -22,13 +22,11 @@ const CONDITION_LABELS: Record<WatchCondition, (rule: WatchRule, price: number)
buy_above: (r, p) => `buy orders at ${Math.round(p).toLocaleString()} (above ${r.threshold.toLocaleString()}) — instant-sell opportunity`, buy_above: (r, p) => `buy orders at ${Math.round(p).toLocaleString()} (above ${r.threshold.toLocaleString()}) — instant-sell opportunity`,
}; };
export function listRules(): WatchRule[] { export async function listRules(): Promise<WatchRule[]> {
return ( return (
getDb() (await query(
.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`,
`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[]
)
.all() as any[]
).map((r) => ({ ).map((r) => ({
id: r.id, id: r.id,
item: r.item, 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 { export async function createRule(rule: {
getDb() item: string;
.prepare('INSERT INTO watch_rules (item, city, quality, condition, threshold, enabled, created_at) VALUES (?, ?, ?, ?, ?, 1, ?)') city: string;
.run(rule.item, rule.city, rule.quality ?? 1, rule.condition, rule.threshold, new Date().toISOString()); quality?: number;
condition: WatchCondition;
threshold: number;
}): Promise<void> {
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 { export async function deleteRule(id: number): Promise<void> {
getDb().prepare('DELETE FROM watch_rules WHERE id = ?').run(id); await query('DELETE FROM watch_rules WHERE id = ?', [id]);
} }
export function setRuleEnabled(id: number, enabled: boolean): void { export async function setRuleEnabled(id: number, enabled: boolean): Promise<void> {
getDb().prepare('UPDATE watch_rules SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, id); await query('UPDATE watch_rules SET enabled = ? WHERE id = ?', [enabled ? 1 : 0, id]);
} }
export function listAlerts(unseenOnly = false): any[] { export async function listAlerts(unseenOnly = false): Promise<any[]> {
return getDb() return query(
.prepare( `SELECT a.*, w.item, w.city, i.name_en
`SELECT a.*, w.item, w.city, i.name_en FROM watch_alerts a
FROM watch_alerts a JOIN watch_rules w ON w.id = a.rule_id
JOIN watch_rules w ON w.id = a.rule_id LEFT JOIN items i ON i.unique_name = w.item
LEFT JOIN items i ON i.unique_name = w.item ${unseenOnly ? 'WHERE a.seen = 0' : ''}
${unseenOnly ? 'WHERE a.seen = 0' : ''} ORDER BY a.id DESC LIMIT 100`,
ORDER BY a.id DESC LIMIT 100`, );
)
.all();
} }
export function markAlertsSeen(): void { export async function markAlertsSeen(): Promise<void> {
getDb().prepare('UPDATE watch_alerts SET seen = 1 WHERE seen = 0').run(); await query('UPDATE watch_alerts SET seen = 1 WHERE seen = 0');
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -84,19 +91,15 @@ export interface PollResult {
let lastPoll: PollResult | null = null; let lastPoll: PollResult | null = null;
let timer: NodeJS.Timeout | null = null; let timer: NodeJS.Timeout | null = null;
let intervalMinutes = 15;
export function getWatcherStatus() { export function getWatcherStatus() {
return { return { lastPoll, intervalMinutes, running: timer != null };
lastPoll,
intervalMinutes: Number(getMeta('watch_interval_min') ?? 15),
running: timer != null,
};
} }
/** Fetch fresh prices for all watched item/city pairs and evaluate the rules. */ /** Fetch fresh prices for all watched item/city pairs and evaluate the rules. */
export async function pollWatches(): Promise<PollResult> { export async function pollWatches(): Promise<PollResult> {
const db = getDb(); const rules = (await listRules()).filter((r) => r.enabled);
const rules = listRules().filter((r) => r.enabled);
const result: PollResult = { checkedRules: rules.length, newAlerts: 0, at: new Date().toISOString() }; const result: PollResult = { checkedRules: rules.length, newAlerts: 0, at: new Date().toISOString() };
if (rules.length === 0) { if (rules.length === 0) {
lastPoll = result; lastPoll = result;
@ -106,39 +109,35 @@ export async function pollWatches(): Promise<PollResult> {
try { try {
const items = [...new Set(rules.map((r) => r.item))]; const items = [...new Set(rules.map((r) => r.item))];
const cities = [...new Set(rules.map((r) => r.city))]; 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(); const now = new Date().toISOString();
db.transaction(() => { const priceRows: unknown[][] = [];
for (const r of rows) { for (const r of rows) {
const sellDate = normalizeAodpDate(r.sell_price_min_date); const sellDate = normalizeAodpDate(r.sell_price_min_date);
const buyDate = normalizeAodpDate(r.buy_price_max_date); const buyDate = normalizeAodpDate(r.buy_price_max_date);
if (!sellDate && !buyDate) continue; 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); 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) { } catch (e) {
result.error = e instanceof Error ? e.message : String(e); result.error = e instanceof Error ? e.message : String(e);
lastPoll = result; lastPoll = result;
setMeta('watch_last_error', result.error); await setMeta('watch_last_error', result.error);
return result; 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) { 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; if (prices.length === 0) continue;
const p = prices[0]; const p = prices[0];
let triggered = false; let triggered = false;
@ -155,9 +154,14 @@ export async function pollWatches(): Promise<PollResult> {
} }
if (!triggered) continue; if (!triggered) continue;
// don't stack duplicate alerts while one is still unread // 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; 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++; result.newAlerts++;
} }
@ -165,11 +169,11 @@ export async function pollWatches(): Promise<PollResult> {
return result; return result;
} }
export function startWatcherLoop(): void { export async function startWatcherLoop(): Promise<void> {
if (timer) clearInterval(timer); 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(() => { timer = setInterval(() => {
void pollWatches().catch(() => undefined); void pollWatches().catch(() => undefined);
}, minutes * 60_000); }, intervalMinutes * 60_000);
timer.unref?.(); timer.unref?.();
} }

23
shared/package.json Normal file
View file

@ -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"
}
}

157
shared/src/capture.ts Normal file
View file

@ -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<string, string> = {
'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() };
}

19
shared/src/index.ts Normal file
View file

@ -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';

140
shared/src/worldParse.ts Normal file
View file

@ -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 folder>\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<T>(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<string, string>();
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<string>();
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]),
};
}

15
shared/tsconfig.json Normal file
View file

@ -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"]
}

View file

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import ItemsPage from './pages/ItemsPage'; import ItemsPage from './pages/ItemsPage';
import GameDataPage from './pages/GameDataPage'; import GameDataPage from './pages/GameDataPage';
import MarketPage from './pages/MarketPage'; import MarketPage from './pages/MarketPage';
@ -8,6 +8,7 @@ import CharacterPage from './pages/CharacterPage';
import TravelPage from './pages/TravelPage'; import TravelPage from './pages/TravelPage';
import WatchPage from './pages/WatchPage'; import WatchPage from './pages/WatchPage';
import SnifferPage from './pages/SnifferPage'; import SnifferPage from './pages/SnifferPage';
import { getApiKey, onUnauthorized, setApiKey } from './apiKey';
const PAGES = [ const PAGES = [
{ id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage }, { id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage },
@ -21,9 +22,37 @@ const PAGES = [
{ id: 'settings', label: 'Settings', component: SettingsPage }, { id: 'settings', label: 'Settings', component: SettingsPage },
] as const; ] as const;
function ApiKeyGate({ onSaved }: { onSaved: () => void }) {
const [key, setKey] = useState(getApiKey());
return (
<div style={{ maxWidth: 460, margin: '15vh auto', padding: 24 }} className="card">
<h2 style={{ marginTop: 0 }}>API key required</h2>
<p className="muted">
This server is protected. Enter the API key (the <code>ALBION_API_KEY</code> configured on the server) to
continue. It's stored only in this browser.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<input type="password" value={key} placeholder="API key" style={{ flex: 1 }} onChange={(e) => setKey(e.target.value)} />
<button className="primary" onClick={() => { setApiKey(key.trim()); onSaved(); }}>Save</button>
</div>
</div>
);
}
export default function App() { export default function App() {
const [page, setPage] = useState<string>('items'); const [page, setPage] = useState<string>('opportunities');
const Active = PAGES.find((p) => p.id === page)?.component ?? ItemsPage; 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 <ApiKeyGate onSaved={() => { setLocked(false); location.reload(); }} />;
return ( return (
<div className="app"> <div className="app">

41
web/src/apiKey.ts Normal file
View file

@ -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 <key>` 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;
};
}

View file

@ -1,8 +1,11 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import { installFetchAuth } from './apiKey';
import './styles.css'; import './styles.css';
installFetchAuth();
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />