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:
parent
3b20b1e431
commit
5706248a6b
11 changed files with 795 additions and 4 deletions
140
package-lock.json
generated
140
package-lock.json
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
server/src/routes/travel.ts
Normal file
37
server/src/routes/travel.ts
Normal 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
181
server/src/travel/import.ts
Normal 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
168
server/src/travel/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
222
web/src/pages/TravelPage.tsx
Normal file
222
web/src/pages/TravelPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue