diff --git a/package-lock.json b/package-lock.json index b8bacc0..98933a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -925,6 +925,18 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1615,6 +1627,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2021,6 +2045,16 @@ "node": ">=6" } }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2202,6 +2236,45 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.0.tgz", + "integrity": "sha512-9IGxMqvqLOnqP+Egi1nqDHKv5k8aZ7r9n558enxcucmyVGEBNPAU+MOg/8jPIS7rO7sSq4gFm1/nHtiaubMruw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastify": { "version": "5.12.1", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.1.tgz", @@ -2402,6 +2475,18 @@ "node": ">=8" } }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2536,6 +2621,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -2635,6 +2726,21 @@ "wrappy": "1" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3228,6 +3334,21 @@ "dev": true, "license": "MIT" }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -3687,6 +3808,21 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -3738,12 +3874,14 @@ "version": "0.1.0", "dependencies": { "better-sqlite3": "^12.2.0", + "des.js": "^1.1.0", + "fast-xml-parser": "^5.11.0", "fastify": "^5.4.0", "undici": "^8.10.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", - "@types/node": "^24.0.0", + "@types/node": "^24.13.3", "tsx": "^4.20.0", "typescript": "^5.8.0", "vitest": "^3.2.0" diff --git a/server/package.json b/server/package.json index 25b8278..7ff674f 100644 --- a/server/package.json +++ b/server/package.json @@ -12,12 +12,14 @@ }, "dependencies": { "better-sqlite3": "^12.2.0", + "des.js": "^1.1.0", + "fast-xml-parser": "^5.11.0", "fastify": "^5.4.0", "undici": "^8.10.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", - "@types/node": "^24.0.0", + "@types/node": "^24.13.3", "tsx": "^4.20.0", "typescript": "^5.8.0", "vitest": "^3.2.0" diff --git a/server/src/db/index.ts b/server/src/db/index.ts index f5625fb..c343654 100644 --- a/server/src/db/index.ts +++ b/server/src/db/index.ts @@ -57,6 +57,19 @@ CREATE TABLE IF NOT EXISTS market_prices ( ); CREATE INDEX IF NOT EXISTS idx_market_prices_city ON market_prices(city); +CREATE TABLE IF NOT EXISTS zones ( + albion_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + color TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS zone_connections ( + zone_a TEXT NOT NULL, + zone_b TEXT NOT NULL, + PRIMARY KEY (zone_a, zone_b) +); + CREATE TABLE IF NOT EXISTS profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, diff --git a/server/src/index.ts b/server/src/index.ts index a443bbd..adede02 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,6 +6,7 @@ import { itemRoutes } from './routes/items.js'; import { marketRoutes } from './routes/market.js'; import { economyRoutes } from './routes/economy.js'; import { profileRoutes } from './routes/profiles.js'; +import { travelRoutes } from './routes/travel.js'; const app = Fastify({ logger: { level: 'info' } }); @@ -17,6 +18,7 @@ await app.register(itemRoutes); await app.register(marketRoutes); await app.register(economyRoutes); await app.register(profileRoutes); +await app.register(travelRoutes); await app.listen({ port: API_PORT, host: '127.0.0.1' }); console.log(`API listening on http://127.0.0.1:${API_PORT}`); diff --git a/server/src/routes/market.ts b/server/src/routes/market.ts index d0bacce..094df6d 100644 --- a/server/src/routes/market.ts +++ b/server/src/routes/market.ts @@ -67,10 +67,15 @@ export async function marketRoutes(app: FastifyInstance): Promise { albionServer: marketSummary().server, availableServers: Object.keys(SERVER_HOSTS), proxyUrl: getMeta('proxy_url') ?? '', + gamePath: getMeta('game_path') ?? '', })); app.put('/api/settings', async (req, reply) => { - const { albionServer, proxyUrl } = (req.body ?? {}) as { albionServer?: string; proxyUrl?: string }; + const { albionServer, proxyUrl, gamePath } = (req.body ?? {}) as { + albionServer?: string; + proxyUrl?: string; + gamePath?: string; + }; if (albionServer) { try { setAlbionServer(albionServer); @@ -85,6 +90,13 @@ export async function marketRoutes(app: FastifyInstance): Promise { setMeta('proxy_url', proxyUrl); } } + if (gamePath !== undefined) { + if (gamePath === '') { + getDb().prepare("DELETE FROM meta WHERE key = 'game_path'").run(); + } else { + setMeta('game_path', gamePath); + } + } return { ok: true }; }); } diff --git a/server/src/routes/travel.ts b/server/src/routes/travel.ts new file mode 100644 index 0000000..534acb5 --- /dev/null +++ b/server/src/routes/travel.ts @@ -0,0 +1,37 @@ +import type { FastifyInstance } from 'fastify'; +import { importWorld } from '../travel/import.js'; +import { findRoute, haulAdvisor, listZones, travelStatus, type TravelMode } from '../travel/route.js'; + +export async function travelRoutes(app: FastifyInstance): Promise { + app.get('/api/travel/status', async () => travelStatus()); + + app.post('/api/travel/import', async (_req, reply) => { + try { + return importWorld(); + } catch (e) { + return reply.code(400).send({ error: e instanceof Error ? e.message : String(e) }); + } + }); + + app.get('/api/travel/zones', async () => ({ zones: listZones() })); + + app.post('/api/travel/route', async (req, reply) => { + const { from, to, mode } = (req.body ?? {}) as { from?: string; to?: string; mode?: TravelMode }; + if (!from || !to) return reply.code(400).send({ error: 'from and to are required' }); + const result = findRoute(from, to, mode === 'all' ? 'all' : 'safe'); + if ('error' in result) return reply.code(400).send(result); + return result; + }); + + app.post('/api/travel/haul', async (req, reply) => { + const { from, to, maxWeight, minTier, maxTier } = (req.body ?? {}) as { + from?: string; + to?: string; + maxWeight?: number; + minTier?: number; + maxTier?: number; + }; + if (!from || !to) return reply.code(400).send({ error: 'from and to are required' }); + return { items: haulAdvisor(from, to, maxWeight ?? 1500, minTier ?? 2, maxTier ?? 8) }; + }); +} diff --git a/server/src/travel/import.ts b/server/src/travel/import.ts new file mode 100644 index 0000000..0fef97d --- /dev/null +++ b/server/src/travel/import.ts @@ -0,0 +1,181 @@ +// @ts-expect-error des.js ships no types; it's a small pure-JS DES used because +// OpenSSL 3 (Node 17+) removed legacy single-DES from node:crypto +import des from 'des.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { XMLParser } from 'fast-xml-parser'; +import { getDb, getMeta, setMeta } from '../db/index.js'; + +/** + * The world map (zones + portals) comes from the game's own files: + * \game\Albion-Online_Data\StreamingAssets\GameData\cluster\world.bin + * .bin files are DES-encrypted, gzip-compressed XML. The key/IV are public + * knowledge (ao-id-extractor, Portaler, Statistics Analysis Tool all use them); + * we only read the file, never modify the game. + */ +const BIN_KEY = Buffer.from([48, 239, 114, 71, 66, 242, 4, 50]); +const BIN_IV = Buffer.from([14, 166, 220, 137, 219, 237, 220, 79]); + +export function decryptBin(encrypted: Buffer): Buffer { + const decipher = des.CBC.instantiate(des.DES).create({ type: 'decrypt', key: BIN_KEY, iv: BIN_IV }); + let compressed = Buffer.from([...decipher.update(encrypted), ...decipher.final()]); + // strip PKCS7 padding (des.js does not remove it) + const pad = compressed[compressed.length - 1]; + if (pad >= 1 && pad <= 8) compressed = compressed.subarray(0, compressed.length - pad); + return zlib.gunzipSync(compressed); +} + +const WORLD_BIN_SUBPATH = path.join('game', 'Albion-Online_Data', 'StreamingAssets', 'GameData', 'cluster', 'world.bin'); + +const COMMON_GAME_DIRS = [ + 'E:\\albion', + 'C:\\Program Files (x86)\\Albion Online', + 'C:\\Program Files (x86)\\AlbionOnline', + 'C:\\Program Files (x86)\\Steam\\steamapps\\common\\Albion Online', +]; + +export function findWorldBin(): string | null { + const configured = getMeta('game_path'); + const candidates = configured ? [configured, ...COMMON_GAME_DIRS] : COMMON_GAME_DIRS; + for (const dir of candidates) { + const direct = path.join(dir, WORLD_BIN_SUBPATH); + if (fs.existsSync(direct)) return direct; + // allow pointing straight at .../GameData or the cluster dir or the bin itself + const alternatives = [dir, path.join(dir, 'cluster', 'world.bin'), path.join(dir, 'world.bin')]; + for (const alt of alternatives) { + if (alt.endsWith('world.bin') && fs.existsSync(alt)) return alt; + } + } + return null; +} + +export function zoneColor(type: string): string { + const t = type.toUpperCase(); + if (t.startsWith('PLAYERCITY')) return 'city'; + if (t.startsWith('SAFEAREA')) return 'blue'; + if (t.includes('YELLOW')) return 'yellow'; + if (t.includes('RED')) return 'red'; + if (t.includes('BLACK')) return 'black'; + if (t.startsWith('TUNNEL')) return 'road'; + return 'other'; +} + +interface RawExit { + '@id'?: string; + '@targetid'?: string; +} + +interface RawCluster { + '@id'?: string; + '@displayname'?: string; + '@type'?: string; + exits?: { exit?: RawExit | RawExit[] }; +} + +function asArray(v: T | T[] | undefined): T[] { + if (v === undefined || v === null) return []; + return Array.isArray(v) ? v : [v]; +} + +export interface WorldImportStats { + zones: number; + connections: number; + worldBinPath: string; +} + +export function importWorld(): WorldImportStats { + const binPath = findWorldBin(); + if (!binPath) { + throw new Error( + 'world.bin not found. Set your Albion Online game folder in Settings ' + + '(the folder that contains the "game" directory).', + ); + } + + const xml = decryptBin(fs.readFileSync(binPath)).toString('utf8'); + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@', + isArray: (name) => name === 'cluster' || name === 'exit', + }); + const doc = parser.parse(xml); + const clusters: RawCluster[] = doc?.world?.clusters?.cluster ?? []; + if (clusters.length === 0) throw new Error('no clusters found in world.bin — format may have changed'); + + // Same filtering approach as Portaler: overworld zones only. Roads of Avalon + // (TUNNEL_*) have dynamic connections, so they are excluded from routing. + const zones = clusters.filter((c) => { + const type = (c['@type'] ?? '').toUpperCase(); + const name = c['@displayname'] ?? ''; + const id = c['@id'] ?? ''; + return ( + (type.startsWith('OPENPVP') || type.startsWith('SAFEAREA') || type.startsWith('PLAYERCITY') || type.startsWith('PASSAGE')) && + !!id && + !!name && + !name.toLowerCase().includes('debug') && + !type.includes('DUNGEON_') && + !type.includes('EXPEDITION_') && + !type.includes('ARENA_') && + !type.includes('_NOFURNITURE') && + type !== 'PASSAGE_SAFEAREA' + ); + }); + + const zoneIds = new Set(zones.map((z) => z['@id']!)); + + // Exits reference each other by GUID: exit X's targetid is the GUID of the + // matching exit in the neighboring zone. Map every exit GUID to its zone, + // then resolve each targetid to the neighbor. + const exitOwner = new Map(); + for (const z of zones) { + for (const e of asArray(z.exits?.exit)) { + const id = e['@id']; + if (id) exitOwner.set(String(id).slice(0, 36), z['@id']!); + } + } + + const edges = new Set(); + for (const z of zones) { + for (const e of asArray(z.exits?.exit)) { + const targetId = e['@targetid']; + if (!targetId) continue; + const neighbor = exitOwner.get(String(targetId).slice(0, 36)); + if (!neighbor || neighbor === z['@id'] || !zoneIds.has(neighbor)) continue; + const [a, b] = [z['@id']!, neighbor].sort(); + edges.add(`${a}|${b}`); + } + } + + const db = getDb(); + db.exec(` + CREATE TABLE IF NOT EXISTS zones ( + albion_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + color TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS zone_connections ( + zone_a TEXT NOT NULL, + zone_b TEXT NOT NULL, + PRIMARY KEY (zone_a, zone_b) + ); + `); + + const insertZone = db.prepare('INSERT OR REPLACE INTO zones (albion_id, name, type, color) VALUES (?, ?, ?, ?)'); + const insertEdge = db.prepare('INSERT OR REPLACE INTO zone_connections (zone_a, zone_b) VALUES (?, ?)'); + db.transaction(() => { + db.exec('DELETE FROM zone_connections; DELETE FROM zones;'); + for (const z of zones) { + insertZone.run(z['@id'], z['@displayname'], z['@type'], zoneColor(z['@type'] ?? '')); + } + for (const edge of edges) { + const [a, b] = edge.split('|'); + insertEdge.run(a, b); + } + })(); + + setMeta('world_imported_at', new Date().toISOString()); + setMeta('world_bin_path', binPath); + return { zones: zones.length, connections: edges.size, worldBinPath: binPath }; +} diff --git a/server/src/travel/route.ts b/server/src/travel/route.ts new file mode 100644 index 0000000..b53217a --- /dev/null +++ b/server/src/travel/route.ts @@ -0,0 +1,168 @@ +import { getDb, getMeta } from '../db/index.js'; +import { getActiveProfile } from '../character/service.js'; +import { getEconomyConfig } from '../economy/config.js'; +import { buildProvider } from '../economy/service.js'; + +export interface Zone { + albionId: string; + name: string; + type: string; + color: string; +} + +export type TravelMode = 'safe' | 'all'; + +/** Colors passable in safe mode. Yellow zones can't kill you (knockdown only). */ +const SAFE_COLORS = new Set(['city', 'blue', 'yellow']); +/** Roads of Avalon connections are dynamic; never route through them. */ +const ROUTABLE_COLORS = new Set(['city', 'blue', 'yellow', 'red', 'black', 'other']); + +function loadGraph(): { zones: Map; adj: Map } { + const db = getDb(); + const zones = new Map(); + for (const r of db.prepare('SELECT * FROM zones').all() as any[]) { + zones.set(r.albion_id, { albionId: r.albion_id, name: r.name, type: r.type, color: r.color }); + } + const adj = new Map(); + for (const r of db.prepare('SELECT * FROM zone_connections').all() as any[]) { + if (!adj.has(r.zone_a)) adj.set(r.zone_a, []); + if (!adj.has(r.zone_b)) adj.set(r.zone_b, []); + adj.get(r.zone_a)!.push(r.zone_b); + adj.get(r.zone_b)!.push(r.zone_a); + } + return { zones, adj }; +} + +export interface RouteResult { + found: boolean; + path: Zone[]; + zonesTraversed: number; + dangerZones: number; // red + black on the path +} + +export function findRoute(fromName: string, toName: string, mode: TravelMode): RouteResult | { error: string } { + const { zones, adj } = loadGraph(); + if (zones.size === 0) return { error: 'world map not imported — run the import on the Travel page' }; + + const byName = new Map(); + for (const z of zones.values()) byName.set(z.name.toLowerCase(), z.albionId); + const start = byName.get(fromName.toLowerCase()); + const goal = byName.get(toName.toLowerCase()); + if (!start) return { error: `unknown zone: ${fromName}` }; + if (!goal) return { error: `unknown zone: ${toName}` }; + + const passable = (id: string): boolean => { + const z = zones.get(id); + if (!z) return false; + if (!ROUTABLE_COLORS.has(z.color)) return false; + return mode === 'all' || SAFE_COLORS.has(z.color); + }; + + // BFS: every zone transition costs one loading screen, so fewest zones = fastest + const prev = new Map(); + prev.set(start, null); + let frontier = [start]; + while (frontier.length > 0 && !prev.has(goal)) { + const next: string[] = []; + for (const cur of frontier) { + for (const nb of adj.get(cur) ?? []) { + if (prev.has(nb)) continue; + // start/goal are always allowed (you can leave a red zone you're in) + if (nb !== goal && !passable(nb)) continue; + prev.set(nb, cur); + next.push(nb); + } + } + frontier = next; + } + + if (!prev.has(goal)) return { found: false, path: [], zonesTraversed: 0, dangerZones: 0 }; + + const path: Zone[] = []; + for (let cur: string | null = goal; cur != null; cur = prev.get(cur) ?? null) { + path.unshift(zones.get(cur)!); + } + const dangerZones = path.filter((z) => z.color === 'red' || z.color === 'black').length; + return { found: true, path, zonesTraversed: path.length - 1, dangerZones }; +} + +export function listZones(): Zone[] { + return (getDb().prepare('SELECT * FROM zones ORDER BY name').all() as any[]).map((r) => ({ + albionId: r.albion_id, + name: r.name, + type: r.type, + color: r.color, + })); +} + +export function travelStatus() { + const db = getDb(); + const hasTables = db + .prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='zones'") + .get() as { n: number }; + const zoneCount = hasTables.n > 0 ? (db.prepare('SELECT COUNT(*) AS n FROM zones').get() as { n: number }).n : 0; + const connCount = + hasTables.n > 0 && zoneCount > 0 ? (db.prepare('SELECT COUNT(*) AS n FROM zone_connections').get() as { n: number }).n : 0; + return { + zones: zoneCount, + connections: connCount, + importedAt: getMeta('world_imported_at'), + worldBinPath: getMeta('world_bin_path'), + gamePath: getMeta('game_path'), + }; +} + +// --------------------------------------------------------------------------- +// Haul advisor: what is worth carrying from city A to city B? + +export interface HaulItem { + item: string; + name: string | null; + tier: number | null; + weight: number; + buyAt: number; // sell-order price in origin (you buy instantly) + sellAt: number; // sell-order price in destination + netPerUnit: number; // after taxes at destination + unitsCarryable: number; + tripProfit: number; + profitPerWeight: number; +} + +export function haulAdvisor(from: string, to: string, maxWeight: number, minTier = 2, maxTier = 8): HaulItem[] { + const cfg = getEconomyConfig(); + const premium = getActiveProfile()?.premium ?? true; + const tax = premium ? cfg.salesTaxPremium : cfg.salesTaxNoPremium; + const feeRate = tax + cfg.listingFee; + + const origin = buildProvider(from); + const dest = buildProvider(to); + + const items = getDb() + .prepare('SELECT unique_name, name_en, tier, weight FROM items WHERE weight IS NOT NULL AND weight > 0 AND tier BETWEEN ? AND ?') + .all(minTier, maxTier) as { unique_name: string; name_en: string | null; tier: number | null; weight: number }[]; + + const results: HaulItem[] = []; + for (const it of items) { + const buy = origin.sellMin(it.unique_name, from); + const sell = dest.sellMin(it.unique_name, to); + if (!buy || !sell) continue; + const netPerUnit = sell.price * (1 - feeRate) - buy.price; + if (netPerUnit <= 0) continue; + const unitsCarryable = Math.floor(maxWeight / it.weight); + if (unitsCarryable === 0) continue; + results.push({ + item: it.unique_name, + name: it.name_en, + tier: it.tier, + weight: it.weight, + buyAt: buy.price, + sellAt: sell.price, + netPerUnit, + unitsCarryable, + tripProfit: netPerUnit * unitsCarryable, + profitPerWeight: netPerUnit / it.weight, + }); + } + results.sort((a, b) => b.tripProfit - a.tripProfit); + return results.slice(0, 50); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 63eb8e5..2ee75c8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,9 +5,11 @@ import MarketPage from './pages/MarketPage'; import SettingsPage from './pages/SettingsPage'; import OpportunitiesPage from './pages/OpportunitiesPage'; import CharacterPage from './pages/CharacterPage'; +import TravelPage from './pages/TravelPage'; const PAGES = [ { id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage }, + { id: 'travel', label: 'Travel Planner', component: TravelPage }, { 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/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index bbd15e7..fcb8bec 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -4,11 +4,13 @@ interface Settings { albionServer: string; availableServers: string[]; proxyUrl: string; + gamePath: string; } export default function SettingsPage() { const [settings, setSettings] = useState(null); const [proxyUrl, setProxyUrl] = useState(''); + const [gamePath, setGamePath] = useState(''); const [saved, setSaved] = useState(false); useEffect(() => { @@ -17,10 +19,11 @@ export default function SettingsPage() { .then((s: Settings) => { setSettings(s); setProxyUrl(s.proxyUrl); + setGamePath(s.gamePath); }); }, []); - const save = async (patch: Partial<{ albionServer: string; proxyUrl: string }>) => { + const save = async (patch: Partial<{ albionServer: string; proxyUrl: string; gamePath: string }>) => { await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -64,6 +67,17 @@ export default function SettingsPage() { {saved &&

