Compare commits

...

4 commits

Author SHA1 Message Date
Заид Омар Медхат
d18464dcde Show tier/enchant in Market Data item search
All checks were successful
Deploy / deploy (push) Successful in 1m40s
Enchanted variants share a localized name, so the market search results
and selected-item header now show a T4.3-style chip (tier + enchant),
making it clear which enchantment you're pricing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 05:13:06 +05:00
Заид Омар Медхат
c4683ff5b0 Backend caching + AODP refetch cooldown
- In-memory TTL cache (server/src/cache.ts) with a get/set/clear surface
  that a Redis backend can replace later for multi-replica setups.
- Cache the expensive opportunities ranking (5 min), item detail/search
  (1 h), and per-item market prices (10 min). All keyed by request; each
  read endpoint takes a refresh/force bypass.
- Invalidate precisely on writes: price upserts, overrides, profile
  activate/update, economy config, and game-data reparse (full clear).
- AODP anti-spam: a price download now skips items priced within a
  configurable cooldown (default 15 min, Settings page) unless "force"
  is checked; the job reports how many fresh items it skipped.
- No client-side caching (per request); the browser still calls the API,
  but repeats are served from the server cache without recompute/AODP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 05:09:38 +05:00
Заид Омар Медхат
b8207f5b15 Fix enchanted resource IDs in recipes (T4_LEATHER_LEVEL3@3)
Enchanted refined resources are top-level items carrying their own
@enchantmentlevel, and their tradeable/market id is <name>@<n>
(e.g. T4_LEATHER_LEVEL3@3). The parser stored them — and referenced
them in enchanted recipes — as just T4_LEATHER_LEVEL3, so those inputs
never matched AODP price rows and enchanted-item costs were wrong.
Apply canonicalId(name, enchantLevel) to both item ids and craftresource
references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:59:30 +05:00
Заид Омар Медхат
742b7c2085 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>
2026-08-23 04:53:28 +05:00
17 changed files with 384 additions and 83 deletions

View file

@ -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> {

View file

@ -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);
});

53
server/src/cache.ts Normal file
View file

@ -0,0 +1,53 @@
/**
* Process-local TTL cache for expensive/repeated reads (opportunities ranking,
* item lookups, market summaries). Keeps tab-switches and repeated requests cheap
* without recomputing or re-querying.
*
* Single-instance in-memory is enough for now. The surface (get/set/clear) is
* deliberately tiny so a Redis-backed implementation can replace it later if the
* app is ever scaled to multiple replicas that must share a cache — set
* REDIS_URL and swap the internals; callers don't change.
*/
interface Entry {
at: number;
value: unknown;
}
const store = new Map<string, Entry>();
export function cacheGet<T>(key: string, ttlMs: number): T | undefined {
const e = store.get(key);
if (!e) return undefined;
if (Date.now() - e.at > ttlMs) {
store.delete(key);
return undefined;
}
return e.value as T;
}
export function cacheSet(key: string, value: unknown): void {
store.set(key, { at: Date.now(), value });
}
/** Clear entries whose key starts with prefix (or everything if omitted). */
export function cacheClear(prefix?: string): void {
if (!prefix) {
store.clear();
return;
}
for (const k of [...store.keys()]) if (k.startsWith(prefix)) store.delete(k);
}
/**
* Get-or-compute. Returns the cached value when fresh; otherwise runs producer,
* stores, and returns it. `force` bypasses the read (still repopulates).
*/
export async function cached<T>(key: string, ttlMs: number, producer: () => Promise<T>, force = false): Promise<T> {
if (!force) {
const hit = cacheGet<T>(key, ttlMs);
if (hit !== undefined) return hit;
}
const value = await producer();
cacheSet(key, value);
return value;
}

View file

