albion/server/src/sniffer/service.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

104 lines
2.9 KiB
TypeScript

import { findTshark, listInterfaces, startCapture, type CaptureHandle } from '@albion/shared';
import { storeOrders, type CapturedOrder } from './store.js';
/**
* 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.
*/
export interface SnifferStatus {
tsharkPath: string | null;
running: boolean;
packets: number;
photonEvents: number;
marketOrdersSeen: number;
pricesStored: number;
startedAt: string | null;
lastError: string | null;
eventCodeCounts: Record<string, number>;
recentOrders: CapturedOrder[];
}
let handle: CaptureHandle | null = null;
const state = {
packets: 0,
photonEvents: 0,
marketOrdersSeen: 0,
pricesStored: 0,
startedAt: null as string | null,
lastError: null as string | null,
eventCodeCounts: new Map<number, number>(),
recentOrders: [] as CapturedOrder[],
};
export { listInterfaces };
export function startSniffer(interfaceIds?: string[]): { started: boolean; error?: string } {
if (handle) return { started: false, error: 'sniffer already running' };
Object.assign(state, {
packets: 0,
photonEvents: 0,
marketOrdersSeen: 0,
pricesStored: 0,
startedAt: new Date().toISOString(),
lastError: null,
recentOrders: [],
});
state.eventCodeCounts.clear();
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 {
handle?.stop();
handle = null;
}
export function snifferStatus(): SnifferStatus {
return {
tsharkPath: findTshark(),
running: handle != null,
packets: state.packets,
photonEvents: state.photonEvents,
marketOrdersSeen: state.marketOrdersSeen,
pricesStored: state.pricesStored,
startedAt: state.startedAt,
lastError: state.lastError,
eventCodeCounts: Object.fromEntries([...state.eventCodeCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 40)),
recentOrders: state.recentOrders,
};
}