Phase 7: passive game sniffer + README

tshark-based capture (no native modules) of the player's own UDP 5056
traffic decoded with photon-packet-parser; market orders detected by
JSON shape (patch-proof) and stored as firsthand prices; event-code
debug log to identify destiny-board codes; Sniffer page + README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Заид Омар Медхат 2026-08-23 03:32:22 +05:00
parent 4c16fead97
commit 9c4a2a8ed3
8 changed files with 571 additions and 0 deletions

46
README.md Normal file
View file

@ -0,0 +1,46 @@
# 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.
## Run it
```
npm install
npm run gamedata # first time only: download items/recipes (~40 MB)
npm run dev # starts API (port 4310) + UI
```
Open **http://localhost:5173**.
## First-time setup
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).
## 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.
## 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
## Tests
```
npm test
```

59
package-lock.json generated
View file

@ -1639,6 +1639,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/assert-plus": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/assertion-error": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@ -1803,6 +1812,17 @@
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
} }
}, },
"node_modules/buffercursor": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/buffercursor/-/buffercursor-0.0.12.tgz",
"integrity": "sha512-Z+6Jm/eW6ITeqcFQKVXX7LYIGk7rENqCKHJ4CbWfJMeLpQZJj1v70WehkLmp+1kFN/QyCgpQ3Z0dKUHAwSbf9w==",
"dependencies": {
"verror": "^1.4.0"
},
"engines": {
"node": ">= 0.5.0"
}
},
"node_modules/cac": { "node_modules/cac": {
"version": "6.7.14", "version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@ -1977,6 +1997,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -2175,6 +2201,15 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/extsprintf": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
"integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==",
"engines": [
"node >=0.6.0"
],
"license": "MIT"
},
"node_modules/fast-decode-uri-component": { "node_modules/fast-decode-uri-component": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
@ -2758,6 +2793,15 @@
"node": ">= 14.16" "node": ">= 14.16"
} }
}, },
"node_modules/photon-packet-parser": {
"version": "0.0.19",
"resolved": "https://registry.npmjs.org/photon-packet-parser/-/photon-packet-parser-0.0.19.tgz",
"integrity": "sha512-js4WLrMHLdkMdhr04Ww9jkMbhTYFx+JHQOh0j+Qi1ZwzxAVwk5zgMs+6pV9rENLQRaGnQ7s7dRXvMoU53YfKjg==",
"license": "ISC",
"dependencies": {
"buffercursor": "^0.0.12"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -3596,6 +3640,20 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/verror": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
"integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==",
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.6", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
@ -3877,6 +3935,7 @@
"des.js": "^1.1.0", "des.js": "^1.1.0",
"fast-xml-parser": "^5.11.0", "fast-xml-parser": "^5.11.0",
"fastify": "^5.4.0", "fastify": "^5.4.0",
"photon-packet-parser": "^0.0.19",
"undici": "^8.10.0" "undici": "^8.10.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -15,6 +15,7 @@
"des.js": "^1.1.0", "des.js": "^1.1.0",
"fast-xml-parser": "^5.11.0", "fast-xml-parser": "^5.11.0",
"fastify": "^5.4.0", "fastify": "^5.4.0",
"photon-packet-parser": "^0.0.19",
"undici": "^8.10.0" "undici": "^8.10.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -8,6 +8,7 @@ import { economyRoutes } from './routes/economy.js';
import { profileRoutes } from './routes/profiles.js'; import { profileRoutes } from './routes/profiles.js';
import { travelRoutes } from './routes/travel.js'; import { travelRoutes } from './routes/travel.js';
import { watchRoutes } from './routes/watch.js'; import { watchRoutes } from './routes/watch.js';
import { snifferRoutes } from './routes/sniffer.js';
import { startWatcherLoop } from './watcher/service.js'; import { startWatcherLoop } from './watcher/service.js';
const app = Fastify({ logger: { level: 'info' } }); const app = Fastify({ logger: { level: 'info' } });
@ -22,6 +23,7 @@ await app.register(economyRoutes);
await app.register(profileRoutes); await app.register(profileRoutes);
await app.register(travelRoutes); await app.register(travelRoutes);
await app.register(watchRoutes); await app.register(watchRoutes);
await app.register(snifferRoutes);
startWatcherLoop(); startWatcherLoop();
await app.listen({ port: API_PORT, host: '127.0.0.1' }); await app.listen({ port: API_PORT, host: '127.0.0.1' });

View file

@ -0,0 +1,20 @@
import type { FastifyInstance } from 'fastify';
import { listInterfaces, snifferStatus, startSniffer, stopSniffer } from '../sniffer/service.js';
export async function snifferRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/sniffer/status', async () => snifferStatus());
app.get('/api/sniffer/interfaces', async () => ({ interfaces: listInterfaces() }));
app.post('/api/sniffer/start', async (req, reply) => {
const { interfaces } = (req.body ?? {}) as { interfaces?: string[] };
const result = startSniffer(interfaces);
if (!result.started) return reply.code(400).send({ error: result.error });
return { ok: true };
});
app.post('/api/sniffer/stop', async () => {
stopSniffer();
return { ok: true };
});
}

View file

@ -0,0 +1,291 @@
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';
/**
* 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.
*/
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;
packets: number;
photonEvents: number;
marketOrdersSeen: number;
pricesStored: number;
startedAt: string | null;
lastError: string | null;
eventCodeCounts: Record<string, number>;
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;
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 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 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.',
};
}
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;
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;
});
return { started: true };
}
export function stopSniffer(): void {
if (proc) {
proc.kill();
proc = null;
}
}
export function snifferStatus(): SnifferStatus {
return {
tsharkPath: findTshark(),
running: proc != 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,
};
}

