All checks were successful
Deploy / deploy (push) Successful in 1m42s
- 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>
192 lines
7 KiB
TypeScript
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);
|
|
}
|