Client/server split, Postgres, auth, Windows agent, Docker deploy
All checks were successful
Deploy / deploy (push) Successful in 3m58s
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:
parent
9c4a2a8ed3
commit
337dac78ba
48 changed files with 2090 additions and 1388 deletions
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
server/data
|
||||
.git
|
||||
*.log
|
||||
.env
|
||||
10
.env.example
Normal file
10
.env.example
Normal 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
|
||||
68
.forgejo/workflows/deploy.yml
Normal file
68
.forgejo/workflows/deploy.yml
Normal 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
46
Dockerfile
Normal 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"]
|
||||
80
README.md
80
README.md
|
|
@ -1,46 +1,76 @@
|
|||
# Albion Economy Assistant
|
||||
|
||||
A local web app that tells you what to craft, refine, enchant, haul, and watch in Albion Online — using live market prices, the game's own recipe data, and **your** character's destiny board progress.
|
||||
Tells you what to craft, refine, enchant, haul, and watch in Albion Online — using live market prices, the game's own recipe data, and **your** character's destiny board progress.
|
||||
|
||||
## Run it
|
||||
## Architecture
|
||||
|
||||
Three deployable pieces plus a shared library:
|
||||
|
||||
- **server/** — Fastify + TypeScript API that also serves the web UI. Stores everything in a **shared Postgres**. Deployable (Docker) or run locally.
|
||||
- **web/** — Vite + React UI (built and served by the server in production).
|
||||
- **agent/** — a small **Windows companion** that runs on your gaming PC. It reads game-local data a deployed server can't see (world map, live market orders) and pushes it to the server over an authenticated API. See `agent/README.md`.
|
||||
- **shared/** — world-map parsing + packet capture, reused by server and agent.
|
||||
|
||||
```
|
||||
[ Your gaming PC ] [ Server (VPS / Forgejo deploy) ]
|
||||
Albion Online albion-economy container
|
||||
agent ── HTTPS + API key ──► /api/ingest/* ├─ serves web UI
|
||||
(world map, market orders) └─ Postgres (shared, `backend` net)
|
||||
Nginx Proxy Manager (`proxy` net) → subdomain
|
||||
You ── browser ──────────────► the same server (web UI, API key in browser)
|
||||
```
|
||||
|
||||
## Run locally (single machine)
|
||||
|
||||
Needs a Postgres to point at. Quick throwaway one:
|
||||
|
||||
```
|
||||
docker run -d --name albion-pg -e POSTGRES_PASSWORD=pw -e POSTGRES_DB=albion -p 5432:5432 postgres:16-alpine
|
||||
export DATABASE_URL=postgres://postgres:pw@127.0.0.1:5432/albion # PowerShell: $env:DATABASE_URL=...
|
||||
npm install
|
||||
npm run gamedata # first time only: download items/recipes (~40 MB)
|
||||
npm run dev # starts API (port 4310) + UI
|
||||
npm run gamedata # download items/recipes into Postgres (first time)
|
||||
npm run dev # API on :4310, Vite UI on :5173
|
||||
```
|
||||
|
||||
Open **http://localhost:5173**.
|
||||
Open **http://localhost:5173**. With no `ALBION_API_KEY` set, the API is open (fine for localhost).
|
||||
|
||||
## First-time setup
|
||||
## Deploy (Docker + Forgejo)
|
||||
|
||||
1. **Settings page** — pick your Albion server (Europe is default). If `albion-online-data.com` is blocked in your country, turn on your VPN before downloading prices (or set a proxy URL if your VPN is browser-only). Set your game folder (e.g. `E:\albion`) for the travel planner.
|
||||
2. **Market Data page** — click *Download prices* for your tier range. Prices show age badges (green = fresh); you can manually override any price, and manual prices always win over API data.
|
||||
3. **Character page** — create a profile, set premium, and enter your destiny board levels per crafting category (mastery / spec / other specs). Every calculation uses the active profile.
|
||||
4. **Travel Planner page** — click *Import world map* (reads zone connections from your game's files, read-only).
|
||||
The repo ships a `Dockerfile`, `docker-compose.yml`, and `.forgejo/workflows/deploy.yml`.
|
||||
|
||||
1. On the host, have your shared Postgres reachable on an external Docker network named **`backend`**, and Nginx Proxy Manager on **`proxy`**. Create the database: `CREATE DATABASE albion;`
|
||||
2. Add Forgejo Actions **secrets**: `DATABASE_URL` (e.g. `postgres://albion:pw@postgres:5432/albion`) and `ALBION_API_KEY` (a long random string).
|
||||
3. Push to `master` (or run the workflow manually). It builds the image and `docker compose up -d`. The container joins `proxy` + `backend`; point an NPM proxy host at `albion-economy:4310` and assign your subdomain.
|
||||
4. First boot auto-downloads and parses game data into Postgres.
|
||||
|
||||
Locally you can mirror it: create `.env` from `.env.example`, ensure the `proxy`/`backend` networks exist, then `docker compose up -d`.
|
||||
|
||||
### Security
|
||||
|
||||
- **`ALBION_API_KEY` is required in the compose file.** Every `/api/*` route (except `/api/health`) needs `Authorization: Bearer <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
47
agent/README.md
Normal 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
18
agent/package.json
Normal 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
36
agent/src/api.ts
Normal 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
54
agent/src/config.ts
Normal 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
143
agent/src/index.ts
Normal 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
16
agent/tsconfig.json
Normal 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
38
docker-compose.yml
Normal 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
638
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
32
server/src/auth.ts
Normal 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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
56
server/src/sniffer/store.ts
Normal file
56
server/src/sniffer/store.ts
Normal 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
46
server/src/static.ts
Normal 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
|
||||
});
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
23
shared/package.json
Normal 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
157
shared/src/capture.ts
Normal 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
19
shared/src/index.ts
Normal 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
140
shared/src/worldParse.ts
Normal 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
15
shared/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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
41
web/src/apiKey.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Reference in a new issue