Travel directions, agent resilience, opportunities clarity

- Travel planner shows the compass bearing (N/NE/.../NW) of each hop,
  computed from world-map coordinates (+x East, +y North, calibrated
  against Bridgewatch=south, Lymhurst=east). Zone positions parsed from
  world.bin into new zones.pos_x/pos_y columns.
- Agent: retry ingest POSTs with backoff + longer timeout, graceful
  failure (no more uncaught crash on a transient connect timeout).
- Opportunities: state the city prices apply to in the breakdown, and
  show tier+enchant as one "T4.3" chip with a tooltip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Заид Омар Медхат 2026-08-23 04:53:28 +05:00
parent 5d629d1641
commit 742b7c2085
9 changed files with 189 additions and 37 deletions

View file

@ -1,29 +1,61 @@
import type { AlbionOrder, ParsedWorld } from '@albion/shared'; import type { AlbionOrder, ParsedWorld } from '@albion/shared';
import type { AgentConfig } from './config.js'; import type { AgentConfig } from './config.js';
/** Thin client for the server's ingest endpoints. */ function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
/** Thin client for the server's ingest endpoints, with retry on transient failures. */
export class ServerClient { export class ServerClient {
constructor(private cfg: AgentConfig) {} constructor(private cfg: AgentConfig) {}
private async post(pathname: string, body: unknown): Promise<any> { private async post(pathname: string, body: unknown, retries = 4): Promise<any> {
const payload = JSON.stringify(body);
let lastErr: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(`${this.cfg.serverUrl}${pathname}`, { const res = await fetch(`${this.cfg.serverUrl}${pathname}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(this.cfg.apiKey ? { Authorization: `Bearer ${this.cfg.apiKey}` } : {}), ...(this.cfg.apiKey ? { Authorization: `Bearer ${this.cfg.apiKey}` } : {}),
}, },
body: JSON.stringify(body), body: payload,
signal: AbortSignal.timeout(30000), 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) { if (!res.ok) {
const text = await res.text().catch(() => ''); const text = await res.text().catch(() => '');
throw new Error(`${pathname} -> HTTP ${res.status} ${text}`); throw new Error(`${pathname} -> HTTP ${res.status} ${text}`);
} }
return res.json(); 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);
}
}
}
throw lastErr;
} }
ping(): Promise<any> { async ping(): Promise<any> {
return fetch(`${this.cfg.serverUrl}/api/health`, { signal: AbortSignal.timeout(10000) }).then((r) => r.json()); // 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<any> { sendWorld(world: ParsedWorld): Promise<any> {

View file

@ -126,7 +126,12 @@ async function main(): Promise<void> {
} }
if (command === 'world') { if (command === 'world') {
try {
await uploadWorld(client, cfg); await uploadWorld(client, cfg);
} catch (e) {
console.error('World upload failed:', e instanceof Error ? e.message : e);
process.exit(1);
}
return; return;
} }
@ -140,4 +145,7 @@ async function main(): Promise<void> {
process.exit(1); process.exit(1);
} }
void main(); main().catch((e) => {
console.error('Agent error:', e instanceof Error ? e.message : e);
process.exit(1);
});

View file

@ -103,8 +103,13 @@ CREATE TABLE IF NOT EXISTS zones (
albion_id TEXT PRIMARY KEY, albion_id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
type 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 ( CREATE TABLE IF NOT EXISTS zone_connections (
zone_a TEXT NOT NULL, zone_a TEXT NOT NULL,

View file

@ -44,12 +44,12 @@ export async function storeWorld(world: ParsedWorld, source: string): Promise<Wo
for (const [a, b] of world.connections) edgeSet.add([a, b].sort().join('|')); for (const [a, b] of world.connections) edgeSet.add([a, b].sort().join('|'));
// chunked bulk inserts // chunked bulk inserts
const zoneRows = world.zones.map((z) => [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) { for (let i = 0; i < zoneRows.length; i += 5000) {
const chunk = zoneRows.slice(i, i + 5000); const chunk = zoneRows.slice(i, i + 5000);
const params: unknown[] = []; const params: unknown[] = [];
const groups = chunk.map((r) => `(${r.map((v) => `$${params.push(v)}`).join(', ')})`); 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('|')); const edges = [...edgeSet].map((e) => e.split('|'));
for (let i = 0; i < edges.length; i += 10000) { for (let i = 0; i < edges.length; i += 10000) {

View file

@ -8,6 +8,20 @@ export interface Zone {
name: string; name: string;
type: string; type: string;
color: 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'; 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<string, Zone>; adj: Map<string, string[]> }> { async function loadGraph(): Promise<{ zones: Map<string, Zone>; adj: Map<string, string[]> }> {
const zones = new Map<string, Zone>(); const zones = new Map<string, Zone>();
for (const r of (await query('SELECT * FROM zones')) as any[]) { 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<string, string[]>(); const adj = new Map<string, string[]>();
for (const r of (await query('SELECT * FROM zone_connections')) as any[]) { for (const r of (await query('SELECT * FROM zone_connections')) as any[]) {
@ -32,9 +46,14 @@ async function loadGraph(): Promise<{ zones: Map<string, Zone>; adj: Map<string,
return { zones, adj }; 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 { export interface RouteResult {
found: boolean; found: boolean;
path: Zone[]; path: RouteStep[];
zonesTraversed: number; zonesTraversed: number;
dangerZones: number; // red + black on the path dangerZones: number; // red + black on the path
} }
@ -77,10 +96,14 @@ export async function findRoute(fromName: string, toName: string, mode: TravelMo
if (!prev.has(goal)) return { found: false, path: [], zonesTraversed: 0, dangerZones: 0 }; if (!prev.has(goal)) return { found: false, path: [], zonesTraversed: 0, dangerZones: 0 };
const path: Zone[] = []; const zonePath: Zone[] = [];
for (let cur: string | null = goal; cur != null; cur = prev.get(cur) ?? null) { for (let cur: string | null = goal; cur != null; cur = prev.get(cur) ?? null) {
path.unshift(zones.get(cur)!); 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; const dangerZones = path.filter((z) => z.color === 'red' || z.color === 'black').length;
return { found: true, path, zonesTraversed: path.length - 1, dangerZones }; return { found: true, path, zonesTraversed: path.length - 1, dangerZones };
} }
@ -91,6 +114,8 @@ export async function listZones(): Promise<Zone[]> {
name: r.name, name: r.name,
type: r.type, type: r.type,
color: r.color, color: r.color,
x: r.pos_x,
y: r.pos_y,
})); }));
} }

View file

@ -49,6 +49,9 @@ export interface WorldZone {
name: string; name: string;
type: string; type: string;
color: 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 { export interface ParsedWorld {
@ -65,9 +68,18 @@ interface RawCluster {
'@id'?: string; '@id'?: string;
'@displayname'?: string; '@displayname'?: string;
'@type'?: string; '@type'?: string;
'@worldmapposition'?: string;
exits?: { exit?: RawExit | RawExit[] }; 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<T>(v: T | T[] | undefined): T[] { function asArray<T>(v: T | T[] | undefined): T[] {
if (v === undefined || v === null) return []; if (v === undefined || v === null) return [];
return Array.isArray(v) ? v : [v]; return Array.isArray(v) ? v : [v];
@ -129,12 +141,17 @@ export function parseWorldXml(xml: string): ParsedWorld {
} }
return { return {
zones: zones.map((z) => ({ zones: zones.map((z) => {
const [x, y] = parsePos(z['@worldmapposition']);
return {
albionId: z['@id']!, albionId: z['@id']!,
name: z['@displayname']!, name: z['@displayname']!,
type: z['@type'] ?? '', type: z['@type'] ?? '',
color: zoneColor(z['@type'] ?? ''), color: zoneColor(z['@type'] ?? ''),
})), x,
y,
};
}),
connections: [...edges].map((e) => e.split('|') as [string, string]), connections: [...edges].map((e) => e.split('|') as [string, string]),
}; };
} }

55
web/src/apiCache.ts Normal file
View file

@ -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<string, Entry>();
const DEFAULT_TTL = 5 * 60 * 1000; // 5 minutes
export interface CachedOptions {
ttl?: number;
force?: boolean;
init?: RequestInit;
}
export async function cachedJson<T = any>(key: string, url: string, opts: CachedOptions = {}): Promise<T> {
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<T = any>(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);
}

View file

@ -163,8 +163,9 @@ export default function OpportunitiesPage() {
<tr key={o.recipeId} className="clickable" onClick={() => setExpanded(expanded === o.recipeId ? null : o.recipeId)}> <tr key={o.recipeId} className="clickable" onClick={() => setExpanded(expanded === o.recipeId ? null : o.recipeId)}>
<td> <td>
{o.outputName ?? o.outputItem} {o.outputName ?? o.outputItem}
{o.enchantment > 0 && <span className="tag">.{o.enchantment}</span>} <span className="tag" title={o.enchantment > 0 ? `Tier ${o.tier}, enchantment ${o.enchantment}` : `Tier ${o.tier}`}>
<span className="tag">T{o.tier}</span> T{o.tier}{o.enchantment > 0 ? `.${o.enchantment}` : ''}
</span>
</td> </td>
<td className="muted">{o.source === 'upgrade' ? 'enchant' : 'craft'}</td> <td className="muted">{o.source === 'upgrade' ? 'enchant' : 'craft'}</td>
<td style={{ color: o.profit >= 0 ? '#7fd18a' : '#e08869', fontWeight: 600 }}>{silver(o.profit)}</td> <td style={{ color: o.profit >= 0 ? '#7fd18a' : '#e08869', fontWeight: 600 }}>{silver(o.profit)}</td>
@ -180,7 +181,8 @@ export default function OpportunitiesPage() {
<td colSpan={9} style={{ background: '#191d24' }}> <td colSpan={9} style={{ background: '#191d24' }}>
<div style={{ padding: '8px 4px' }}> <div style={{ padding: '8px 4px' }}>
<p className="muted" style={{ margin: '4px 0' }}> <p className="muted" style={{ margin: '4px 0' }}>
Sells for {silver(o.outputPrice)} ({o.amountCrafted}× per craft) · station fee {silver(o.fee)} · Sells in <strong>{o.city}</strong> 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)}% {o.focusUsed > 0 ? ` uses ${Math.round(o.focusUsed)} focus ·` : ''} return rate {(o.rrr * 100).toFixed(1)}%
</p> </p>
<table> <table>

View file

@ -7,9 +7,13 @@ interface Zone {
color: string; color: string;
} }
interface RouteStep extends Zone {
dirToNext: string | null;
}
interface RouteResult { interface RouteResult {
found: boolean; found: boolean;
path: Zone[]; path: RouteStep[];
zonesTraversed: number; zonesTraversed: number;
dangerZones: number; dangerZones: number;
} }
@ -166,7 +170,11 @@ export default function TravelPage() {
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
{route.path.map((z, i) => ( {route.path.map((z, i) => (
<span key={z.albionId} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}> <span key={z.albionId} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{i > 0 && <span className="muted">›</span>} {i > 0 && (
<span className="muted" style={{ fontSize: 12 }}>
{route.path[i - 1].dirToNext ? `— ${route.path[i - 1].dirToNext} →` : '›'}
</span>
)}
<span className="tag" style={{ color: ZONE_COLORS[z.color] ?? undefined, fontSize: 13 }}>{z.name}</span> <span className="tag" style={{ color: ZONE_COLORS[z.color] ?? undefined, fontSize: 13 }}>{z.name}</span>
</span> </span>
))} ))}