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
A local web app that tells you what to craft, refine, enchant, haul, and watch in Albion Online — using live market prices, the game's own recipe data, and **your** character's destiny board progress.
Tells you what to craft, refine, enchant, haul, and watch in Albion Online — using live market prices, the game's own recipe data, and **your** character's destiny board progress.
## Run it
## Architecture
Three deployable pieces plus a shared library:
- **server/** — Fastify + TypeScript API that also serves the web UI. Stores everything in a **shared Postgres**. Deployable (Docker) or run locally.
- **web/** — Vite + React UI (built and served by the server in production).
- **agent/** — a small **Windows companion** that runs on your gaming PC. It reads game-local data a deployed server can't see (world map, live market orders) and pushes it to the server over an authenticated API. See `agent/README.md`.
- **shared/** — world-map parsing + packet capture, reused by server and agent.
```
[ Your gaming PC ] [ Server (VPS / Forgejo deploy) ]
Albion Online albion-economy container
agent ── HTTPS + API key ──► /api/ingest/* ├─ serves web UI
(world map, market orders) └─ Postgres (shared, `backend` net)
Nginx Proxy Manager (`proxy` net) → subdomain
You ── browser ──────────────► the same server (web UI, API key in browser)
```
## Run locally (single machine)
Needs a Postgres to point at. Quick throwaway one:
```
docker run -d --name albion-pg -e POSTGRES_PASSWORD=pw -e POSTGRES_DB=albion -p 5432:5432 postgres:16-alpine
export DATABASE_URL=postgres://postgres:pw@127.0.0.1:5432/albion # PowerShell: $env:DATABASE_URL=...
npm install
npm run gamedata # first time only: download items/recipes (~40 MB)
npm run dev # starts API (port 4310) + UI
npm run gamedata # download items/recipes into Postgres (first time)
npm run dev # API on :4310, Vite UI on :5173
```
Open **http://localhost:5173**.
Open **http://localhost:5173**. With no `ALBION_API_KEY` set, the API is open (fine for localhost).
## First-time setup
## Deploy (Docker + Forgejo)
1. **Settings page** — pick your Albion server (Europe is default). If `albion-online-data.com` is blocked in your country, turn on your VPN before downloading prices (or set a proxy URL if your VPN is browser-only). Set your game folder (e.g. `E:\albion`) for the travel planner.
2. **Market Data page** — click *Download prices* for your tier range. Prices show age badges (green = fresh); you can manually override any price, and manual prices always win over API data.
3. **Character page** — create a profile, set premium, and enter your destiny board levels per crafting category (mastery / spec / other specs). Every calculation uses the active profile.
4. **Travel Planner page** — click *Import world map* (reads zone connections from your game's files, read-only).
The repo ships a `Dockerfile`, `docker-compose.yml`, and `.forgejo/workflows/deploy.yml`.
1. On the host, have your shared Postgres reachable on an external Docker network named **`backend`**, and Nginx Proxy Manager on **`proxy`**. Create the database: `CREATE DATABASE albion;`
2. Add Forgejo Actions **secrets**: `DATABASE_URL` (e.g. `postgres://albion:pw@postgres:5432/albion`) and `ALBION_API_KEY` (a long random string).
3. Push to `master` (or run the workflow manually). It builds the image and `docker compose up -d`. The container joins `proxy` + `backend`; point an NPM proxy host at `albion-economy:4310` and assign your subdomain.
4. First boot auto-downloads and parses game data into Postgres.
Locally you can mirror it: create `.env` from `.env.example`, ensure the `proxy`/`backend` networks exist, then `docker compose up -d`.
### Security
- **`ALBION_API_KEY` is required in the compose file.** Every `/api/*` route (except `/api/health`) needs `Authorization: Bearer <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
- **Opportunities** — ranks every craft/refine/enchant by profit for a chosen city: return rates (city bonuses + focus), station fees from item values, taxes, and recursive buy-vs-craft decisions for inputs. Sort by profit, ROI, or silver-per-focus.
- **Travel Planner** — routes between any two zones, *safe zones only* or *all zones* (shows red/black zone count), plus a haul advisor that ranks what's worth carrying between two cities for your carry weight.
- **Market Watch** — price alert rules (drops below / rises above / buy orders above) checked on an interval.
- **Game Sniffer** — optional: passively reads your own game traffic (requires [Wireshark](https://www.wireshark.org) with Npcap). While capturing, every marketplace you open in game feeds firsthand prices into the app. Passive read-only capture, the same SBI-tolerated technique as the Albion Data Project client.
- **Item Browser / Game Data** — recipe explorer; re-download game data after patches.
- **Opportunities** — ranks every craft/refine/enchant for a city by profit: return rates (city bonuses + focus), station fees, taxes, and recursive buy-vs-craft input decisions. Sort by profit, ROI, or silver-per-focus. Uses your active character profile.
- **Travel Planner** — safe-only vs all-zones routing (with red/black danger counts) over the real zone graph, plus a haul advisor ranking what to carry between cities by profit per weight.
- **Market Watch** — price alert rules checked on an interval.
- **Character** — destiny board profiles (mastery/spec per category) that drive focus costs and taxes.
- **Game Sniffer** — status of firsthand price capture (when the server runs on your PC; otherwise use the agent).
- **Item Browser / Game Data / Settings** — recipe explorer, patch-day refresh, server/proxy/game-path config.
## Character & game-local data
There is no official API for character progress, so the **Character** page lets you enter destiny board levels by hand. The **agent** can also push world-map and market data from your PC. Capturing your own traffic is the SBI-tolerated technique (Albion Data Project style) — read-only, no memory reading, no injection.
## After a game patch
- *Game Data* page → re-download game data.
- *Travel Planner* → re-import the world map.
- If balance values changed (return rates, taxes, city bonuses), edit them via `PUT /api/economy/config` — all game constants are config, not code.
## Data sources
- Recipes/items/zones: [ao-data/ao-bin-dumps](https://github.com/ao-data/ao-bin-dumps) + your local game files (read-only)
- Market prices: [Albion Online Data Project](https://www.albion-online-data.com) + optional local sniffer + manual overrides
- Character data: entered manually (no official API exists); sniffer-assisted auto-fill planned
- *Game Data* page → re-download game data (or the agent/`npm run gamedata`).
- *Travel Planner* → re-import the world map (or `npm run agent -- world`).
- Balance constants (return rates, taxes, city bonuses) are editable via `PUT /api/economy/config` — config, not code.
## Tests
```
npm test
npm test # economy math (vitest); the engine is pure, no DB needed
```

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",
"version": "0.1.0",
"workspaces": [
"shared",
"server",
"web"
"web",
"agent"
],
"devDependencies": {
"concurrently": "^9.1.2"
}
},
"agent": {
"name": "@albion/agent",
"version": "0.1.0",
"dependencies": {
"@albion/shared": "*"
},
"devDependencies": {
"@types/node": "^24.13.3",
"tsx": "^4.20.0",
"typescript": "^5.8.0"
}
},
"node_modules/@albion/agent": {
"resolved": "agent",
"link": true
},
"node_modules/@albion/server": {
"resolved": "server",
"link": true
},
"node_modules/@albion/shared": {
"resolved": "shared",
"link": true
},
"node_modules/@albion/web": {
"resolved": "web",
"link": true
@ -1345,16 +1367,6 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@ -1390,6 +1402,18 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/pg": {
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@ -1687,26 +1711,6 @@
"fastq": "^1.17.1"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz",
@ -1720,40 +1724,6 @@
"node": ">=6.0.0"
}
},
"node_modules/better-sqlite3": {
"version": "12.11.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/browserslist": {
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@ -1788,30 +1758,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffercursor": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/buffercursor/-/buffercursor-0.0.12.tgz",
@ -1911,12 +1857,6 @@
"node": ">= 16"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@ -2028,21 +1968,6 @@
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@ -2053,15 +1978,6 @@
"node": ">=6"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@ -2081,15 +1997,6 @@
"minimalistic-assert": "^1.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.412",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz",
@ -2104,15 +2011,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@ -2182,15 +2080,6 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@ -2382,12 +2271,6 @@
}
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/find-my-way": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz",
@ -2402,12 +2285,6 @@
"node": ">=20"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -2443,12 +2320,6 @@
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -2459,38 +2330,12 @@
"node": ">=8"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz",
@ -2644,39 +2489,12 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -2703,36 +2521,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.94.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-abi/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-releases": {
"version": "2.0.53",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
@ -2752,15 +2540,6 @@
"node": ">=14.0.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
@ -2793,6 +2572,95 @@
"node": ">= 14.16"
}
},
"node_modules/pg": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.16.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/photon-packet-parser": {
"version": "0.0.19",
"resolved": "https://registry.npmjs.org/photon-packet-parser/-/photon-packet-parser-0.0.19.tgz",
@ -2888,31 +2756,43 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
"xtend": "^4.0.0"
},
"engines": {
"node": ">=10"
"node": ">=0.10.0"
}
},
"node_modules/process-warning": {
@ -2931,37 +2811,12 @@
],
"license": "MIT"
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@ -2993,20 +2848,6 @@
"node": ">=0.10.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
@ -3116,26 +2957,6 @@
"tslib": "^2.1.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
@ -3225,51 +3046,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@ -3312,15 +3088,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -3349,15 +3116,6 @@
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
@ -3409,34 +3167,6 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
@ -3561,18 +3291,6 @@
"fsevents": "~2.3.3"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -3634,12 +3352,6 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/verror": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
@ -3860,12 +3572,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
@ -3881,6 +3587,15 @@
"node": ">=16.0.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@ -3931,21 +3646,32 @@
"name": "@albion/server",
"version": "0.1.0",
"dependencies": {
"better-sqlite3": "^12.2.0",
"des.js": "^1.1.0",
"fast-xml-parser": "^5.11.0",
"@albion/shared": "*",
"fastify": "^5.4.0",
"photon-packet-parser": "^0.0.19",
"pg": "^8.13.1",
"undici": "^8.10.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.13.3",
"@types/pg": "^8.11.10",
"tsx": "^4.20.0",
"typescript": "^5.8.0",
"vitest": "^3.2.0"
}
},
"shared": {
"name": "@albion/shared",
"version": "0.1.0",
"dependencies": {
"des.js": "^1.1.0",
"fast-xml-parser": "^5.11.0",
"photon-packet-parser": "^0.0.19"
},
"devDependencies": {
"@types/node": "^24.13.3",
"typescript": "^5.8.0"
}
},
"web": {
"name": "@albion/web",
"version": "0.1.0",

View file

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

View file

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

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

View file

@ -6,8 +6,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** Server root directory (server/) */
export const SERVER_ROOT = path.resolve(__dirname, '..');
/** Local data directory (git-ignored): SQLite db + downloaded game data */
export const DATA_DIR = path.join(SERVER_ROOT, 'data');
/** Local data directory (git-ignored): SQLite db + downloaded game data.
* Override with ALBION_DATA_DIR (e.g. a mounted Docker volume). */
export const DATA_DIR = process.env.ALBION_DATA_DIR ?? path.join(SERVER_ROOT, 'data');
export const GAMEDATA_DIR = path.join(DATA_DIR, 'gamedata');
export const DB_PATH = path.join(DATA_DIR, 'app.db');

