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:
parent
4c16fead97
commit
9c4a2a8ed3
8 changed files with 571 additions and 0 deletions
46
README.md
Normal file
46
README.md
Normal 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
59
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
20
server/src/routes/sniffer.ts
Normal file
20
server/src/routes/sniffer.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
291
server/src/sniffer/service.ts
Normal file
291
server/src/sniffer/service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
150
web/src/pages/SnifferPage.tsx
Normal file
150
web/src/pages/SnifferPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue