diff --git a/agent/src/api.ts b/agent/src/api.ts index 6c25dca..60c663e 100644 --- a/agent/src/api.ts +++ b/agent/src/api.ts @@ -1,29 +1,61 @@ import type { AlbionOrder, ParsedWorld } from '@albion/shared'; import type { AgentConfig } from './config.js'; -/** Thin client for the server's ingest endpoints. */ +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** Thin client for the server's ingest endpoints, with retry on transient failures. */ export class ServerClient { constructor(private cfg: AgentConfig) {} - private async post(pathname: string, body: unknown): Promise { - const res = await fetch(`${this.cfg.serverUrl}${pathname}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(this.cfg.apiKey ? { Authorization: `Bearer ${this.cfg.apiKey}` } : {}), - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(30000), - }); - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`${pathname} -> HTTP ${res.status} ${text}`); + private async post(pathname: string, body: unknown, retries = 4): Promise { + const payload = JSON.stringify(body); + let lastErr: unknown; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const res = await fetch(`${this.cfg.serverUrl}${pathname}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(this.cfg.apiKey ? { Authorization: `Bearer ${this.cfg.apiKey}` } : {}), + }, + body: payload, + signal: AbortSignal.timeout(45000), + }); + if (res.status === 401) throw new Error('401 unauthorized — check the apiKey in your agent config matches the server.'); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`${pathname} -> HTTP ${res.status} ${text}`); + } + return await res.json(); + } catch (e) { + lastErr = e; + // don't retry auth failures — the key is wrong, retrying won't help + if (e instanceof Error && e.message.startsWith('401')) throw e; + if (attempt < retries) { + const delay = Math.min(15000, 2000 * 2 ** attempt); // 2s,4s,8s,15s + console.warn(` ${pathname} attempt ${attempt + 1} failed (${e instanceof Error ? e.message : e}); retrying in ${delay / 1000}s...`); + await sleep(delay); + } + } } - return res.json(); + throw lastErr; } - ping(): Promise { - return fetch(`${this.cfg.serverUrl}/api/health`, { signal: AbortSignal.timeout(10000) }).then((r) => r.json()); + async ping(): Promise { + // a couple of quick attempts, since the server/NPM may still be warming up + let lastErr: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetch(`${this.cfg.serverUrl}/api/health`, { signal: AbortSignal.timeout(15000) }); + return await res.json(); + } catch (e) { + lastErr = e; + if (attempt < 2) await sleep(3000); + } + } + throw lastErr; } sendWorld(world: ParsedWorld): Promise { diff --git a/agent/src/index.ts b/agent/src/index.ts index 6b4cdde..789ac41 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -126,7 +126,12 @@ async function main(): Promise { } if (command === 'world') { - await uploadWorld(client, cfg); + try { + await uploadWorld(client, cfg); + } catch (e) { + console.error('World upload failed:', e instanceof Error ? e.message : e); + process.exit(1); + } return; } @@ -140,4 +145,7 @@ async function main(): Promise { process.exit(1); } -void main(); +main().catch((e) => { + console.error('Agent error:', e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/server/src/db/index.ts b/server/src/db/index.ts index 5d03608..6d70ee3 100644 --- a/server/src/db/index.ts +++ b/server/src/db/index.ts @@ -103,8 +103,13 @@ CREATE TABLE IF NOT EXISTS zones ( albion_id TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, - color TEXT NOT NULL + color TEXT NOT NULL, + pos_x DOUBLE PRECISION, + pos_y DOUBLE PRECISION ); +-- add position columns to zones tables created before this feature +ALTER TABLE zones ADD COLUMN IF NOT EXISTS pos_x DOUBLE PRECISION; +ALTER TABLE zones ADD COLUMN IF NOT EXISTS pos_y DOUBLE PRECISION; CREATE TABLE IF NOT EXISTS zone_connections ( zone_a TEXT NOT NULL, diff --git a/server/src/travel/import.ts b/server/src/travel/import.ts index 7154239..7cd3c11 100644 --- a/server/src/travel/import.ts +++ b/server/src/travel/import.ts @@ -44,12 +44,12 @@ export async function storeWorld(world: ParsedWorld, source: string): Promise [z.albionId, z.name, z.type, z.color]); + const zoneRows = world.zones.map((z) => [z.albionId, z.name, z.type, z.color, z.x, z.y]); for (let i = 0; i < zoneRows.length; i += 5000) { const chunk = zoneRows.slice(i, i + 5000); const params: unknown[] = []; const groups = chunk.map((r) => `(${r.map((v) => `$${params.push(v)}`).join(', ')})`); - await tx.query(`INSERT INTO zones (albion_id, name, type, color) VALUES ${groups.join(', ')} ON CONFLICT (albion_id) DO NOTHING`, params); + await tx.query(`INSERT INTO zones (albion_id, name, type, color, pos_x, pos_y) VALUES ${groups.join(', ')} ON CONFLICT (albion_id) DO NOTHING`, params); } const edges = [...edgeSet].map((e) => e.split('|')); for (let i = 0; i < edges.length; i += 10000) { diff --git a/server/src/travel/route.ts b/server/src/travel/route.ts index 944aa90..2fbe961 100644 --- a/server/src/travel/route.ts +++ b/server/src/travel/route.ts @@ -8,6 +8,20 @@ export interface Zone { 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'; @@ -20,7 +34,7 @@ const ROUTABLE_COLORS = new Set(['city', 'blue', 'yellow', 'red', 'black', 'othe 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 }); + 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[]) { @@ -32,9 +46,14 @@ async function loadGraph(): Promise<{ zones: Map; adj: Map ({ + ...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 }; } @@ -91,6 +114,8 @@ export async function listZones(): Promise { name: r.name, type: r.type, color: r.color, + x: r.pos_x, + y: r.pos_y, })); } diff --git a/shared/src/worldParse.ts b/shared/src/worldParse.ts index 1bda0da..2733fda 100644 --- a/shared/src/worldParse.ts +++ b/shared/src/worldParse.ts @@ -49,6 +49,9 @@ export interface WorldZone { name: string; type: string; color: string; + /** world-map coordinates (+x = East, +y = North); null if the zone has none */ + x: number | null; + y: number | null; } export interface ParsedWorld { @@ -65,9 +68,18 @@ interface RawCluster { '@id'?: string; '@displayname'?: string; '@type'?: string; + '@worldmapposition'?: string; exits?: { exit?: RawExit | RawExit[] }; } +/** Parse "x y" world-map position; returns [null, null] when absent/malformed. */ +function parsePos(raw: string | undefined): [number | null, number | null] { + if (!raw) return [null, null]; + const parts = raw.trim().split(/\s+/).map(Number); + if (parts.length < 2 || !Number.isFinite(parts[0]) || !Number.isFinite(parts[1])) return [null, null]; + return [parts[0], parts[1]]; +} + function asArray(v: T | T[] | undefined): T[] { if (v === undefined || v === null) return []; return Array.isArray(v) ? v : [v]; @@ -129,12 +141,17 @@ export function parseWorldXml(xml: string): ParsedWorld { } return { - zones: zones.map((z) => ({ - albionId: z['@id']!, - name: z['@displayname']!, - type: z['@type'] ?? '', - color: zoneColor(z['@type'] ?? ''), - })), + zones: zones.map((z) => { + const [x, y] = parsePos(z['@worldmapposition']); + return { + albionId: z['@id']!, + name: z['@displayname']!, + type: z['@type'] ?? '', + color: zoneColor(z['@type'] ?? ''), + x, + y, + }; + }), connections: [...edges].map((e) => e.split('|') as [string, string]), }; } diff --git a/web/src/apiCache.ts b/web/src/apiCache.ts new file mode 100644 index 0000000..459d13a --- /dev/null +++ b/web/src/apiCache.ts @@ -0,0 +1,55 @@ +/** + * Tiny in-memory client cache so switching tabs doesn't refetch data that's + * still fresh. Keyed by an arbitrary string; entries expire after a TTL. Pages + * call cachedJson() on mount (instant when warm) and pass force:true from a + * manual Refresh button to bypass and repopulate. + * + * Lives at module scope, so it survives component unmount/remount (tab switches) + * but resets on a full page reload. + */ +interface Entry { + at: number; + data: unknown; +} + +const store = new Map(); +const DEFAULT_TTL = 5 * 60 * 1000; // 5 minutes + +export interface CachedOptions { + ttl?: number; + force?: boolean; + init?: RequestInit; +} + +export async function cachedJson(key: string, url: string, opts: CachedOptions = {}): Promise { + const ttl = opts.ttl ?? DEFAULT_TTL; + const hit = store.get(key); + if (!opts.force && hit && Date.now() - hit.at < ttl) { + return hit.data as T; + } + const res = await fetch(url, opts.init); + if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`); + const data = (await res.json()) as T; + store.set(key, { at: Date.now(), data }); + return data; +} + +/** Peek a cached value without fetching (for instant first paint). */ +export function peek(key: string): T | undefined { + return store.get(key)?.data as T | undefined; +} + +/** How old the cached entry is, in ms (undefined if absent). */ +export function ageMs(key: string): number | undefined { + const hit = store.get(key); + return hit ? Date.now() - hit.at : undefined; +} + +/** Drop cache entries whose key starts with prefix (or everything if omitted). */ +export function invalidate(prefix?: string): void { + if (!prefix) { + store.clear(); + return; + } + for (const k of [...store.keys()]) if (k.startsWith(prefix)) store.delete(k); +} diff --git a/web/src/pages/OpportunitiesPage.tsx b/web/src/pages/OpportunitiesPage.tsx index 434f53c..346df66 100644 --- a/web/src/pages/OpportunitiesPage.tsx +++ b/web/src/pages/OpportunitiesPage.tsx @@ -163,8 +163,9 @@ export default function OpportunitiesPage() { setExpanded(expanded === o.recipeId ? null : o.recipeId)}> {o.outputName ?? o.outputItem} - {o.enchantment > 0 && .{o.enchantment}} - T{o.tier} + 0 ? `Tier ${o.tier}, enchantment ${o.enchantment}` : `Tier ${o.tier}`}> + T{o.tier}{o.enchantment > 0 ? `.${o.enchantment}` : ''} + {o.source === 'upgrade' ? 'enchant' : 'craft'} = 0 ? '#7fd18a' : '#e08869', fontWeight: 600 }}>{silver(o.profit)} @@ -180,7 +181,8 @@ export default function OpportunitiesPage() {

- Sells for {silver(o.outputPrice)} ({o.amountCrafted}× per craft) · station fee {silver(o.fee)} · + Sells in {o.city} for {silver(o.outputPrice)} ({o.amountCrafted}× per craft) · + inputs bought in {o.city} · station fee {silver(o.fee)} · {o.focusUsed > 0 ? ` uses ${Math.round(o.focusUsed)} focus ·` : ''} return rate {(o.rrr * 100).toFixed(1)}%

diff --git a/web/src/pages/TravelPage.tsx b/web/src/pages/TravelPage.tsx index b5decd5..1e2695d 100644 --- a/web/src/pages/TravelPage.tsx +++ b/web/src/pages/TravelPage.tsx @@ -7,9 +7,13 @@ interface Zone { color: string; } +interface RouteStep extends Zone { + dirToNext: string | null; +} + interface RouteResult { found: boolean; - path: Zone[]; + path: RouteStep[]; zonesTraversed: number; dangerZones: number; } @@ -166,7 +170,11 @@ export default function TravelPage() {
{route.path.map((z, i) => ( - {i > 0 && ›} + {i > 0 && ( + + {route.path[i - 1].dirToNext ? `— ${route.path[i - 1].dirToNext} →` : '›'} + + )} {z.name} ))}