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:
parent
5d629d1641
commit
742b7c2085
9 changed files with 189 additions and 37 deletions
|
|
@ -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<void> {
|
||||
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<any> {
|
||||
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<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}`, {
|
||||
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<any> {
|
||||
return fetch(`${this.cfg.serverUrl}/api/health`, { signal: AbortSignal.timeout(10000) }).then((r) => r.json());
|
||||
async ping(): Promise<any> {
|
||||
// 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> {
|
||||
|
|
|
|||
|
|
@ -126,7 +126,12 @@ async function main(): Promise<void> {
|
|||
}
|
||||
|
||||
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<void> {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
void main();
|
||||
main().catch((e) => {
|
||||
console.error('Agent error:', e instanceof Error ? e.message : e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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('|'));
|
||||
|
||||
// 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) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<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 });
|
||||
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[]) {
|
||||
|
|
@ -32,9 +46,14 @@ async function loadGraph(): Promise<{ zones: Map<string, Zone>; adj: Map<string,
|
|||
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: Zone[];
|
||||
path: RouteStep[];
|
||||
zonesTraversed: number;
|
||||
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 };
|
||||
|
||||
const path: Zone[] = [];
|
||||
const zonePath: Zone[] = [];
|
||||
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;
|
||||
return { found: true, path, zonesTraversed: path.length - 1, dangerZones };
|
||||
}
|
||||
|
|
@ -91,6 +114,8 @@ export async function listZones(): Promise<Zone[]> {
|
|||
name: r.name,
|
||||
type: r.type,
|
||||
color: r.color,
|
||||
x: r.pos_x,
|
||||
y: r.pos_y,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T>(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]),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
55
web/src/apiCache.ts
Normal file
55
web/src/apiCache.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -163,8 +163,9 @@ export default function OpportunitiesPage() {
|
|||
<tr key={o.recipeId} className="clickable" onClick={() => setExpanded(expanded === o.recipeId ? null : o.recipeId)}>
|
||||
<td>
|
||||
{o.outputName ?? o.outputItem}
|
||||
{o.enchantment > 0 && <span className="tag">.{o.enchantment}</span>}
|
||||
<span className="tag">T{o.tier}</span>
|
||||
<span className="tag" title={o.enchantment > 0 ? `Tier ${o.tier}, enchantment ${o.enchantment}` : `Tier ${o.tier}`}>
|
||||
T{o.tier}{o.enchantment > 0 ? `.${o.enchantment}` : ''}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">{o.source === 'upgrade' ? 'enchant' : 'craft'}</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' }}>
|
||||
<div style={{ padding: '8px 4px' }}>
|
||||
<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)}%
|
||||
</p>
|
||||
<table>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||
{route.path.map((z, i) => (
|
||||
<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>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Reference in a new issue