Phase 5: travel planner

World map imported from the local game files (public DES key + gzip,
read-only), 485 zones / 723 portal connections, BFS router with
safe-only vs all-zones modes and danger counts, haul advisor ranking
items by trip profit per carry weight, Travel page + game-path setting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Заид Омар Медхат 2026-08-23 03:24:24 +05:00
parent 3b20b1e431
commit 5706248a6b
11 changed files with 795 additions and 4 deletions

140
package-lock.json generated
View file

@ -925,6 +925,18 @@
"node": "^22.20 || ^24.12 || >=25" "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": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "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" "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": { "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",
@ -2021,6 +2045,16 @@
"node": ">=6" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -2202,6 +2236,45 @@
], ],
"license": "BSD-3-Clause" "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": { "node_modules/fastify": {
"version": "5.12.1", "version": "5.12.1",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.1.tgz", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.1.tgz",
@ -2402,6 +2475,18 @@
"node": ">=8" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -2536,6 +2621,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/minimist": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@ -2635,6 +2726,21 @@
"wrappy": "1" "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": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@ -3228,6 +3334,21 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/supports-color": {
"version": "8.1.1", "version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@ -3687,6 +3808,21 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "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": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@ -3738,12 +3874,14 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"des.js": "^1.1.0",
"fast-xml-parser": "^5.11.0",
"fastify": "^5.4.0", "fastify": "^5.4.0",
"undici": "^8.10.0" "undici": "^8.10.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.0.0", "@types/node": "^24.13.3",
"tsx": "^4.20.0", "tsx": "^4.20.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^3.2.0" "vitest": "^3.2.0"

View file

@ -12,12 +12,14 @@
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"des.js": "^1.1.0",
"fast-xml-parser": "^5.11.0",
"fastify": "^5.4.0", "fastify": "^5.4.0",
"undici": "^8.10.0" "undici": "^8.10.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.0.0", "@types/node": "^24.13.3",
"tsx": "^4.20.0", "tsx": "^4.20.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^3.2.0" "vitest": "^3.2.0"

View file

@ -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 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 ( CREATE TABLE IF NOT EXISTS profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,

View file

@ -6,6 +6,7 @@ import { itemRoutes } from './routes/items.js';
import { marketRoutes } from './routes/market.js'; import { marketRoutes } from './routes/market.js';
import { economyRoutes } from './routes/economy.js'; import { economyRoutes } from './routes/economy.js';
import { profileRoutes } from './routes/profiles.js'; import { profileRoutes } from './routes/profiles.js';
import { travelRoutes } from './routes/travel.js';
const app = Fastify({ logger: { level: 'info' } }); const app = Fastify({ logger: { level: 'info' } });
@ -17,6 +18,7 @@ await app.register(itemRoutes);
await app.register(marketRoutes); await app.register(marketRoutes);
await app.register(economyRoutes); await app.register(economyRoutes);
await app.register(profileRoutes); await app.register(profileRoutes);
await app.register(travelRoutes);
await app.listen({ port: API_PORT, host: '127.0.0.1' }); await app.listen({ port: API_PORT, host: '127.0.0.1' });
console.log(`API listening on http://127.0.0.1:${API_PORT}`); console.log(`API listening on http://127.0.0.1:${API_PORT}`);

View file

@ -67,10 +67,15 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
albionServer: marketSummary().server, albionServer: marketSummary().server,
availableServers: Object.keys(SERVER_HOSTS), availableServers: Object.keys(SERVER_HOSTS),
proxyUrl: getMeta('proxy_url') ?? '', proxyUrl: getMeta('proxy_url') ?? '',
gamePath: getMeta('game_path') ?? '',
})); }));
app.put('/api/settings', async (req, reply) => { 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) { if (albionServer) {
try { try {
setAlbionServer(albionServer); setAlbionServer(albionServer);
@ -85,6 +90,13 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
setMeta('proxy_url', proxyUrl); 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 }; return { ok: true };
}); });
} }

View file

@ -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<void> {
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) };
});
}

181
server/src/travel/import.ts Normal file
View file

@ -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 folder>\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<T>(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<string, string>();
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<string>();
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 };
}

168
server/src/travel/route.ts Normal file
View file

@ -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<string, Zone>; adj: Map<string, string[]> } {
const db = getDb();
const zones = new Map<string, Zone>();
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<string, string[]>();
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<string, string>();
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<string, string | null>();
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);
}

View file

