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; adj: Map }> { const zones = new Map(); 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(); 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 { 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(); 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 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 { 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 { 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); }