Compare commits
4 commits
5d629d1641
...
d18464dcde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d18464dcde | ||
|
|
c4683ff5b0 | ||
|
|
b8207f5b15 | ||
|
|
742b7c2085 |
17 changed files with 384 additions and 83 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);
|
||||
});
|
||||
|
|
|
|||
53
server/src/cache.ts
Normal file
53
server/src/cache.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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