@ -1,4 +1,5 @@
import { get, query, transaction } from '../db/index.js';
import { cacheClear } from '../cache.js';
/**
* Character profile: the user's destiny board progress, entered manually
@ -82,6 +83,7 @@ export async function updateProfile(
new Date().toISOString(),
id,
]);
cacheClear('opp:'); // premium/spec levels feed every ranking
return rowToProfile(await get('SELECT * FROM profiles WHERE id = ?', [id]));
}
@ -90,6 +92,7 @@ export async function activateProfile(id: number): Promise<void> {
await tx.query('UPDATE profiles SET is_active = 0');
await tx.query('UPDATE profiles SET is_active = 1 WHERE id = ?', [id]);
});
cacheClear('opp:'); // a different active profile changes every ranking
}
export async function deleteProfile(id: number): Promise<void> {

View file

@ -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,

View file

@ -1,5 +1,6 @@
import fs from 'node:fs';
import { setMeta, transaction, type Tx } from '../db/index.js';
import { cacheClear } from '../cache.js';
import { gamedataFilePath } from './download.js';
/**
@ -49,10 +50,20 @@ export interface ParseStats {
localizedNames: number;
}
/**
* Canonical tradeable id: an item with @enchantmentlevel n>0 is `<name>@<n>`.
* This matters for enchanted refined resources, whose base names already carry
* `_LEVELn` (e.g. T4_LEATHER_LEVEL3) AND an @enchantmentlevel — the real id the
* market uses is `T4_LEATHER_LEVEL3@3`, not `T4_LEATHER_LEVEL3`.
*/
function canonicalId(uniqueName: string, enchantLevel: number | null): string {
return enchantLevel && enchantLevel > 0 ? `${uniqueName}@${enchantLevel}` : uniqueName;
}
function parseCraftResources(req: Raw): ParsedRecipe['inputs'] {
return asArray<Raw>(req.craftresource)
.map((r) => ({
item: String(r['@uniquename'] ?? ''),
item: canonicalId(String(r['@uniquename'] ?? ''), num(r['@enchantmentlevel'])),
count: num(r['@count']) ?? 0,
maxReturnAmount: num(r['@maxreturnamount']),
}))
@ -84,10 +95,15 @@ function collectItem(raw: Raw, items: ParsedItem[], recipes: ParsedRecipe[]): vo
const uniqueName = raw['@uniquename'];
if (!uniqueName || typeof uniqueName !== 'string') return;
// Some items (enchanted refined resources like T4_LEATHER_LEVEL3) carry their
// own @enchantmentlevel and no enchantments node — their tradeable id is @n.
const selfLevel = num(raw['@enchantmentlevel']) ?? 0;
const selfId = canonicalId(uniqueName, selfLevel);
const base: ParsedItem = {
uniqueName,
uniqueName: selfId,
baseName: uniqueName,
enchantment: 0,
enchantment: selfLevel,
tier: num(raw['@tier']),
weight: num(raw['@weight']),
itemValue: num(raw['@itemvalue']),
@ -96,7 +112,7 @@ function collectItem(raw: Raw, items: ParsedItem[], recipes: ParsedRecipe[]): vo
craftingCategory: raw['@craftingcategory'] ?? null,
};
items.push(base);
recipes.push(...parseRecipesForOutput(uniqueName, raw.craftingrequirements));
recipes.push(...parseRecipesForOutput(selfId, raw.craftingrequirements));
// Enchanted variants live under enchantments.enchantment with their own recipes.
for (const ench of asArray<Raw>(raw.enchantments?.enchantment)) {
@ -230,5 +246,6 @@ export async function parseGameData(): Promise<ParseStats> {
};
await setMeta('gamedata_parsed_at', new Date().toISOString());
await setMeta('gamedata_stats', JSON.stringify(stats));
cacheClear(); // items/recipes changed → drop every cached read
return stats;
}

View file

@ -1,4 +1,5 @@
import { get, getMeta, query, setMeta, transaction } from '../db/index.js';
import { cacheClear } from '../cache.js';
import {
AodpUnreachableError,
CITIES,
@ -7,6 +8,12 @@ import {
SERVER_HOSTS,
} from './client.js';
/** Prices changed → opportunities and market reads are stale. */
function invalidatePriceCaches(): void {
cacheClear('opp:');
cacheClear('market:');
}
export async function getAlbionServer(): Promise<string> {
const s = await getMeta('albion_server');
return s && s in SERVER_HOSTS ? s : 'europe';
@ -26,6 +33,7 @@ export interface RefreshJob {
doneChunks: number;
itemCount: number;
rowsUpserted: number;
skippedFresh?: number; // items skipped because their prices were still within the cooldown
error?: string;
startedAt?: string;
finishedAt?: string;
@ -42,6 +50,24 @@ export interface RefreshOptions {
maxTier?: number;
cities?: string[];
items?: string[]; // explicit list overrides tier-based selection
force?: boolean; // ignore the refetch cooldown
}
/** Minutes an item's prices are considered fresh — within this, a refresh skips it to spare the AODP API. */
async function priceCooldownMinutes(): Promise<number> {
const v = Number(await getMeta('price_cooldown_min'));
return Number.isFinite(v) && v > 0 ? v : 15;
}
/** Items whose most recent price fetch (any city) is within the cooldown window. */
async function recentlyFetchedItems(cooldownMin: number): Promise<Set<string>> {
// fetched_at is stored as ISO-8601 UTC, which orders chronologically as text.
const thresholdIso = new Date(Date.now() - cooldownMin * 60_000).toISOString();
const rows = await query<{ item: string }>(
'SELECT item FROM market_prices GROUP BY item HAVING MAX(fetched_at) > ?',
[thresholdIso],
);
return new Set(rows.map((r) => r.item));
}
/** Items worth pricing: everything that appears in a recipe (as output or input). */
@ -91,6 +117,7 @@ export async function upsertPriceRows(rows: unknown[][], mode: 'overwrite' | 'co
await tx.query(`INSERT INTO market_prices (${PRICE_COLUMNS.join(', ')}) VALUES ${groups.join(', ')}${tail}`, params);
}
});
invalidatePriceCaches();
return rows.length;
}
@ -108,7 +135,7 @@ export function startRefresh(opts: RefreshOptions): { started: boolean; reason?:
void (async () => {
try {
const items =
let items =
opts.items && opts.items.length > 0
? opts.items
: await selectRecipeRelevantItems(opts.minTier ?? 2, opts.maxTier ?? 8);
@ -118,6 +145,25 @@ export function startRefresh(opts: RefreshOptions): { started: boolean; reason?:
job.finishedAt = new Date().toISOString();
return;
}
// Skip items priced recently, so repeated downloads don't spam AODP.
const requested = items.length;
let skipped = 0;
if (!opts.force) {
const cooldown = await priceCooldownMinutes();
const fresh = await recentlyFetchedItems(cooldown);
const filtered = items.filter((i) => !fresh.has(i));
skipped = items.length - filtered.length;
items = filtered;
}
job.skippedFresh = skipped;
if (items.length === 0) {
job.status = 'done';
job.itemCount = requested;
job.rowsUpserted = 0;
job.finishedAt = new Date().toISOString();
return; // everything was fresh — nothing to fetch
}
job.itemCount = items.length;
job.totalChunks = Math.ceil(items.length / 100);
@ -254,10 +300,12 @@ export async function setOverride(o: {
updated_at = excluded.updated_at`,
[o.item, o.city, o.quality ?? 1, o.sellPrice ?? null, o.buyPrice ?? null, o.note ?? null, new Date().toISOString()],
);
invalidatePriceCaches(); // manual override changes prices used everywhere
}
export async function deleteOverride(item: string, city: string, quality = 1): Promise<void> {
await query('DELETE FROM price_overrides WHERE item = ? AND city = ? AND quality = ?', [item, city, quality]);
invalidatePriceCaches();
}
export async function marketSummary() {

View file

@ -1,20 +1,36 @@
import type { FastifyInstance } from 'fastify';
import { cached, cacheClear } from '../cache.js';
import { getEconomyConfig, setEconomyConfig, type EconomyConfig } from '../economy/config.js';
import { rankOpportunities, type RankRequest } from '../economy/service.js';
const OPP_TTL_MS = 5 * 60 * 1000; // opportunities are recomputed at most every 5 min per param-set
export async function economyRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/economy/config', async () => getEconomyConfig());
app.put('/api/economy/config', async (req) => {
await setEconomyConfig((req.body ?? {}) as Partial<EconomyConfig>);
cacheClear('opp:'); // config affects every ranking
return { ok: true, config: await getEconomyConfig() };
});
app.post('/api/economy/opportunities', async (req, reply) => {
const body = (req.body ?? {}) as RankRequest;
const body = (req.body ?? {}) as RankRequest & { refresh?: boolean };
if (!body.city) return reply.code(400).send({ error: 'city is required' });
const { refresh, ...params } = body;
const key = `opp:${JSON.stringify(params)}`;
const started = Date.now();
const result = await rankOpportunities(body);
return { ...result, tookMs: Date.now() - started };
let cacheHit = true;
const result = await cached(
key,
OPP_TTL_MS,
async () => {
cacheHit = false;
return rankOpportunities(params);
},
refresh === true,
);
return { ...result, tookMs: Date.now() - started, cached: cacheHit };
});
}

View file

@ -1,47 +1,58 @@
import type { FastifyInstance } from 'fastify';
import { get, query } from '../db/index.js';
import { cached } from '../cache.js';
// Item/recipe data only changes when game data is re-parsed (which calls
// cacheClear()), so these can cache for a long time.
const ITEM_TTL_MS = 60 * 60 * 1000;
export async function itemRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/items/search', async (req) => {
const { q = '' } = req.query as { q?: string };
const queryStr = q.trim();
if (!queryStr) return { results: [] };
const results = await query(
`SELECT unique_name, name_en, tier, enchantment, shop_category, shop_subcategory
FROM items
WHERE name_en ILIKE ? OR unique_name ILIKE ?
ORDER BY tier, enchantment, unique_name
LIMIT 50`,
[`%${queryStr}%`, `%${queryStr}%`],
const results = await cached(`item:search:${queryStr.toLowerCase()}`, ITEM_TTL_MS, () =>
query(
`SELECT unique_name, name_en, tier, enchantment, shop_category, shop_subcategory
FROM items
WHERE name_en ILIKE ? OR unique_name ILIKE ?
ORDER BY tier, enchantment, unique_name
LIMIT 50`,
[`%${queryStr}%`, `%${queryStr}%`],
),
);
return { results };
});
app.get('/api/items/:uniqueName', async (req, reply) => {
const { uniqueName } = req.params as { uniqueName: string };
const item = await get('SELECT * FROM items WHERE unique_name = ?', [uniqueName]);
if (!item) return reply.code(404).send({ error: 'item not found' });
const data = await cached(`item:detail:${uniqueName}`, ITEM_TTL_MS, async () => {
const item = await get('SELECT * FROM items WHERE unique_name = ?', [uniqueName]);
if (!item) return null;
const recipes = (await query('SELECT * FROM recipes WHERE output_item = ? ORDER BY source, variant', [uniqueName])) as any[];
for (const r of recipes) {
r.inputs = await query(
`SELECT ri.input_item, ri.count, ri.max_return_amount, i.name_en
FROM recipe_inputs ri LEFT JOIN items i ON i.unique_name = ri.input_item
WHERE ri.recipe_id = ?`,
[r.id],
const recipes = (await query('SELECT * FROM recipes WHERE output_item = ? ORDER BY source, variant', [uniqueName])) as any[];
for (const r of recipes) {
r.inputs = await query(
`SELECT ri.input_item, ri.count, ri.max_return_amount, i.name_en
FROM recipe_inputs ri LEFT JOIN items i ON i.unique_name = ri.input_item
WHERE ri.recipe_id = ?`,
[r.id],
);
}
const usedIn = await query(
`SELECT DISTINCT r.output_item, i.name_en
FROM recipe_inputs ri
JOIN recipes r ON r.id = ri.recipe_id
LEFT JOIN items i ON i.unique_name = r.output_item
WHERE ri.input_item = ?
LIMIT 100`,
[uniqueName],
);
}
return { item, recipes, usedIn };
});
const usedIn = await query(
`SELECT DISTINCT r.output_item, i.name_en
FROM recipe_inputs ri
JOIN recipes r ON r.id = ri.recipe_id
LEFT JOIN items i ON i.unique_name = r.output_item
WHERE ri.input_item = ?
LIMIT 100`,
[uniqueName],
);
return { item, recipes, usedIn };
if (!data) return reply.code(404).send({ error: 'item not found' });
return data;
});
}

View file

@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { getMeta, query, setMeta } from '../db/index.js';
import { cached } from '../cache.js';
import { CITIES, SERVER_HOSTS } from '../market/client.js';
import {
deleteOverride,
@ -25,6 +26,7 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
maxTier?: number;
cities?: string[];
items?: string[];
force?: boolean;
};
const result = startRefresh(body);
if (!result.started) return reply.code(409).send({ error: result.reason });
@ -33,7 +35,10 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/market/prices/:item', async (req) => {
const { item } = req.params as { item: string };
return { prices: await getPricesForItem(item) };
const refresh = (req.query as { refresh?: string }).refresh === 'true';
// cache is busted whenever prices/overrides change, so a long TTL is safe
const prices = await cached(`market:prices:${item}`, 10 * 60 * 1000, () => getPricesForItem(item), refresh);
return { prices };
});
app.put('/api/market/override', async (req, reply) => {
@ -69,13 +74,15 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
availableServers: Object.keys(SERVER_HOSTS),
proxyUrl: (await getMeta('proxy_url')) ?? '',
gamePath: (await getMeta('game_path')) ?? '',
priceCooldownMin: Number((await getMeta('price_cooldown_min')) ?? 15),
}));
app.put('/api/settings', async (req, reply) => {
const { albionServer, proxyUrl, gamePath } = (req.body ?? {}) as {
const { albionServer, proxyUrl, gamePath, priceCooldownMin } = (req.body ?? {}) as {
albionServer?: string;
proxyUrl?: string;
gamePath?: string;
priceCooldownMin?: number;
};
if (albionServer) {
try {
@ -92,6 +99,9 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
if (gamePath === '') await query("DELETE FROM meta WHERE key = 'game_path'");
else await setMeta('game_path', gamePath);
}
if (priceCooldownMin !== undefined && Number.isFinite(priceCooldownMin) && priceCooldownMin > 0) {
await setMeta('price_cooldown_min', String(Math.round(priceCooldownMin)));
}
return { ok: true };
});
}

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('|'));
// 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) {

View file

@ -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,
}));
}

View file

@ -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]),
};
}

View file

@ -6,6 +6,7 @@ interface Job {
doneChunks: number;
itemCount: number;
rowsUpserted: number;
skippedFresh?: number;
error?: string;
}
@ -40,13 +41,27 @@ function AgeBadge({ hours, source }: { hours: number | null; source: string | nu
const fmt = (n: number | null) => (n == null ? '—' : n.toLocaleString('en-US'));
interface ItemHit {
unique_name: string;
name_en: string | null;
tier: number | null;
enchantment: number;
}
/** e.g. "T4.3" — shows tier and enchant so enchanted variants are distinguishable. */
function tierTag(it: { tier: number | null; enchantment: number }): string {
const t = it.tier != null ? `T${it.tier}` : '';
return it.enchantment > 0 ? `${t}.${it.enchantment}` : t;
}
export default function MarketPage() {
const [summary, setSummary] = useState<Summary | null>(null);
const [minTier, setMinTier] = useState(2);
const [maxTier, setMaxTier] = useState(8);
const [force, setForce] = useState(false);
const [q, setQ] = useState('');
const [results, setResults] = useState<{ unique_name: string; name_en: string | null }[]>([]);
const [selected, setSelected] = useState<{ unique_name: string; name_en: string | null } | null>(null);
const [results, setResults] = useState<ItemHit[]>([]);
const [selected, setSelected] = useState<ItemHit | null>(null);
const [prices, setPrices] = useState<Price[]>([]);
const [editing, setEditing] = useState<string | null>(null); // "city|quality"
const [editSell, setEditSell] = useState('');
@ -92,7 +107,7 @@ export default function MarketPage() {
.then((d) => setPrices(d.prices ?? []));
}, []);
const select = (it: { unique_name: string; name_en: string | null }) => {
const select = (it: ItemHit) => {
setSelected(it);
setResults([]);
setQ('');
@ -103,7 +118,7 @@ export default function MarketPage() {
await fetch('/api/market/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ minTier, maxTier }),
body: JSON.stringify({ minTier, maxTier, force }),
});
loadSummary();
};
@ -157,6 +172,9 @@ export default function MarketPage() {
{[1, 2, 3, 4, 5, 6, 7, 8].map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</label>
<label title="Ignore the refetch cooldown and re-download everything">
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} /> force
</label>
<button className="primary" disabled={job?.status === 'running'} onClick={startRefresh}>
{job?.status === 'running' ? 'Refreshing…' : 'Download prices'}
</button>
@ -165,7 +183,12 @@ export default function MarketPage() {
{job.doneChunks}/{job.totalChunks} batches ({job.itemCount} items)
</span>
)}
{job?.status === 'done' && <span className="muted">Last run stored {job.rowsUpserted} price rows.</span>}
{job?.status === 'done' && (
<span className="muted">
Last run stored {job.rowsUpserted} price rows
{job.skippedFresh ? `, skipped ${job.skippedFresh} still-fresh items` : ''}.
</span>
)}
</div>
{job?.status === 'error' && (
<p style={{ color: '#e08869' }}>
@ -187,7 +210,12 @@ export default function MarketPage() {
<tbody>
{results.slice(0, 10).map((r) => (
<tr key={r.unique_name} className="clickable" onClick={() => select(r)}>
<td>{r.name_en ?? r.unique_name}</td>
<td>
{r.name_en ?? r.unique_name}
<span className="tag" title={r.enchantment > 0 ? `Tier ${r.tier}, enchant ${r.enchantment}` : `Tier ${r.tier}`}>
{tierTag(r)}
</span>
</td>
<td className="muted">{r.unique_name}</td>
</tr>
))}
@ -198,7 +226,9 @@ export default function MarketPage() {
{selected && (
<>
<h4>
{selected.name_en ?? selected.unique_name} <span className="tag">{selected.unique_name}</span>
{selected.name_en ?? selected.unique_name}
<span className="tag" title={selected.enchantment > 0 ? `enchant ${selected.enchantment}` : undefined}>{tierTag(selected)}</span>
<span className="tag">{selected.unique_name}</span>
</h4>
{prices.length === 0 && (
<p className="muted">No price data yet — run a price download above, or add a manual price.</p>

View file

@ -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>

View file

@ -5,12 +5,14 @@ interface Settings {
availableServers: string[];
proxyUrl: string;
gamePath: string;
priceCooldownMin: number;
}
export default function SettingsPage() {
const [settings, setSettings] = useState<Settings | null>(null);
const [proxyUrl, setProxyUrl] = useState('');
const [gamePath, setGamePath] = useState('');
const [cooldown, setCooldown] = useState(15);
const [saved, setSaved] = useState(false);
useEffect(() => {
@ -20,10 +22,11 @@ export default function SettingsPage() {
setSettings(s);
setProxyUrl(s.proxyUrl);
setGamePath(s.gamePath);
setCooldown(s.priceCooldownMin);
});
}, []);
const save = async (patch: Partial<{ albionServer: string; proxyUrl: string; gamePath: string }>) => {
const save = async (patch: Partial<{ albionServer: string; proxyUrl: string; gamePath: string; priceCooldownMin: number }>) => {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@ -78,6 +81,19 @@ export default function SettingsPage() {
<button className="primary" onClick={() => save({ gamePath })}>Save</button>
</div>
</div>
<div className="card">
<h3>Price refetch cooldown</h3>
<p className="muted">
A price download skips items whose prices were fetched within this many minutes, to avoid spamming the
market API. Use the "force" checkbox on the Market Data page to override it when you truly need fresh data.
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="number" min={1} value={cooldown} style={{ width: 90 }}
onChange={(e) => setCooldown(Number(e.target.value))} />
<span className="muted">minutes</span>
<button className="primary" onClick={() => save({ priceCooldownMin: cooldown })}>Save</button>
</div>
</div>
</div>
);
}

View file

@ -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>
))}