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>
143 lines
4.4 KiB
TypeScript
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();
|