View file

@ -7,11 +7,13 @@ import OpportunitiesPage from './pages/OpportunitiesPage';
import CharacterPage from './pages/CharacterPage'; import CharacterPage from './pages/CharacterPage';
import TravelPage from './pages/TravelPage'; import TravelPage from './pages/TravelPage';
import WatchPage from './pages/WatchPage'; import WatchPage from './pages/WatchPage';
import SnifferPage from './pages/SnifferPage';
const PAGES = [ const PAGES = [
{ id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage }, { id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage },
{ id: 'travel', label: 'Travel Planner', component: TravelPage }, { id: 'travel', label: 'Travel Planner', component: TravelPage },
{ id: 'watch', label: 'Market Watch', component: WatchPage }, { id: 'watch', label: 'Market Watch', component: WatchPage },
{ id: 'sniffer', label: 'Game Sniffer', component: SnifferPage },
{ id: 'character', label: 'Character', component: CharacterPage }, { id: 'character', label: 'Character', component: CharacterPage },
{ id: 'market', label: 'Market Data', component: MarketPage }, { id: 'market', label: 'Market Data', component: MarketPage },
{ id: 'items', label: 'Item Browser', component: ItemsPage }, { id: 'items', label: 'Item Browser', component: ItemsPage },

View file

@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from 'react';
interface CapturedOrder {
item: string;
city: string;
quality: number;
auctionType: string;
unitPriceSilver: number;
amount: number;
seenAt: string;
}
interface Status {
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[];
}
export default function SnifferPage() {
const [status, setStatus] = useState<Status | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const pollRef = useRef<number | null>(null);
const load = () => fetch('/api/sniffer/status').then((r) => r.json()).then(setStatus);
useEffect(() => {
load();
}, []);
useEffect(() => {
if (status?.running && pollRef.current == null) {
pollRef.current = window.setInterval(load, 3000);
}
if (!status?.running && pollRef.current != null) {
clearInterval(pollRef.current);
pollRef.current = null;
}
return () => {
if (pollRef.current != null) clearInterval(pollRef.current);
pollRef.current = null;
};
}, [status?.running]);
const start = async () => {
setBusy(true);
setError('');
try {
const res = await fetch('/api/sniffer/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!res.ok) setError((await res.json()).error);
await load();
} finally {
setBusy(false);
}
};
const stop = async () => {
await fetch('/api/sniffer/stop', { method: 'POST' });
load();
};
return (
<div>
<h2>Game Sniffer</h2>
<div className="card">
<p className="muted">
Passively reads your own game traffic (the same technique the Albion Data Project client uses — read-only,
no game modification). While it runs, open a marketplace in game: every price you see is captured as
firsthand market data, fresher than the community API.
</p>
{status?.tsharkPath ? (
<p>Capture engine: <span className="tag">{status.tsharkPath}</span></p>
) : (
<p style={{ color: '#e0c069' }}>
Wireshark/tshark not found. Install <strong>Wireshark</strong> from wireshark.org (keep the bundled
<strong> Npcap</strong> driver checked during install), then reopen this page.
</p>
)}
<div style={{ display: 'flex', gap: 10 }}>
{!status?.running ? (
<button className="primary" disabled={busy || !status?.tsharkPath} onClick={start}>Start capture</button>
) : (
<button className="primary" onClick={stop}>Stop capture</button>
)}
</div>
{error && <p style={{ color: '#e08869' }}>{error}</p>}
{status?.lastError && <p style={{ color: '#e08869' }}>{status.lastError}</p>}
</div>
{status?.running && (
<div className="card">
<p>
Capturing since {status.startedAt ? new Date(status.startedAt).toLocaleTimeString() : ''} ·{' '}
{status.packets} game packets · {status.photonEvents} events · {status.marketOrdersSeen} market orders seen ·{' '}
{status.pricesStored} prices stored
</p>
{status.packets === 0 && (
<p className="muted">No packets yet — make sure Albion is running and you change zones or open a market.</p>
)}
</div>
)}
{status && status.recentOrders.length > 0 && (
<div className="card">
<h3>Recently captured market orders</h3>
<table>
<thead>
<tr><th>Item</th><th>City</th><th>Q</th><th>Type</th><th>Price</th><th>Amount</th><th>Seen</th></tr>
</thead>
<tbody>
{status.recentOrders.map((o, i) => (
<tr key={i}>
<td>{o.item}</td>
<td>{o.city}</td>
<td>{o.quality}</td>
<td className="muted">{o.auctionType === 'offer' ? 'sell order' : 'buy order'}</td>
<td>{Math.round(o.unitPriceSilver).toLocaleString()}</td>
<td>{o.amount}</td>
<td className="muted">{new Date(o.seenAt).toLocaleTimeString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{status && Object.keys(status.eventCodeCounts).length > 0 && (
<div className="card">
<h3>Event codes seen (debug)</h3>
<p className="muted">
Used to identify destiny-board events for auto-filling your character profile — open your destiny board
in game while capturing and note which codes spike.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{Object.entries(status.eventCodeCounts).map(([code, n]) => (
<span key={code} className="tag">{code}: {n}</span>
))}
</div>
</div>
)}
</div>
);
}