@ -5,9 +5,11 @@ import MarketPage from './pages/MarketPage';
import SettingsPage from './pages/SettingsPage'; import SettingsPage from './pages/SettingsPage';
import OpportunitiesPage from './pages/OpportunitiesPage'; import OpportunitiesPage from './pages/OpportunitiesPage';
import CharacterPage from './pages/CharacterPage'; import CharacterPage from './pages/CharacterPage';
import TravelPage from './pages/TravelPage';
const PAGES = [ const PAGES = [
{ id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage }, { id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage },
{ id: 'travel', label: 'Travel Planner', component: TravelPage },
{ 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

@ -4,11 +4,13 @@ interface Settings {
albionServer: string; albionServer: string;
availableServers: string[]; availableServers: string[];
proxyUrl: string; proxyUrl: string;
gamePath: string;
} }
export default function SettingsPage() { export default function SettingsPage() {
const [settings, setSettings] = useState<Settings | null>(null); const [settings, setSettings] = useState<Settings | null>(null);
const [proxyUrl, setProxyUrl] = useState(''); const [proxyUrl, setProxyUrl] = useState('');
const [gamePath, setGamePath] = useState('');
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
useEffect(() => { useEffect(() => {
@ -17,10 +19,11 @@ export default function SettingsPage() {
.then((s: Settings) => { .then((s: Settings) => {
setSettings(s); setSettings(s);
setProxyUrl(s.proxyUrl); 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', { await fetch('/api/settings', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -64,6 +67,17 @@ export default function SettingsPage() {
</div> </div>
{saved && <p style={{ color: '#7fd18a' }}>Saved.</p>} {saved && <p style={{ color: '#7fd18a' }}>Saved.</p>}
</div> </div>
<div className="card">
<h3>Albion game folder</h3>
<p className="muted">
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.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<input type="text" placeholder="E:\albion" value={gamePath} onChange={(e) => setGamePath(e.target.value)} />
<button className="primary" onClick={() => save({ gamePath })}>Save</button>
</div>
</div>
</div> </div>
); );
} }

View file

@ -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<string, string> = {
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<Status | null>(null);
const [zones, setZones] = useState<Zone[]>([]);
const [from, setFrom] = useState('Lymhurst');
const [to, setTo] = useState('Martlock');
const [mode, setMode] = useState<'safe' | 'all'>('safe');
const [route, setRoute] = useState<RouteResult | null>(null);
const [routeError, setRouteError] = useState('');
const [importing, setImporting] = useState(false);
const [importMsg, setImportMsg] = useState('');
const [maxWeight, setMaxWeight] = useState(1500);
const [haul, setHaul] = useState<HaulItem[] | null>(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 (
<div>
<h2>Travel Planner</h2>
<div className="card">
<p>
{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.'}
</p>
<p className="muted">
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.
</p>
<button className="primary" disabled={importing} onClick={doImport}>
{importing ? 'Importing…' : 'Import / refresh world map'}
</button>
{importMsg && <p>{importMsg}</p>}
</div>
<div className="card">
<h3>Route</h3>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<input type="text" list="zone-list" value={from} style={{ width: 200 }} placeholder="From zone…"
onChange={(e) => setFrom(e.target.value)} />
<span className="muted">→</span>
<input type="text" list="zone-list" value={to} style={{ width: 200 }} placeholder="To zone…"
onChange={(e) => setTo(e.target.value)} />
<datalist id="zone-list">
{zoneNames.map((n) => <option key={n} value={n} />)}
</datalist>
<label>
<input type="radio" checked={mode === 'safe'} onChange={() => setMode('safe')} /> safe zones only
</label>
<label>
<input type="radio" checked={mode === 'all'} onChange={() => setMode('all')} /> all zones (shortest)
</label>
<button className="primary" onClick={findRoute}>Find route</button>
</div>
{routeError && <p style={{ color: '#e08869' }}>{routeError}</p>}
{route && !route.found && (
<p style={{ color: '#e08869' }}>
No route found{mode === 'safe' ? ' through safe zones — try "all zones" (this destination may require red/black zones)' : ''}.
</p>
)}
{route?.found && (
<>
<p>
{route.zonesTraversed} zone transitions ·{' '}
{route.dangerZones > 0
? <span style={{ color: '#e08869' }}>{route.dangerZones} dangerous (red/black) zones — full-loot PvP!</span>
: <span style={{ color: '#7fd18a' }}>fully safe</span>}
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
{route.path.map((z, i) => (
<span key={z.albionId} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{i > 0 && <span className="muted">›</span>}
<span className="tag" style={{ color: ZONE_COLORS[z.color] ?? undefined, fontSize: 13 }}>{z.name}</span>
</span>
))}
</div>
</>
)}
</div>
<div className="card">
<h3>What should I haul {from} → {to}?</h3>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<label>
carry weight{' '}
<input type="number" value={maxWeight} style={{ width: 90 }} onChange={(e) => setMaxWeight(Number(e.target.value))} />
</label>
<span className="muted">(your max load in kg — mount included)</span>
<button className="primary" disabled={haulLoading} onClick={findHaul}>
{haulLoading ? 'Computing…' : 'Rank items'}
</button>
<span className="muted">Uses downloaded market prices for both cities.</span>
</div>
{haul && haul.length === 0 && (
<p className="muted">No profitable hauls found — check that prices are downloaded for both cities.</p>
)}
{haul && haul.length > 0 && (
<table style={{ marginTop: 10 }}>
<thead>
<tr>
<th>Item</th><th>Buy at {from}</th><th>Sell at {to}</th><th>Net/unit</th><th>Units</th><th>Trip profit</th><th>Profit/kg</th>
</tr>
</thead>
<tbody>
{haul.map((h) => (
<tr key={h.item}>
<td>{h.name ?? h.item} <span className="tag">T{h.tier}</span></td>
<td>{silver(h.buyAt)}</td>
<td>{silver(h.sellAt)}</td>
<td>{silver(h.netPerUnit)}</td>
<td>{h.unitsCarryable}</td>
<td style={{ color: '#7fd18a', fontWeight: 600 }}>{silver(h.tripProfit)}</td>
<td>{silver(h.profitPerWeight)}</td>
</tr>
))}
</tbody>
</table>
)}
<p className="muted" style={{ marginBottom: 0 }}>
Suggested cities: {CITIES.join(', ')}
</p>
</div>
</div>
);
}