Saved.

} +
+

Albion game folder

+

+ Used to read the world map (travel planner) from the game's files — read-only. The folder that contains the + "game" directory, e.g. E:\albion. +

+
+ setGamePath(e.target.value)} /> + +
+
); } diff --git a/web/src/pages/TravelPage.tsx b/web/src/pages/TravelPage.tsx new file mode 100644 index 0000000..b5decd5 --- /dev/null +++ b/web/src/pages/TravelPage.tsx @@ -0,0 +1,222 @@ +import { useEffect, useMemo, useState } from 'react'; + +interface Zone { + albionId: string; + name: string; + type: string; + color: string; +} + +interface RouteResult { + found: boolean; + path: Zone[]; + zonesTraversed: number; + dangerZones: number; +} + +interface HaulItem { + item: string; + name: string | null; + tier: number | null; + weight: number; + buyAt: number; + sellAt: number; + netPerUnit: number; + unitsCarryable: number; + tripProfit: number; + profitPerWeight: number; +} + +interface Status { + zones: number; + connections: number; + importedAt: string | null; +} + +const ZONE_COLORS: Record = { + city: '#d4a94e', + blue: '#6aa7e8', + yellow: '#e0c069', + red: '#e08869', + black: '#b07fd1', + other: '#9aa3b2', +}; + +const CITIES = ['Bridgewatch', 'Caerleon', 'Fort Sterling', 'Lymhurst', 'Martlock', 'Thetford', 'Brecilien']; +const silver = (n: number) => Math.round(n).toLocaleString('en-US'); + +export default function TravelPage() { + const [status, setStatus] = useState(null); + const [zones, setZones] = useState([]); + const [from, setFrom] = useState('Lymhurst'); + const [to, setTo] = useState('Martlock'); + const [mode, setMode] = useState<'safe' | 'all'>('safe'); + const [route, setRoute] = useState(null); + const [routeError, setRouteError] = useState(''); + const [importing, setImporting] = useState(false); + const [importMsg, setImportMsg] = useState(''); + const [maxWeight, setMaxWeight] = useState(1500); + const [haul, setHaul] = useState(null); + const [haulLoading, setHaulLoading] = useState(false); + + const load = async () => { + setStatus(await fetch('/api/travel/status').then((r) => r.json())); + const z = await fetch('/api/travel/zones').then((r) => r.json()); + setZones(z.zones ?? []); + }; + useEffect(() => { + load(); + }, []); + + const doImport = async () => { + setImporting(true); + setImportMsg(''); + try { + const res = await fetch('/api/travel/import', { method: 'POST' }); + const d = await res.json(); + setImportMsg(res.ok ? `Imported ${d.zones} zones, ${d.connections} connections.` : d.error); + await load(); + } finally { + setImporting(false); + } + }; + + const findRoute = async () => { + setRoute(null); + setRouteError(''); + const res = await fetch('/api/travel/route', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from, to, mode }), + }); + const d = await res.json(); + if (!res.ok) setRouteError(d.error); + else setRoute(d); + }; + + const findHaul = async () => { + setHaulLoading(true); + try { + const d = await fetch('/api/travel/haul', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from, to, maxWeight }), + }).then((r) => r.json()); + setHaul(d.items ?? []); + } finally { + setHaulLoading(false); + } + }; + + const zoneNames = useMemo(() => zones.map((z) => z.name), [zones]); + + return ( +
+

Travel Planner

+ +
+

+ {status && status.zones > 0 + ? `World map: ${status.zones} zones, ${status.connections} connections (imported ${status.importedAt ? new Date(status.importedAt).toLocaleString() : ''})` + : 'World map not imported yet.'} +

+

+ Zone connections are read from your Albion installation's world map file (read-only). Re-import after + world-changing patches. If import fails, set your game folder on the Settings page. +

+ + {importMsg &&

{importMsg}

} +
+ +
+

Route

+
+ setFrom(e.target.value)} /> + → + setTo(e.target.value)} /> + + {zoneNames.map((n) => + + + +
+ {routeError &&

{routeError}

} + {route && !route.found && ( +

+ No route found{mode === 'safe' ? ' through safe zones — try "all zones" (this destination may require red/black zones)' : ''}. +

+ )} + {route?.found && ( + <> +

+ {route.zonesTraversed} zone transitions ·{' '} + {route.dangerZones > 0 + ? {route.dangerZones} dangerous (red/black) zones — full-loot PvP! + : fully safe} +

+
+ {route.path.map((z, i) => ( + + {i > 0 && ›} + {z.name} + + ))} +
+ + )} +
+ +
+

What should I haul {from} → {to}?

+
+ + (your max load in kg — mount included) + + Uses downloaded market prices for both cities. +
+ {haul && haul.length === 0 && ( +

No profitable hauls found — check that prices are downloaded for both cities.

+ )} + {haul && haul.length > 0 && ( + + + + + + + + {haul.map((h) => ( + + + + + + + + + + ))} + +
ItemBuy at {from}Sell at {to}Net/unitUnitsTrip profitProfit/kg
{h.name ?? h.item} T{h.tier}{silver(h.buyAt)}{silver(h.sellAt)}{silver(h.netPerUnit)}{h.unitsCarryable}{silver(h.tripProfit)}{silver(h.profitPerWeight)}
+ )} +

+ Suggested cities: {CITIES.join(', ')} +

+
+
+ ); +}