View file

@ -1,7 +1,18 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { DB_PATH } from '../config.js';
import pg from 'pg';
/**
* Postgres data layer. Uses a shared instance (the deployment's `backend`
* network) via DATABASE_URL, e.g.
* postgres://user:pass@postgres:5432/albion
*
* Queries are written with `?` placeholders and a small helper rewrites them to
* Postgres `$1,$2,...` so the SQL stays readable. All access is async.
*/
// Postgres returns BIGINT/NUMERIC as strings by default; parse the ones we use
// (counts, prices, item_value) back to JS numbers.
pg.types.setTypeParser(20, (v) => (v == null ? null : Number(v))); // int8/bigint
pg.types.setTypeParser(1700, (v) => (v == null ? null : Number(v))); // numeric
const SCHEMA = `
CREATE TABLE IF NOT EXISTS meta (
@ -10,12 +21,12 @@ CREATE TABLE IF NOT EXISTS meta (
);
CREATE TABLE IF NOT EXISTS items (
unique_name TEXT PRIMARY KEY, -- e.g. T4_BAG@1
base_name TEXT NOT NULL, -- e.g. T4_BAG
unique_name TEXT PRIMARY KEY,
base_name TEXT NOT NULL,
enchantment INTEGER NOT NULL DEFAULT 0,
tier INTEGER,
weight REAL,
item_value INTEGER,
weight DOUBLE PRECISION,
item_value DOUBLE PRECISION,
shop_category TEXT,
shop_subcategory TEXT,
crafting_category TEXT,
@ -25,21 +36,21 @@ CREATE INDEX IF NOT EXISTS idx_items_base ON items(base_name);
CREATE INDEX IF NOT EXISTS idx_items_name_en ON items(name_en);
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id SERIAL PRIMARY KEY,
output_item TEXT NOT NULL,
variant INTEGER NOT NULL DEFAULT 0, -- alternative recipe index for the same output
amount_crafted REAL NOT NULL DEFAULT 1,
silver_cost REAL NOT NULL DEFAULT 0,
focus_cost REAL NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'crafting' -- 'crafting' | 'upgrade' (enchanting)
variant INTEGER NOT NULL DEFAULT 0,
amount_crafted DOUBLE PRECISION NOT NULL DEFAULT 1,
silver_cost DOUBLE PRECISION NOT NULL DEFAULT 0,
focus_cost DOUBLE PRECISION NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'crafting'
);
CREATE INDEX IF NOT EXISTS idx_recipes_output ON recipes(output_item);
CREATE TABLE IF NOT EXISTS recipe_inputs (
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
input_item TEXT NOT NULL,
count REAL NOT NULL,
max_return_amount REAL, -- 0 means the resource is never returned (e.g. artifacts)
count DOUBLE PRECISION NOT NULL,
max_return_amount DOUBLE PRECISION,
PRIMARY KEY (recipe_id, input_item)
);
CREATE INDEX IF NOT EXISTS idx_recipe_inputs_item ON recipe_inputs(input_item);
@ -48,31 +59,42 @@ CREATE TABLE IF NOT EXISTS market_prices (
item TEXT NOT NULL,
city TEXT NOT NULL,
quality INTEGER NOT NULL DEFAULT 1,
sell_price_min REAL,
sell_min_date TEXT, -- when AODP last observed this sell order (UTC ISO)
buy_price_max REAL,
sell_price_min DOUBLE PRECISION,
sell_min_date TEXT,
buy_price_max DOUBLE PRECISION,
buy_max_date TEXT,
fetched_at TEXT NOT NULL,
PRIMARY KEY (item, city, quality)
);
CREATE INDEX IF NOT EXISTS idx_market_prices_city ON market_prices(city);
CREATE TABLE IF NOT EXISTS watch_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
CREATE TABLE IF NOT EXISTS price_overrides (
item TEXT NOT NULL,
city TEXT NOT NULL,
quality INTEGER NOT NULL DEFAULT 1,
condition TEXT NOT NULL, -- 'sell_below' | 'sell_above' | 'buy_above'
threshold REAL NOT NULL,
sell_price DOUBLE PRECISION,
buy_price DOUBLE PRECISION,
note TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (item, city, quality)
);
CREATE TABLE IF NOT EXISTS watch_rules (
id SERIAL PRIMARY KEY,
item TEXT NOT NULL,
city TEXT NOT NULL,
quality INTEGER NOT NULL DEFAULT 1,
condition TEXT NOT NULL,
threshold DOUBLE PRECISION NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS watch_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id SERIAL PRIMARY KEY,
rule_id INTEGER NOT NULL REFERENCES watch_rules(id) ON DELETE CASCADE,
message TEXT NOT NULL,
price REAL,
price DOUBLE PRECISION,
triggered_at TEXT NOT NULL,
seen INTEGER NOT NULL DEFAULT 0
);
@ -91,48 +113,93 @@ CREATE TABLE IF NOT EXISTS zone_connections (
);
CREATE TABLE IF NOT EXISTS profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 0,
premium INTEGER NOT NULL DEFAULT 1,
data TEXT NOT NULL DEFAULT '{}', -- JSON: destiny board levels per crafting category
data TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS price_overrides (
item TEXT NOT NULL,
city TEXT NOT NULL,
quality INTEGER NOT NULL DEFAULT 1,
sell_price REAL,
buy_price REAL,
note TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (item, city, quality)
);
`;
let db: Database.Database | null = null;
let pool: pg.Pool | null = null;
export function getDb(): Database.Database {
if (!db) {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(SCHEMA);
export function getPool(): pg.Pool {
if (!pool) {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL is not set (e.g. postgres://user:pass@postgres:5432/albion)');
}
pool = new pg.Pool({ connectionString, max: 10 });
}
return db;
return pool;
}
export function getMeta(key: string): string | null {
const row = getDb().prepare('SELECT value FROM meta WHERE key = ?').get(key) as
| { value: string }
| undefined;
/** Rewrite `?` placeholders to Postgres `$1, $2, ...` (our SQL has no `?` in literals). */
function toPg(sql: string): string {
let i = 0;
return sql.replace(/\?/g, () => `$${++i}`);
}
export async function query<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;
}
export function setMeta(key: string, value: string): void {
getDb()
.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.run(key, value);
export async function setMeta(key: string, value: string): Promise<void> {
await query(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
[key, value],
);
}

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import fs from 'node:fs';
import { getDb, setMeta } from '../db/index.js';
import { setMeta, transaction, type Tx } from '../db/index.js';
import { gamedataFilePath } from './download.js';
/**
@ -147,7 +147,21 @@ function walk(node: unknown, items: ParsedItem[], recipes: ParsedRecipe[]): void
}
}
export function parseGameData(): ParseStats {
/** Insert many rows in parameter-bounded chunks (Postgres caps at 65535 params/statement). */
async function bulkInsert(tx: Tx, table: string, columns: string[], rows: unknown[][]): Promise<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 localized = JSON.parse(fs.readFileSync(gamedataFilePath('items_localized.json'), 'utf8')) as Raw[];
@ -162,64 +176,59 @@ export function parseGameData(): ParseStats {
if (typeof un === 'string' && typeof name === 'string') namesEn.set(un, name);
}
const db = getDb();
const insertItem = db.prepare(`
INSERT OR REPLACE INTO items
(unique_name, base_name, enchantment, tier, weight, item_value, shop_category, shop_subcategory, crafting_category, name_en)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertRecipe = db.prepare(`
INSERT INTO recipes (output_item, variant, amount_crafted, silver_cost, focus_cost, source)
VALUES (?, ?, ?, ?, ?, ?)
`);
const insertInput = db.prepare(`
INSERT OR REPLACE INTO recipe_inputs (recipe_id, input_item, count, max_return_amount)
VALUES (?, ?, ?, ?)
`);
// De-duplicate items by unique_name (last wins, matching the old INSERT OR REPLACE).
const itemByName = new Map<string, ParsedItem>();
for (const it of items) itemByName.set(it.uniqueName, it);
let inputCount = 0;
db.transaction(() => {
db.exec('DELETE FROM recipe_inputs; DELETE FROM recipes; DELETE FROM items;');
for (const it of items) {
// Enchanted names like T4_BAG@1 resolve their display name from the localized dump
// when present; fall back to the base item's name.
const nameEn = namesEn.get(it.uniqueName) ?? namesEn.get(it.baseName) ?? null;
insertItem.run(
it.uniqueName,
it.baseName,
it.enchantment,
it.tier,
it.weight,
it.itemValue,
it.shopCategory,
it.shopSubcategory,
it.craftingCategory,
nameEn,
);
const itemRows = [...itemByName.values()].map((it) => [
it.uniqueName,
it.baseName,
it.enchantment,
it.tier,
it.weight,
it.itemValue,
it.shopCategory,
it.shopSubcategory,
it.craftingCategory,
// Enchanted names resolve their display name from the localized dump, falling back to the base item.
namesEn.get(it.uniqueName) ?? namesEn.get(it.baseName) ?? null,
]);
// Assign explicit recipe ids so inputs can be bulk-inserted without RETURNING.
const recipeRows: unknown[][] = [];
const inputRows: unknown[][] = [];
recipes.forEach((r, i) => {
const id = i + 1;
recipeRows.push([id, r.outputItem, r.variant, r.amountCrafted, r.silverCost, r.focusCost, r.source]);
const seenInputs = new Set<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,
r.variant,
r.amountCrafted,
r.silverCost,
r.focusCost,
r.source,
);
for (const input of r.inputs) {
insertInput.run(lastInsertRowid, input.item, input.count, input.maxReturnAmount);
inputCount++;
}
}
})();
});
await transaction(async (tx) => {
await tx.exec('DELETE FROM recipe_inputs; DELETE FROM recipes; DELETE FROM items;');
await bulkInsert(
tx,
'items',
['unique_name', 'base_name', 'enchantment', 'tier', 'weight', 'item_value', 'shop_category', 'shop_subcategory', 'crafting_category', 'name_en'],
itemRows,
);
await bulkInsert(tx, 'recipes', ['id', 'output_item', 'variant', 'amount_crafted', 'silver_cost', 'focus_cost', 'source'], recipeRows);
await bulkInsert(tx, 'recipe_inputs', ['recipe_id', 'input_item', 'count', 'max_return_amount'], inputRows);
// keep the SERIAL sequence ahead of our explicit ids for any future inserts
await tx.exec(`SELECT setval(pg_get_serial_sequence('recipes', 'id'), GREATEST((SELECT MAX(id) FROM recipes), 1))`);
});
const stats: ParseStats = {
items: items.length,
recipes: recipes.length,
recipeInputs: inputCount,
items: itemRows.length,
recipes: recipeRows.length,
recipeInputs: inputRows.length,
localizedNames: namesEn.size,
};
setMeta('gamedata_parsed_at', new Date().toISOString());
setMeta('gamedata_stats', JSON.stringify(stats));
await setMeta('gamedata_parsed_at', new Date().toISOString());
await setMeta('gamedata_stats', JSON.stringify(stats));
return stats;
}

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 { API_PORT } from './config.js';
import { getDb } from './db/index.js';
import { registerStaticSpa } from './static.js';
import { registerAuth } from './auth.js';
import { initDb } from './db/index.js';
import { gamedataRoutes } from './routes/gamedata.js';
import { itemRoutes } from './routes/items.js';
import { marketRoutes } from './routes/market.js';
@ -11,9 +16,36 @@ import { watchRoutes } from './routes/watch.js';
import { snifferRoutes } from './routes/sniffer.js';
import { startWatcherLoop } from './watcher/service.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Bind to 127.0.0.1 by default (safe local dev); set HOST=0.0.0.0 to expose (e.g. in Docker).
const HOST = process.env.HOST ?? '127.0.0.1';
const PORT = Number(process.env.PORT ?? API_PORT);
const app = Fastify({ logger: { level: 'info' } });
getDb(); // initialize schema on startup
await initDb(); // create schema on startup
// First-boot convenience: if no game data is loaded yet, download + parse it in
// the background so a fresh deployment is usable without a manual step.
void (async () => {
try {
const { get } = await import('./db/index.js');
const row = await get<{ n: number }>('SELECT COUNT(*) AS n FROM items');
if ((row?.n ?? 0) === 0) {
app.log.info('No game data found — downloading and parsing (first boot)...');
const { downloadGameData } = await import('./gamedata/download.js');
const { parseGameData } = await import('./gamedata/parse.js');
await downloadGameData(false);
const stats = await parseGameData();
app.log.info(`Game data loaded: ${stats.items} items, ${stats.recipes} recipes.`);
}
} catch (e) {
app.log.error(`First-boot game data load failed: ${e instanceof Error ? e.message : e}`);
}
})();
registerAuth(app);
app.get('/api/health', async () => ({ ok: true }));
await app.register(gamedataRoutes);
@ -24,7 +56,14 @@ await app.register(profileRoutes);
await app.register(travelRoutes);
await app.register(watchRoutes);
await app.register(snifferRoutes);
startWatcherLoop();
await startWatcherLoop();
await app.listen({ port: API_PORT, host: '127.0.0.1' });
console.log(`API listening on http://127.0.0.1:${API_PORT}`);
// Serve the built web UI if present (production/Docker). In dev, Vite serves it.
const webDist = process.env.WEB_DIST ?? path.resolve(__dirname, '../../web/dist');
if (fs.existsSync(path.join(webDist, 'index.html'))) {
registerStaticSpa(app, webDist);
app.log.info(`Serving web UI from ${webDist}`);
}
await app.listen({ port: PORT, host: HOST });
console.log(`API listening on http://${HOST}:${PORT}`);

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
* routes its market requests through it.
*/
function getProxyUrl(): string | null {
return getMeta('proxy_url') || process.env.HTTPS_PROXY || process.env.HTTP_PROXY || null;
async function getProxyUrl(): Promise<string | 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> }> {
const proxyUrl = getProxyUrl();
const proxyUrl = await getProxyUrl();
const signal = AbortSignal.timeout(timeoutMs);
if (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 {
AodpUnreachableError,
CITIES,
@ -7,14 +7,14 @@ import {
SERVER_HOSTS,
} from './client.js';
export function getAlbionServer(): string {
const s = getMeta('albion_server');
export async function getAlbionServer(): Promise<string> {
const s = await getMeta('albion_server');
return s && s in SERVER_HOSTS ? s : 'europe';
}
export function setAlbionServer(server: string): void {
export async function setAlbionServer(server: string): Promise<void> {
if (!(server in SERVER_HOSTS)) throw new Error(`unknown server: ${server}`);
setMeta('albion_server', server);
await setMeta('albion_server', server);
}
// ---------------------------------------------------------------------------
@ -45,44 +45,86 @@ export interface RefreshOptions {
}
/** Items worth pricing: everything that appears in a recipe (as output or input). */
function selectRecipeRelevantItems(minTier: number, maxTier: number): string[] {
const rows = getDb()
.prepare(
`SELECT DISTINCT i.unique_name AS name
FROM items i
WHERE i.tier BETWEEN ? AND ?
AND (
EXISTS (SELECT 1 FROM recipes r WHERE r.output_item = i.unique_name)
OR EXISTS (SELECT 1 FROM recipe_inputs ri WHERE ri.input_item = i.unique_name)
)`,
)
.all(minTier, maxTier) as { name: string }[];
async function selectRecipeRelevantItems(minTier: number, maxTier: number): Promise<string[]> {
const rows = await query<{ name: string }>(
`SELECT DISTINCT i.unique_name AS name
FROM items i
WHERE i.tier BETWEEN ? AND ?
AND (
EXISTS (SELECT 1 FROM recipes r WHERE r.output_item = i.unique_name)
OR EXISTS (SELECT 1 FROM recipe_inputs ri WHERE ri.input_item = i.unique_name)
)`,
[minTier, maxTier],
);
return rows.map((r) => r.name);
}
const PRICE_COLUMNS = ['item', 'city', 'quality', 'sell_price_min', 'sell_min_date', 'buy_price_max', 'buy_max_date', 'fetched_at'];
// AODP rows carry both sides of the book, so overwrite. Sniffed orders may carry
// only one side, so coalesce keeps the other side's last-known value.
const TAIL_OVERWRITE = `
ON CONFLICT (item, city, quality) DO UPDATE SET
sell_price_min = excluded.sell_price_min,
sell_min_date = excluded.sell_min_date,
buy_price_max = excluded.buy_price_max,
buy_max_date = excluded.buy_max_date,
fetched_at = excluded.fetched_at`;
const TAIL_COALESCE = `
ON CONFLICT (item, city, quality) DO UPDATE SET
sell_price_min = COALESCE(excluded.sell_price_min, market_prices.sell_price_min),
sell_min_date = CASE WHEN excluded.sell_price_min IS NOT NULL THEN excluded.sell_min_date ELSE market_prices.sell_min_date END,
buy_price_max = COALESCE(excluded.buy_price_max, market_prices.buy_price_max),
buy_max_date = CASE WHEN excluded.buy_price_max IS NOT NULL THEN excluded.buy_max_date ELSE market_prices.buy_max_date END,
fetched_at = excluded.fetched_at`;
/** Chunked multi-row upsert into market_prices (returns rows written). */
export async function upsertPriceRows(rows: unknown[][], mode: 'overwrite' | 'coalesce' = 'overwrite'): Promise<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 } {
if (job.status === 'running') return { started: false, reason: 'a refresh is already running' };
const items =
opts.items && opts.items.length > 0
? opts.items
: selectRecipeRelevantItems(opts.minTier ?? 2, opts.maxTier ?? 8);
if (items.length === 0) return { started: false, reason: 'no items match the selection' };
const cities = opts.cities && opts.cities.length > 0 ? opts.cities : [...CITIES];
job = {
status: 'running',
totalChunks: Math.ceil(items.length / 100),
totalChunks: 0,
doneChunks: 0,
itemCount: items.length,
itemCount: 0,
rowsUpserted: 0,
startedAt: new Date().toISOString(),
};
void (async () => {
try {
const items =
opts.items && opts.items.length > 0
? opts.items
: await selectRecipeRelevantItems(opts.minTier ?? 2, opts.maxTier ?? 8);
if (items.length === 0) {
job.status = 'error';
job.error = 'no items match the selection';
job.finishedAt = new Date().toISOString();
return;
}
job.itemCount = items.length;
job.totalChunks = Math.ceil(items.length / 100);
const cities = opts.cities && opts.cities.length > 0 ? opts.cities : [...CITIES];
const server = await getAlbionServer();
const rows = await fetchPrices({
server: getAlbionServer(),
server,
items,
cities,
onProgress: (done, total) => {
@ -91,42 +133,27 @@ export function startRefresh(opts: RefreshOptions): { started: boolean; reason?:
},
});
const db = getDb();
const upsert = db.prepare(`
INSERT INTO market_prices (item, city, quality, sell_price_min, sell_min_date, buy_price_max, buy_max_date, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(item, city, quality) DO UPDATE SET
sell_price_min = excluded.sell_price_min,
sell_min_date = excluded.sell_min_date,
buy_price_max = excluded.buy_price_max,
buy_max_date = excluded.buy_max_date,
fetched_at = excluded.fetched_at
`);
const now = new Date().toISOString();
let upserted = 0;
db.transaction(() => {
for (const r of rows) {
const sellDate = normalizeAodpDate(r.sell_price_min_date);
const buyDate = normalizeAodpDate(r.buy_price_max_date);
if (!sellDate && !buyDate) continue; // never observed anywhere
upsert.run(
r.item_id,
r.city,
r.quality,
r.sell_price_min > 0 ? r.sell_price_min : null,
sellDate,
r.buy_price_max > 0 ? r.buy_price_max : null,
buyDate,
now,
);
upserted++;
}
})();
job.rowsUpserted = upserted;
const priceRows: unknown[][] = [];
for (const r of rows) {
const sellDate = normalizeAodpDate(r.sell_price_min_date);
const buyDate = normalizeAodpDate(r.buy_price_max_date);
if (!sellDate && !buyDate) continue; // never observed anywhere
priceRows.push([
r.item_id,
r.city,
r.quality,
r.sell_price_min > 0 ? r.sell_price_min : null,
sellDate,
r.buy_price_max > 0 ? r.buy_price_max : null,
buyDate,
now,
]);
}
job.rowsUpserted = await upsertPriceRows(priceRows);
job.status = 'done';
job.finishedAt = new Date().toISOString();
setMeta('market_last_refresh', job.finishedAt);
await setMeta('market_last_refresh', job.finishedAt);
} catch (e) {
job.status = 'error';
job.finishedAt = new Date().toISOString();
@ -148,7 +175,7 @@ export interface MergedPrice {
buyPrice: number | null;
sellSource: 'api' | 'manual' | null;
buySource: 'api' | 'manual' | null;
sellAgeHours: number | null; // null for manual overrides / missing
sellAgeHours: number | null;
buyAgeHours: number | null;
note: string | null;
}
@ -160,10 +187,9 @@ function ageHours(iso: string | null): number | null {
return Math.max(0, (Date.now() - t) / 3600000);
}
export function getPricesForItem(item: string): MergedPrice[] {
const db = getDb();
const api = db.prepare('SELECT * FROM market_prices WHERE item = ?').all(item) as any[];
const overrides = db.prepare('SELECT * FROM price_overrides WHERE item = ?').all(item) as any[];
export async function getPricesForItem(item: string): Promise<MergedPrice[]> {
const api = (await query('SELECT * FROM market_prices WHERE item = ?', [item])) as any[];
const overrides = (await query('SELECT * FROM price_overrides WHERE item = ?', [item])) as any[];
const byKey = new Map<string, MergedPrice>();
for (const r of api) {
@ -210,40 +236,38 @@ export function getPricesForItem(item: string): MergedPrice[] {
return [...byKey.values()].sort((a, b) => a.city.localeCompare(b.city) || a.quality - b.quality);
}
export function setOverride(o: {
export async function setOverride(o: {
item: string;
city: string;
quality?: number;
sellPrice?: number | null;
buyPrice?: number | null;
note?: string | null;
}): void {
getDb()
.prepare(
`INSERT INTO price_overrides (item, city, quality, sell_price, buy_price, note, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(item, city, quality) DO UPDATE SET
sell_price = excluded.sell_price,
buy_price = excluded.buy_price,
note = excluded.note,
updated_at = excluded.updated_at`,
)
.run(o.item, o.city, o.quality ?? 1, o.sellPrice ?? null, o.buyPrice ?? null, o.note ?? null, new Date().toISOString());
}): Promise<void> {
await query(
`INSERT INTO price_overrides (item, city, quality, sell_price, buy_price, note, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (item, city, quality) DO UPDATE SET
sell_price = excluded.sell_price,
buy_price = excluded.buy_price,
note = excluded.note,
updated_at = excluded.updated_at`,
[o.item, o.city, o.quality ?? 1, o.sellPrice ?? null, o.buyPrice ?? null, o.note ?? null, new Date().toISOString()],
);
}
export function deleteOverride(item: string, city: string, quality = 1): void {
getDb().prepare('DELETE FROM price_overrides WHERE item = ? AND city = ? AND quality = ?').run(item, city, quality);
export async function deleteOverride(item: string, city: string, quality = 1): Promise<void> {
await query('DELETE FROM price_overrides WHERE item = ? AND city = ? AND quality = ?', [item, city, quality]);
}
export function marketSummary() {
const db = getDb();
const n = (sql: string) => (db.prepare(sql).get() as { n: number }).n;
export async function marketSummary() {
const n = async (sql: string) => (await get<{ n: number }>(sql))!.n;
return {
server: getAlbionServer(),
lastRefresh: getMeta('market_last_refresh'),
pricedRows: n('SELECT COUNT(*) AS n FROM market_prices'),
pricedItems: n('SELECT COUNT(DISTINCT item) AS n FROM market_prices'),
overrides: n('SELECT COUNT(*) AS n FROM price_overrides'),
server: await getAlbionServer(),
lastRefresh: await getMeta('market_last_refresh'),
pricedRows: await n('SELECT COUNT(*) AS n FROM market_prices'),
pricedItems: await n('SELECT COUNT(DISTINCT item) AS n FROM market_prices'),
overrides: await n('SELECT COUNT(*) AS n FROM price_overrides'),
job: getRefreshJob(),
};
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,19 @@
import type { FastifyInstance } from 'fastify';
import type { AlbionOrder } from '@albion/shared';
import { listInterfaces, snifferStatus, startSniffer, stopSniffer } from '../sniffer/service.js';
import { storeOrders } from '../sniffer/store.js';
export async function snifferRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/sniffer/status', async () => snifferStatus());
// Ingest: the Windows agent pushes captured market orders here.
app.post('/api/ingest/orders', async (req, reply) => {
const body = (req.body ?? {}) as { orders?: AlbionOrder[] };
if (!Array.isArray(body.orders)) return reply.code(400).send({ error: 'orders array required' });
const { pricesStored } = await storeOrders(body.orders);
return { ok: true, ordersReceived: body.orders.length, pricesStored };
});
app.get('/api/sniffer/interfaces', async () => ({ interfaces: listInterfaces() }));
app.post('/api/sniffer/start', async (req, reply) => {

View file

@ -1,24 +1,32 @@
import type { FastifyInstance } from 'fastify';
import { importWorld } from '../travel/import.js';
import { importWorldLocal, storeWorld } from '../travel/import.js';
import { findRoute, haulAdvisor, listZones, travelStatus, type TravelMode } from '../travel/route.js';
import type { ParsedWorld } from '@albion/shared';
export async function travelRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/travel/status', async () => travelStatus());
app.post('/api/travel/import', async (_req, reply) => {
try {
return importWorld();
return await importWorldLocal();
} catch (e) {
return reply.code(400).send({ error: e instanceof Error ? e.message : String(e) });
}
});
app.get('/api/travel/zones', async () => ({ zones: listZones() }));
// Ingest: the Windows agent pushes a parsed world map here (game runs on the agent's PC).
app.post('/api/ingest/world', async (req, reply) => {
const body = (req.body ?? {}) as { world?: ParsedWorld };
if (!body.world?.zones?.length) return reply.code(400).send({ error: 'world.zones required' });
return storeWorld(body.world, 'agent');
});
app.get('/api/travel/zones', async () => ({ zones: await listZones() }));
app.post('/api/travel/route', async (req, reply) => {
const { from, to, mode } = (req.body ?? {}) as { from?: string; to?: string; mode?: TravelMode };
if (!from || !to) return reply.code(400).send({ error: 'from and to are required' });
const result = findRoute(from, to, mode === 'all' ? 'all' : 'safe');
const result = await findRoute(from, to, mode === 'all' ? 'all' : 'safe');
if ('error' in result) return reply.code(400).send(result);
return result;
});
@ -32,6 +40,6 @@ export async function travelRoutes(app: FastifyInstance): Promise<void> {
maxTier?: number;
};
if (!from || !to) return reply.code(400).send({ error: 'from and to are required' });
return { items: haulAdvisor(from, to, maxWeight ?? 1500, minTier ?? 2, maxTier ?? 8) };
return { items: await haulAdvisor(from, to, maxWeight ?? 1500, minTier ?? 2, maxTier ?? 8) };
});
}

View file

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

View file

@ -1,50 +1,12 @@
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import fs from 'node:fs';
import { getDb } from '../db/index.js';
import { PhotonPacketParser } from 'photon-packet-parser';
import { findTshark, listInterfaces, startCapture, type CaptureHandle } from '@albion/shared';
import { storeOrders, type CapturedOrder } from './store.js';
/**
* Passive sniffer for the user's OWN Albion client traffic (UDP 5056).
* Capture is done by spawning tshark (Wireshark CLI, which bundles Npcap on
* Windows) so no native Node modules are needed; payloads are decoded with the
* pure-JS Photon protocol parser. Read-only observation — the SBI-tolerated
* category (same technique as the Albion Data Project client). No memory
* reading, no injection, no automation.
*
* Consumers:
* 1. Market orders: whenever the player opens a marketplace in game, the
* auction responses contain order JSON — we detect them by shape (any
* string parameter parsing as JSON with UnitPriceSilver), which survives
* patches, and feed fresh firsthand prices into market_prices.
* 2. Event-code debug log: counts photon event codes seen, so destiny-board
* related codes can be identified in a live session and added later.
* LOCAL sniffer: used only when the server runs on the same PC as the game.
* When the server is deployed, the Windows agent captures instead and pushes
* results to /api/ingest/*. Capture + Photon decoding live in @albion/shared.
*/
const TSHARK_CANDIDATES = [
'C:\\Program Files\\Wireshark\\tshark.exe',
'C:\\Program Files (x86)\\Wireshark\\tshark.exe',
];
/** Market LocationId -> city (protocol ids, not cluster ids). Editable if a patch changes them. */
const MARKET_LOCATIONS: Record<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 {
tsharkPath: string | null;
running: boolean;
@ -58,27 +20,7 @@ export interface SnifferStatus {
recentOrders: CapturedOrder[];
}
export interface CapturedOrder {
item: string;
city: string;
quality: number;
auctionType: string;
unitPriceSilver: number;
amount: number;
seenAt: string;
}
interface AlbionOrder {
UnitPriceSilver: number;
Amount: number;
AuctionType: string;
ItemTypeId: string;
EnchantmentLevel: number;
QualityLevel: number;
LocationId: string | number;
}
let proc: ChildProcess | null = null;
let handle: CaptureHandle | null = null;
const state = {
packets: 0,
photonEvents: 0,
@ -90,195 +32,66 @@ const state = {
recentOrders: [] as CapturedOrder[],
};
export function listInterfaces(): { id: string; name: string }[] {
const tshark = findTshark();
if (!tshark) return [];
const res = spawnSync(tshark, ['-D'], { encoding: 'utf8', timeout: 15000 });
return (res.stdout ?? '')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
const m = line.match(/^(\d+)\.\s+(.*)$/);
return m ? { id: m[1], name: m[2] } : null;
})
.filter((x): x is { id: string; name: string } => x != null);
}
function toItemId(o: AlbionOrder): string {
if (o.ItemTypeId.includes('@')) return o.ItemTypeId;
return o.EnchantmentLevel > 0 ? `${o.ItemTypeId}@${o.EnchantmentLevel}` : o.ItemTypeId;
}
/** Group captured orders into best sell (min) / best buy (max) and store them as fresh prices. */
function storeOrders(orders: AlbionOrder[]): void {
const best = new Map<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 { listInterfaces };
export function startSniffer(interfaceIds?: string[]): { started: boolean; error?: string } {
if (proc) return { started: false, error: 'sniffer already running' };
const tshark = findTshark();
if (!tshark) {
return {
started: false,
error:
'tshark.exe not found. Install Wireshark (https://www.wireshark.org) with the bundled Npcap driver — the sniffer uses it to read your own game traffic passively.',
};
}
if (handle) return { started: false, error: 'sniffer already running' };
const parser = new PhotonPacketParser();
parser.on('event', (d: any) => handlePhotonMessage(d, true));
parser.on('response', (d: any) => handlePhotonMessage(d, false));
parser.on('request', () => undefined);
parser.on('error', () => undefined);
const ifaces = interfaceIds && interfaceIds.length > 0 ? interfaceIds : listInterfaces().map((i) => i.id);
const ifaceArgs = ifaces.flatMap((i) => ['-i', i]);
const args = [...ifaceArgs, '-l', '-n', '-q', '-f', 'udp port 5056', '-T', 'fields', '-e', 'data'];
proc = spawn(tshark, args, { windowsHide: true });
state.packets = 0;
state.photonEvents = 0;
state.marketOrdersSeen = 0;
state.pricesStored = 0;
state.startedAt = new Date().toISOString();
state.lastError = null;
Object.assign(state, {
packets: 0,
photonEvents: 0,
marketOrdersSeen: 0,
pricesStored: 0,
startedAt: new Date().toISOString(),
lastError: null,
recentOrders: [],
});
state.eventCodeCounts.clear();
state.recentOrders = [];
let pending = '';
proc.stdout!.on('data', (chunk: Buffer) => {
pending += chunk.toString('ascii');
const lines = pending.split('\n');
pending = lines.pop() ?? '';
for (const line of lines) {
const hex = line.trim().replace(/:/g, '');
if (!hex || hex.length < 24) continue;
state.packets++;
try {
parser.handle(Buffer.from(hex, 'hex'));
} catch {
/* malformed packet — ignore */
}
}
});
proc.stderr!.on('data', (chunk: Buffer) => {
const msg = chunk.toString('utf8').trim();
// tshark prints capture stats to stderr; only keep real errors
if (msg && !/^Capturing on/i.test(msg) && !/^\d+ packets?/i.test(msg)) state.lastError = msg.slice(0, 400);
});
proc.on('exit', (code) => {
if (code !== 0 && code != null) state.lastError = state.lastError ?? `tshark exited with code ${code}`;
proc = null;
});
const result = startCapture(
{
onPacket: () => {
state.packets++;
},
onEventCode: (code) => {
state.photonEvents++;
state.eventCodeCounts.set(code, (state.eventCodeCounts.get(code) ?? 0) + 1);
},
onOrders: (orders) => {
state.marketOrdersSeen += orders.length;
void storeOrders(orders)
.then(({ pricesStored, recent }) => {
state.pricesStored += pricesStored;
state.recentOrders = [...recent, ...state.recentOrders].slice(0, 50);
})
.catch((e) => {
state.lastError = `storing sniffed orders failed: ${e instanceof Error ? e.message : e}`;
});
},
onError: (message) => {
state.lastError = message;
},
},
interfaceIds,
);
if ('error' in result) {
state.startedAt = null;
return { started: false, error: result.error };
}
handle = result;
return { started: true };
}
export function stopSniffer(): void {
if (proc) {
proc.kill();
proc = null;
}
handle?.stop();
handle = null;
}
export function snifferStatus(): SnifferStatus {
return {
tsharkPath: findTshark(),
running: proc != null,
running: handle != null,
packets: state.packets,
photonEvents: state.photonEvents,
marketOrdersSeen: state.marketOrdersSeen,

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 path from 'node:path';
import zlib from 'node:zlib';
import { XMLParser } from 'fast-xml-parser';
import { getDb, getMeta, setMeta } from '../db/index.js';
import { decryptBin, parseWorldXml, WORLD_BIN_SUBPATH, type ParsedWorld } from '@albion/shared';
import { getMeta, setMeta, transaction } from '../db/index.js';
/**
* The world map (zones + portals) comes from the game's own files:
* <game 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.
* World map (zones + portal connections) storage. Two entry points:
* - importWorldLocal(): read the game's world.bin from THIS machine's disk
* (used when the server runs on the gaming PC).
* - storeWorld(): persist a ParsedWorld sent by the Windows agent (used when
* the server is deployed and the game runs elsewhere).
* Parsing/decryption lives in @albion/shared so the agent reuses it verbatim.
*/
const BIN_KEY = Buffer.from([48, 239, 114, 71, 66, 242, 4, 50]);
const BIN_IV = Buffer.from([14, 166, 220, 137, 219, 237, 220, 79]);
export function decryptBin(encrypted: Buffer): Buffer {
const decipher = des.CBC.instantiate(des.DES).create({ type: 'decrypt', key: BIN_KEY, iv: BIN_IV });
let compressed = Buffer.from([...decipher.update(encrypted), ...decipher.final()]);
// strip PKCS7 padding (des.js does not remove it)
const pad = compressed[compressed.length - 1];
if (pad >= 1 && pad <= 8) compressed = compressed.subarray(0, compressed.length - pad);
return zlib.gunzipSync(compressed);
}
const WORLD_BIN_SUBPATH = path.join('game', 'Albion-Online_Data', 'StreamingAssets', 'GameData', 'cluster', 'world.bin');
const COMMON_GAME_DIRS = [
'E:\\albion',
@ -35,147 +19,61 @@ const COMMON_GAME_DIRS = [
'C:\\Program Files (x86)\\Steam\\steamapps\\common\\Albion Online',
];
export function findWorldBin(): string | null {
const configured = getMeta('game_path');
export async function findWorldBin(): Promise<string | null> {
const configured = await getMeta('game_path');
const candidates = configured ? [configured, ...COMMON_GAME_DIRS] : COMMON_GAME_DIRS;
for (const dir of candidates) {
const direct = path.join(dir, WORLD_BIN_SUBPATH);
if (fs.existsSync(direct)) return direct;
// allow pointing straight at .../GameData or the cluster dir or the bin itself
const alternatives = [dir, path.join(dir, 'cluster', 'world.bin'), path.join(dir, 'world.bin')];
for (const alt of alternatives) {
if (alt.endsWith('world.bin') && fs.existsSync(alt)) return alt;
}
if (dir.endsWith('world.bin') && fs.existsSync(dir)) return dir;
}
return null;
}
export function zoneColor(type: string): string {
const t = type.toUpperCase();
if (t.startsWith('PLAYERCITY')) return 'city';
if (t.startsWith('SAFEAREA')) return 'blue';
if (t.includes('YELLOW')) return 'yellow';
if (t.includes('RED')) return 'red';
if (t.includes('BLACK')) return 'black';
if (t.startsWith('TUNNEL')) return 'road';
return 'other';
}
interface RawExit {
'@id'?: string;
'@targetid'?: string;
}
interface RawCluster {
'@id'?: string;
'@displayname'?: string;
'@type'?: string;
exits?: { exit?: RawExit | RawExit[] };
}
function asArray<T>(v: T | T[] | undefined): T[] {
if (v === undefined || v === null) return [];
return Array.isArray(v) ? v : [v];
}
export interface WorldImportStats {
zones: number;
connections: number;
worldBinPath: string;
source: string;
}
export function importWorld(): WorldImportStats {
const binPath = findWorldBin();
export async function storeWorld(world: ParsedWorld, source: string): Promise<WorldImportStats> {
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) {
throw new Error(
'world.bin not found. Set your Albion Online game folder in Settings ' +
'(the folder that contains the "game" directory).',
'world.bin not found on this machine. Set your Albion Online game folder in Settings, ' +
'or push it from the Windows agent if the game runs on another PC.',
);
}
const xml = decryptBin(fs.readFileSync(binPath)).toString('utf8');
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@',
isArray: (name) => name === 'cluster' || name === 'exit',
});
const doc = parser.parse(xml);
const clusters: RawCluster[] = doc?.world?.clusters?.cluster ?? [];
if (clusters.length === 0) throw new Error('no clusters found in world.bin — format may have changed');
// Same filtering approach as Portaler: overworld zones only. Roads of Avalon
// (TUNNEL_*) have dynamic connections, so they are excluded from routing.
const zones = clusters.filter((c) => {
const type = (c['@type'] ?? '').toUpperCase();
const name = c['@displayname'] ?? '';
const id = c['@id'] ?? '';
return (
(type.startsWith('OPENPVP') || type.startsWith('SAFEAREA') || type.startsWith('PLAYERCITY') || type.startsWith('PASSAGE')) &&
!!id &&
!!name &&
!name.toLowerCase().includes('debug') &&
!type.includes('DUNGEON_') &&
!type.includes('EXPEDITION_') &&
!type.includes('ARENA_') &&
!type.includes('_NOFURNITURE') &&
type !== 'PASSAGE_SAFEAREA'
);
});
const zoneIds = new Set(zones.map((z) => z['@id']!));
// Exits reference each other by GUID: exit X's targetid is the GUID of the
// matching exit in the neighboring zone. Map every exit GUID to its zone,
// then resolve each targetid to the neighbor.
const exitOwner = new Map<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 };
const world = parseWorldXml(xml);
return storeWorld(world, `local:${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 { getEconomyConfig } from '../economy/config.js';
import { buildProvider } from '../economy/service.js';
@ -17,14 +17,13 @@ const SAFE_COLORS = new Set(['city', 'blue', 'yellow']);
/** Roads of Avalon connections are dynamic; never route through them. */
const ROUTABLE_COLORS = new Set(['city', 'blue', 'yellow', 'red', 'black', 'other']);
function loadGraph(): { zones: Map<string, Zone>; adj: Map<string, string[]> } {
const db = getDb();
async function loadGraph(): Promise<{ zones: Map<string, Zone>; adj: Map<string, string[]> }> {
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 });
}
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_b)) adj.set(r.zone_b, []);
adj.get(r.zone_a)!.push(r.zone_b);
@ -40,8 +39,8 @@ export interface RouteResult {
dangerZones: number; // red + black on the path
}
export function findRoute(fromName: string, toName: string, mode: TravelMode): RouteResult | { error: string } {
const { zones, adj } = loadGraph();
export async function findRoute(fromName: string, toName: string, mode: TravelMode): Promise<RouteResult | { error: string }> {
const { zones, adj } = await loadGraph();
if (zones.size === 0) return { error: 'world map not imported — run the import on the Travel page' };
const byName = new Map<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 };
}
export function listZones(): Zone[] {
return (getDb().prepare('SELECT * FROM zones ORDER BY name').all() as any[]).map((r) => ({
export async function listZones(): Promise<Zone[]> {
return ((await query('SELECT * FROM zones ORDER BY name')) as any[]).map((r) => ({
albionId: r.albion_id,
name: r.name,
type: r.type,
@ -95,20 +94,15 @@ export function listZones(): Zone[] {
}));
}
export function travelStatus() {
const db = getDb();
const hasTables = db
.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='zones'")
.get() as { n: number };
const zoneCount = hasTables.n > 0 ? (db.prepare('SELECT COUNT(*) AS n FROM zones').get() as { n: number }).n : 0;
const connCount =
hasTables.n > 0 && zoneCount > 0 ? (db.prepare('SELECT COUNT(*) AS n FROM zone_connections').get() as { n: number }).n : 0;
export async function travelStatus() {
const zoneCount = (await get<{ n: number }>('SELECT COUNT(*) AS n FROM zones'))!.n;
const connCount = zoneCount > 0 ? (await get<{ n: number }>('SELECT COUNT(*) AS n FROM zone_connections'))!.n : 0;
return {
zones: zoneCount,
connections: connCount,
importedAt: getMeta('world_imported_at'),
worldBinPath: getMeta('world_bin_path'),
gamePath: getMeta('game_path'),
importedAt: await getMeta('world_imported_at'),
worldSource: await getMeta('world_source'),
gamePath: await getMeta('game_path'),
};
}
@ -128,18 +122,19 @@ export interface HaulItem {
profitPerWeight: number;
}
export function haulAdvisor(from: string, to: string, maxWeight: number, minTier = 2, maxTier = 8): HaulItem[] {
const cfg = getEconomyConfig();
const premium = getActiveProfile()?.premium ?? true;
export async function haulAdvisor(from: string, to: string, maxWeight: number, minTier = 2, maxTier = 8): Promise<HaulItem[]> {
const cfg = await getEconomyConfig();
const premium = (await getActiveProfile())?.premium ?? true;
const tax = premium ? cfg.salesTaxPremium : cfg.salesTaxNoPremium;
const feeRate = tax + cfg.listingFee;
const origin = buildProvider(from);
const dest = buildProvider(to);
const origin = await buildProvider(from);
const dest = await buildProvider(to);
const items = getDb()
.prepare('SELECT unique_name, name_en, tier, weight FROM items WHERE weight IS NOT NULL AND weight > 0 AND tier BETWEEN ? AND ?')
.all(minTier, maxTier) as { unique_name: string; name_en: string | null; tier: number | null; weight: number }[];
const items = (await query(
'SELECT unique_name, name_en, tier, weight FROM items WHERE weight IS NOT NULL AND weight > 0 AND tier BETWEEN ? AND ?',
[minTier, maxTier],
)) as { unique_name: string; name_en: string | null; tier: number | null; weight: number }[];
const results: HaulItem[] = [];
for (const it of items) {

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

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 GameDataPage from './pages/GameDataPage';
import MarketPage from './pages/MarketPage';
@ -8,6 +8,7 @@ import CharacterPage from './pages/CharacterPage';
import TravelPage from './pages/TravelPage';
import WatchPage from './pages/WatchPage';
import SnifferPage from './pages/SnifferPage';
import { getApiKey, onUnauthorized, setApiKey } from './apiKey';
const PAGES = [
{ id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage },
@ -21,9 +22,37 @@ const PAGES = [
{ id: 'settings', label: 'Settings', component: SettingsPage },
] as const;
function ApiKeyGate({ onSaved }: { onSaved: () => void }) {
const [key, setKey] = useState(getApiKey());
return (
<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() {
const [page, setPage] = useState<string>('items');
const Active = PAGES.find((p) => p.id === page)?.component ?? ItemsPage;
const [page, setPage] = useState<string>('opportunities');
const [locked, setLocked] = useState(false);
const Active = PAGES.find((p) => p.id === page)?.component ?? OpportunitiesPage;
useEffect(() => {
onUnauthorized(() => setLocked(true));
// probe: if the server needs a key and we don't have a valid one, lock
fetch('/api/settings').then((r) => {
if (r.status === 401) setLocked(true);
});
}, []);
if (locked) return <ApiKeyGate onSaved={() => { setLocked(false); location.reload(); }} />;
return (
<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 ReactDOM from 'react-dom/client';
import App from './App';
import { installFetchAuth } from './apiKey';
import './styles.css';
installFetchAuth();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />