From 9c4a2a8ed33218675558278654f368ca2543aa3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=97=D0=B0=D0=B8=D0=B4=20=D0=9E=D0=BC=D0=B0=D1=80=20?= =?UTF-8?q?=D0=9C=D0=B5=D0=B4=D1=85=D0=B0=D1=82?= Date: Sun, 23 Aug 2026 03:32:22 +0500 Subject: [PATCH] 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 --- README.md | 46 ++++++ package-lock.json | 59 +++++++ server/package.json | 1 + server/src/index.ts | 2 + server/src/routes/sniffer.ts | 20 +++ server/src/sniffer/service.ts | 291 ++++++++++++++++++++++++++++++++++ web/src/App.tsx | 2 + web/src/pages/SnifferPage.tsx | 150 ++++++++++++++++++ 8 files changed, 571 insertions(+) create mode 100644 README.md create mode 100644 server/src/routes/sniffer.ts create mode 100644 server/src/sniffer/service.ts create mode 100644 web/src/pages/SnifferPage.tsx diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6caca0 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/package-lock.json b/package-lock.json index 98933a2..b91d4fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1639,6 +1639,15 @@ ], "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1803,6 +1812,17 @@ "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": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1977,6 +1997,12 @@ "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": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2175,6 +2201,15 @@ "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": { "version": "1.0.1", "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_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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3596,6 +3640,20 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "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": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -3877,6 +3935,7 @@ "des.js": "^1.1.0", "fast-xml-parser": "^5.11.0", "fastify": "^5.4.0", + "photon-packet-parser": "^0.0.19", "undici": "^8.10.0" }, "devDependencies": { diff --git a/server/package.json b/server/package.json index 7ff674f..5654876 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "des.js": "^1.1.0", "fast-xml-parser": "^5.11.0", "fastify": "^5.4.0", + "photon-packet-parser": "^0.0.19", "undici": "^8.10.0" }, "devDependencies": { diff --git a/server/src/index.ts b/server/src/index.ts index 3028d43..67924f3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -8,6 +8,7 @@ import { economyRoutes } from './routes/economy.js'; import { profileRoutes } from './routes/profiles.js'; import { travelRoutes } from './routes/travel.js'; import { watchRoutes } from './routes/watch.js'; +import { snifferRoutes } from './routes/sniffer.js'; import { startWatcherLoop } from './watcher/service.js'; const app = Fastify({ logger: { level: 'info' } }); @@ -22,6 +23,7 @@ await app.register(economyRoutes); await app.register(profileRoutes); await app.register(travelRoutes); await app.register(watchRoutes); +await app.register(snifferRoutes); startWatcherLoop(); await app.listen({ port: API_PORT, host: '127.0.0.1' }); diff --git a/server/src/routes/sniffer.ts b/server/src/routes/sniffer.ts new file mode 100644 index 0000000..8e2e1ac --- /dev/null +++ b/server/src/routes/sniffer.ts @@ -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 { + 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 }; + }); +} diff --git a/server/src/sniffer/service.ts b/server/src/sniffer/service.ts new file mode 100644 index 0000000..eaf4d5d --- /dev/null +++ b/server/src/sniffer/service.ts @@ -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 = { + '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; + 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(), + 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(); + 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, + }; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index eddeff7..1b2880f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,11 +7,13 @@ import OpportunitiesPage from './pages/OpportunitiesPage'; import CharacterPage from './pages/CharacterPage'; import TravelPage from './pages/TravelPage'; import WatchPage from './pages/WatchPage'; +import SnifferPage from './pages/SnifferPage'; const PAGES = [ { id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage }, { id: 'travel', label: 'Travel Planner', component: TravelPage }, { id: 'watch', label: 'Market Watch', component: WatchPage }, + { id: 'sniffer', label: 'Game Sniffer', component: SnifferPage }, { id: 'character', label: 'Character', component: CharacterPage }, { id: 'market', label: 'Market Data', component: MarketPage }, { id: 'items', label: 'Item Browser', component: ItemsPage }, diff --git a/web/src/pages/SnifferPage.tsx b/web/src/pages/SnifferPage.tsx new file mode 100644 index 0000000..57dabc5 --- /dev/null +++ b/web/src/pages/SnifferPage.tsx @@ -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; + recentOrders: CapturedOrder[]; +} + +export default function SnifferPage() { + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const pollRef = useRef(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 ( +
+

Game Sniffer

+ +
+

+ 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. +

+ {status?.tsharkPath ? ( +

Capture engine: {status.tsharkPath}

+ ) : ( +

+ Wireshark/tshark not found. Install Wireshark from wireshark.org (keep the bundled + Npcap driver checked during install), then reopen this page. +

+ )} +
+ {!status?.running ? ( + + ) : ( + + )} +
+ {error &&

{error}

} + {status?.lastError &&

{status.lastError}

} +
+ + {status?.running && ( +
+

+ 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 +

+ {status.packets === 0 && ( +

No packets yet — make sure Albion is running and you change zones or open a market.

+ )} +
+ )} + + {status && status.recentOrders.length > 0 && ( +
+

Recently captured market orders

+ + + + + + {status.recentOrders.map((o, i) => ( + + + + + + + + + + ))} + +
ItemCityQTypePriceAmountSeen
{o.item}{o.city}{o.quality}{o.auctionType === 'offer' ? 'sell order' : 'buy order'}{Math.round(o.unitPriceSilver).toLocaleString()}{o.amount}{new Date(o.seenAt).toLocaleTimeString()}
+
+ )} + + {status && Object.keys(status.eventCodeCounts).length > 0 && ( +
+

Event codes seen (debug)

+

+ 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. +

+
+ {Object.entries(status.eventCodeCounts).map(([code, n]) => ( + {code}: {n} + ))} +
+
+ )} +
+ ); +}