albion/server/src/travel/route.ts
Заид Омар Медхат 7ed585d6a9
All checks were successful
Deploy / deploy (push) Successful in 1m42s
Show ingredient tier/enchant + price age in haul advisor
- Opportunities breakdown now shows each ingredient's tier/enchant
  (e.g. T6.4) so enchant mismatches are visible at a glance.
- Travel haul advisor now shows color-coded price-age badges (green
  fresh / yellow aging / red stale) for both buy and sell prices, like
  Opportunities already does.

Note: the enchanted-ingredient pricing itself was already correct in
code (T6_SHOES_PLATE_SET1@4); a deployed DB parsed before that fix must
re-run Game Data -> Re-download to pick it up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 05:33:52 +05:00

192 lines
7 KiB
TypeScript

import { get, getMeta, query } 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;
x: number | null;
y: number | null;
}
/** 8-point compass bearing from A to B using map coords (+x East, +y North). */
const COMPASS = ['E', 'NE', 'N', 'NW', 'W', 'SW', 'S', 'SE'];
export function direction(a: Zone, b: Zone): string | null {
if (a.x == null || a.y == null || b.x == null || b.y == null) return null;
const dx = b.x - a.x;
const dy = b.y - a.y;
if (dx === 0 && dy === 0) return null;
const angle = (Math.atan2(dy, dx) * 180) / Math.PI; // 0 = East, 90 = North
const idx = ((Math.round(angle / 45) % 8) + 8) % 8;
return COMPASS[idx];
}
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']);
async function loadGraph(): Promise<{ zones: Map<string, Zone>; adj: Map<string, string[]> }> {
const zones = new Map<string, Zone>();
for (const r of (await query('SELECT * FROM zones')) as any[]) {
zones.set(r.albion_id, { albionId: r.albion_id, name: r.name, type: r.type, color: r.color, x: r.pos_x, y: r.pos_y });
}
const adj = new Map<string, string[]>();
for (const r of (await query('SELECT * FROM zone_connections')) 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 RouteStep extends Zone {
/** compass direction of the NEXT hop from this zone (null on the final zone) */
dirToNext: string | null;
}
export interface RouteResult {
found: boolean;
path: RouteStep[];
zonesTraversed: number;
dangerZones: number; // red + black on the path
}
export async function findRoute(fromName: string, toName: string, mode: TravelMode): Promise<RouteResult | { error: string }> {
const { zones, adj } = await 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 zonePath: Zone[] = [];
for (let cur: string | null = goal; cur != null; cur = prev.get(cur) ?? null) {
zonePath.unshift(zones.get(cur)!);
}
const path: RouteStep[] = zonePath.map((z, i) => ({
...z,
dirToNext: i < zonePath.length - 1 ? direction(z, zonePath[i + 1]) : null,
}));
const dangerZones = path.filter((z) => z.color === 'red' || z.color === 'black').length;
return { found: true, path, zonesTraversed: path.length - 1, dangerZones };
}
export async function listZones(): Promise<Zone[]> {
return ((await query('SELECT * FROM zones ORDER BY name')) as any[]).map((r) => ({
albionId: r.albion_id,
name: r.name,
type: r.type,
color: r.color,
x: r.pos_x,
y: r.pos_y,
}));
}
export async function travelStatus() {
const zoneCount = (await get<{ n: number }>('SELECT COUNT(*) AS n FROM zones'))!.n;
const connCount = zoneCount > 0 ? (await get<{ n: number }>('SELECT COUNT(*) AS n FROM zone_connections'))!.n : 0;
return {
zones: zoneCount,
connections: connCount,
importedAt: await getMeta('world_imported_at'),
worldSource: await getMeta('world_source'),
gamePath: await 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
buyAgeHours: number | null; // how stale the origin price is
sellAgeHours: number | null; // how stale the destination price is
netPerUnit: number; // after taxes at destination
unitsCarryable: number;
tripProfit: number;
profitPerWeight: number;
}
export async function haulAdvisor(from: string, to: string, maxWeight: number, minTier = 2, maxTier = 8): Promise<HaulItem[]> {
const cfg = await getEconomyConfig();
const premium = (await getActiveProfile())?.premium ?? true;
const tax = premium ? cfg.salesTaxPremium : cfg.salesTaxNoPremium;
const feeRate = tax + cfg.listingFee;
const origin = await buildProvider(from);
const dest = await buildProvider(to);
const items = (await query(
'SELECT unique_name, name_en, tier, weight FROM items WHERE weight IS NOT NULL AND weight > 0 AND tier BETWEEN ? AND ?',
[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,
buyAgeHours: buy.ageHours,
sellAgeHours: sell.ageHours,
netPerUnit,
unitsCarryable,
tripProfit: netPerUnit * unitsCarryable,
profitPerWeight: netPerUnit / it.weight,
});
}
results.sort((a, b) => b.tripProfit - a.tripProfit);
return results.slice(0, 50);
}