albion/agent/src/index.ts
Заид Омар Медхат 337dac78ba
All checks were successful
Deploy / deploy (push) Successful in 3m58s
Client/server split, Postgres, auth, Windows agent, Docker deploy
- 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>
2026-08-23 04:10:08 +05:00

143 lines
4.4 KiB
TypeScript

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();