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 { 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> {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
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 { get, query, transaction } from '../db/index.js';
|
||||||
|
import { cacheClear } from '../cache.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Character profile: the user's destiny board progress, entered manually
|
* Character profile: the user's destiny board progress, entered manually
|
||||||
|
|
@ -82,6 +83,7 @@ export async function updateProfile(
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
id,
|
id,
|
||||||
]);
|
]);
|
||||||
|
cacheClear('opp:'); // premium/spec levels feed every ranking
|
||||||
return rowToProfile(await get('SELECT * FROM profiles WHERE id = ?', [id]));
|
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 = 0');
|
||||||
await tx.query('UPDATE profiles SET is_active = 1 WHERE id = ?', [id]);
|
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> {
|
export async function deleteProfile(id: number): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { setMeta, transaction, type Tx } from '../db/index.js';
|
import { setMeta, transaction, type Tx } from '../db/index.js';
|
||||||
|
import { cacheClear } from '../cache.js';
|
||||||
import { gamedataFilePath } from './download.js';
|
import { gamedataFilePath } from './download.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -49,10 +50,20 @@ export interface ParseStats {
|
||||||
localizedNames: number;
|
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'] {
|
function parseCraftResources(req: Raw): ParsedRecipe['inputs'] {
|
||||||
return asArray<Raw>(req.craftresource)
|
return asArray<Raw>(req.craftresource)
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
item: String(r['@uniquename'] ?? ''),
|
item: canonicalId(String(r['@uniquename'] ?? ''), num(r['@enchantmentlevel'])),
|
||||||
count: num(r['@count']) ?? 0,
|
count: num(r['@count']) ?? 0,
|
||||||
maxReturnAmount: num(r['@maxreturnamount']),
|
maxReturnAmount: num(r['@maxreturnamount']),
|
||||||
}))
|
}))
|
||||||
|
|
@ -84,10 +95,15 @@ function collectItem(raw: Raw, items: ParsedItem[], recipes: ParsedRecipe[]): vo
|
||||||
const uniqueName = raw['@uniquename'];
|
const uniqueName = raw['@uniquename'];
|
||||||
if (!uniqueName || typeof uniqueName !== 'string') return;
|
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 = {
|
const base: ParsedItem = {
|
||||||
uniqueName,
|
uniqueName: selfId,
|
||||||
baseName: uniqueName,
|
baseName: uniqueName,
|
||||||
enchantment: 0,
|
enchantment: selfLevel,
|
||||||
tier: num(raw['@tier']),
|
tier: num(raw['@tier']),
|
||||||
weight: num(raw['@weight']),
|
weight: num(raw['@weight']),
|
||||||
itemValue: num(raw['@itemvalue']),
|
itemValue: num(raw['@itemvalue']),
|
||||||
|
|
@ -96,7 +112,7 @@ function collectItem(raw: Raw, items: ParsedItem[], recipes: ParsedRecipe[]): vo
|
||||||
craftingCategory: raw['@craftingcategory'] ?? null,
|
craftingCategory: raw['@craftingcategory'] ?? null,
|
||||||
};
|
};
|
||||||
items.push(base);
|
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.
|
// Enchanted variants live under enchantments.enchantment with their own recipes.
|
||||||
for (const ench of asArray<Raw>(raw.enchantments?.enchantment)) {
|
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_parsed_at', new Date().toISOString());
|
||||||
await setMeta('gamedata_stats', JSON.stringify(stats));
|
await setMeta('gamedata_stats', JSON.stringify(stats));
|
||||||
|
cacheClear(); // items/recipes changed → drop every cached read
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { get, getMeta, query, setMeta, transaction } from '../db/index.js';
|
import { get, getMeta, query, setMeta, transaction } from '../db/index.js';
|
||||||
|
import { cacheClear } from '../cache.js';
|
||||||
import {
|
import {
|
||||||
AodpUnreachableError,
|
AodpUnreachableError,
|
||||||
CITIES,
|
CITIES,
|
||||||
|
|
@ -7,6 +8,12 @@ import {
|
||||||
SERVER_HOSTS,
|
SERVER_HOSTS,
|
||||||
} from './client.js';
|
} from './client.js';
|
||||||
|
|
||||||
|
/** Prices changed → opportunities and market reads are stale. */
|
||||||
|
function invalidatePriceCaches(): void {
|
||||||
|
cacheClear('opp:');
|
||||||
|
cacheClear('market:');
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAlbionServer(): Promise<string> {
|
export async function getAlbionServer(): Promise<string> {
|
||||||
const s = await getMeta('albion_server');
|
const s = await getMeta('albion_server');
|
||||||
return s && s in SERVER_HOSTS ? s : 'europe';
|
return s && s in SERVER_HOSTS ? s : 'europe';
|
||||||
|
|
@ -26,6 +33,7 @@ export interface RefreshJob {
|
||||||
doneChunks: number;
|
doneChunks: number;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
rowsUpserted: number;
|
rowsUpserted: number;
|
||||||
|
skippedFresh?: number; // items skipped because their prices were still within the cooldown
|
||||||
error?: string;
|
error?: string;
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
finishedAt?: string;
|
finishedAt?: string;
|
||||||
|
|
@ -42,6 +50,24 @@ export interface RefreshOptions {
|
||||||
maxTier?: number;
|
maxTier?: number;
|
||||||
cities?: string[];
|
cities?: string[];
|
||||||
items?: string[]; // explicit list overrides tier-based selection
|
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). */
|
/** 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);
|
await tx.query(`INSERT INTO market_prices (${PRICE_COLUMNS.join(', ')}) VALUES ${groups.join(', ')}${tail}`, params);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
invalidatePriceCaches();
|
||||||
return rows.length;
|
return rows.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,7 +135,7 @@ export function startRefresh(opts: RefreshOptions): { started: boolean; reason?:
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const items =
|
let items =
|
||||||
opts.items && opts.items.length > 0
|
opts.items && opts.items.length > 0
|
||||||
? opts.items
|
? opts.items
|
||||||
: await selectRecipeRelevantItems(opts.minTier ?? 2, opts.maxTier ?? 8);
|
: 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();
|
job.finishedAt = new Date().toISOString();
|
||||||
return;
|
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.itemCount = items.length;
|
||||||
job.totalChunks = Math.ceil(items.length / 100);
|
job.totalChunks = Math.ceil(items.length / 100);
|
||||||
|
|
||||||
|
|
@ -254,10 +300,12 @@ export async function setOverride(o: {
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = excluded.updated_at`,
|
||||||
[o.item, o.city, o.quality ?? 1, o.sellPrice ?? null, o.buyPrice ?? null, o.note ?? null, new Date().toISOString()],
|
[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> {
|
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]);
|
await query('DELETE FROM price_overrides WHERE item = ? AND city = ? AND quality = ?', [item, city, quality]);
|
||||||
|
invalidatePriceCaches();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function marketSummary() {
|
export async function marketSummary() {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,36 @@
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { cached, cacheClear } from '../cache.js';
|
||||||
import { getEconomyConfig, setEconomyConfig, type EconomyConfig } from '../economy/config.js';
|
import { getEconomyConfig, setEconomyConfig, type EconomyConfig } from '../economy/config.js';
|
||||||
import { rankOpportunities, type RankRequest } from '../economy/service.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> {
|
export async function economyRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.get('/api/economy/config', async () => getEconomyConfig());
|
app.get('/api/economy/config', async () => getEconomyConfig());
|
||||||
|
|
||||||
app.put('/api/economy/config', async (req) => {
|
app.put('/api/economy/config', async (req) => {
|
||||||
await setEconomyConfig((req.body ?? {}) as Partial<EconomyConfig>);
|
await setEconomyConfig((req.body ?? {}) as Partial<EconomyConfig>);
|
||||||
|
cacheClear('opp:'); // config affects every ranking
|
||||||
return { ok: true, config: await getEconomyConfig() };
|
return { ok: true, config: await getEconomyConfig() };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/economy/opportunities', async (req, reply) => {
|
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' });
|
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 started = Date.now();
|
||||||
const result = await rankOpportunities(body);
|
let cacheHit = true;
|
||||||
return { ...result, tookMs: Date.now() - started };
|
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,26 +1,34 @@
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { get, query } from '../db/index.js';
|
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> {
|
export async function itemRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.get('/api/items/search', async (req) => {
|
app.get('/api/items/search', async (req) => {
|
||||||
const { q = '' } = req.query as { q?: string };
|
const { q = '' } = req.query as { q?: string };
|
||||||
const queryStr = q.trim();
|
const queryStr = q.trim();
|
||||||
if (!queryStr) return { results: [] };
|
if (!queryStr) return { results: [] };
|
||||||
const results = await query(
|
const results = await cached(`item:search:${queryStr.toLowerCase()}`, ITEM_TTL_MS, () =>
|
||||||
|
query(
|
||||||
`SELECT unique_name, name_en, tier, enchantment, shop_category, shop_subcategory
|
`SELECT unique_name, name_en, tier, enchantment, shop_category, shop_subcategory
|
||||||
FROM items
|
FROM items
|
||||||
WHERE name_en ILIKE ? OR unique_name ILIKE ?
|
WHERE name_en ILIKE ? OR unique_name ILIKE ?
|
||||||
ORDER BY tier, enchantment, unique_name
|
ORDER BY tier, enchantment, unique_name
|
||||||
LIMIT 50`,
|
LIMIT 50`,
|
||||||
[`%${queryStr}%`, `%${queryStr}%`],
|
[`%${queryStr}%`, `%${queryStr}%`],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return { results };
|
return { results };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/items/:uniqueName', async (req, reply) => {
|
app.get('/api/items/:uniqueName', async (req, reply) => {
|
||||||
const { uniqueName } = req.params as { uniqueName: string };
|
const { uniqueName } = req.params as { uniqueName: string };
|
||||||
|
const data = await cached(`item:detail:${uniqueName}`, ITEM_TTL_MS, async () => {
|
||||||
const item = await get('SELECT * FROM items WHERE unique_name = ?', [uniqueName]);
|
const item = await get('SELECT * FROM items WHERE unique_name = ?', [uniqueName]);
|
||||||
if (!item) return reply.code(404).send({ error: 'item not found' });
|
if (!item) return null;
|
||||||
|
|
||||||
const recipes = (await query('SELECT * FROM recipes WHERE output_item = ? ORDER BY source, variant', [uniqueName])) as any[];
|
const recipes = (await query('SELECT * FROM recipes WHERE output_item = ? ORDER BY source, variant', [uniqueName])) as any[];
|
||||||
for (const r of recipes) {
|
for (const r of recipes) {
|
||||||
|
|
@ -41,7 +49,10 @@ export async function itemRoutes(app: FastifyInstance): Promise<void> {
|
||||||
LIMIT 100`,
|
LIMIT 100`,
|
||||||
[uniqueName],
|
[uniqueName],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { item, recipes, usedIn };
|
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 type { FastifyInstance } from 'fastify';
|
||||||
import { getMeta, query, setMeta } from '../db/index.js';
|
import { getMeta, query, setMeta } from '../db/index.js';
|
||||||
|
import { cached } from '../cache.js';
|
||||||
import { CITIES, SERVER_HOSTS } from '../market/client.js';
|
import { CITIES, SERVER_HOSTS } from '../market/client.js';
|
||||||
import {
|
import {
|
||||||
deleteOverride,
|
deleteOverride,
|
||||||
|
|
@ -25,6 +26,7 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
|
||||||
maxTier?: number;
|
maxTier?: number;
|
||||||
cities?: string[];
|
cities?: string[];
|
||||||
items?: string[];
|
items?: string[];
|
||||||
|
force?: boolean;
|
||||||
};
|
};
|
||||||
const result = startRefresh(body);
|
const result = startRefresh(body);
|
||||||
if (!result.started) return reply.code(409).send({ error: result.reason });
|
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) => {
|
app.get('/api/market/prices/:item', async (req) => {
|
||||||
const { item } = req.params as { item: string };
|
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) => {
|
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),
|
availableServers: Object.keys(SERVER_HOSTS),
|
||||||
proxyUrl: (await getMeta('proxy_url')) ?? '',
|
proxyUrl: (await getMeta('proxy_url')) ?? '',
|
||||||
gamePath: (await getMeta('game_path')) ?? '',
|
gamePath: (await getMeta('game_path')) ?? '',
|
||||||
|
priceCooldownMin: Number((await getMeta('price_cooldown_min')) ?? 15),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.put('/api/settings', async (req, reply) => {
|
app.put('/api/settings', async (req, reply) => {
|
||||||
const { albionServer, proxyUrl, gamePath } = (req.body ?? {}) as {
|
const { albionServer, proxyUrl, gamePath, priceCooldownMin } = (req.body ?? {}) as {
|
||||||
albionServer?: string;
|
albionServer?: string;
|
||||||
proxyUrl?: string;
|
proxyUrl?: string;
|
||||||
gamePath?: string;
|
gamePath?: string;
|
||||||
|
priceCooldownMin?: number;
|
||||||
};
|
};
|
||||||
if (albionServer) {
|
if (albionServer) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -92,6 +99,9 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
|
||||||
if (gamePath === '') await query("DELETE FROM meta WHERE key = 'game_path'");
|
if (gamePath === '') await query("DELETE FROM meta WHERE key = 'game_path'");
|
||||||
else await setMeta('game_path', gamePath);
|
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 };
|
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('|'));
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ interface Job {
|
||||||
doneChunks: number;
|
doneChunks: number;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
rowsUpserted: number;
|
rowsUpserted: number;
|
||||||
|
skippedFresh?: number;
|
||||||
error?: string;
|
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'));
|
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() {
|
export default function MarketPage() {
|
||||||
const [summary, setSummary] = useState<Summary | null>(null);
|
const [summary, setSummary] = useState<Summary | null>(null);
|
||||||
const [minTier, setMinTier] = useState(2);
|
const [minTier, setMinTier] = useState(2);
|
||||||
const [maxTier, setMaxTier] = useState(8);
|
const [maxTier, setMaxTier] = useState(8);
|
||||||
|
const [force, setForce] = useState(false);
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [results, setResults] = useState<{ unique_name: string; name_en: string | null }[]>([]);
|
const [results, setResults] = useState<ItemHit[]>([]);
|
||||||
const [selected, setSelected] = useState<{ unique_name: string; name_en: string | null } | null>(null);
|
const [selected, setSelected] = useState<ItemHit | null>(null);
|
||||||
const [prices, setPrices] = useState<Price[]>([]);
|
const [prices, setPrices] = useState<Price[]>([]);
|
||||||
const [editing, setEditing] = useState<string | null>(null); // "city|quality"
|
const [editing, setEditing] = useState<string | null>(null); // "city|quality"
|
||||||
const [editSell, setEditSell] = useState('');
|
const [editSell, setEditSell] = useState('');
|
||||||
|
|
@ -92,7 +107,7 @@ export default function MarketPage() {
|
||||||
.then((d) => setPrices(d.prices ?? []));
|
.then((d) => setPrices(d.prices ?? []));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const select = (it: { unique_name: string; name_en: string | null }) => {
|
const select = (it: ItemHit) => {
|
||||||
setSelected(it);
|
setSelected(it);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setQ('');
|
setQ('');
|
||||||
|
|
@ -103,7 +118,7 @@ export default function MarketPage() {
|
||||||
await fetch('/api/market/refresh', {
|
await fetch('/api/market/refresh', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ minTier, maxTier }),
|
body: JSON.stringify({ minTier, maxTier, force }),
|
||||||
});
|
});
|
||||||
loadSummary();
|
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>)}
|
{[1, 2, 3, 4, 5, 6, 7, 8].map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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}>
|
<button className="primary" disabled={job?.status === 'running'} onClick={startRefresh}>
|
||||||
{job?.status === 'running' ? 'Refreshing…' : 'Download prices'}
|
{job?.status === 'running' ? 'Refreshing…' : 'Download prices'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -165,7 +183,12 @@ export default function MarketPage() {
|
||||||
{job.doneChunks}/{job.totalChunks} batches ({job.itemCount} items)
|
{job.doneChunks}/{job.totalChunks} batches ({job.itemCount} items)
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{job?.status === 'error' && (
|
{job?.status === 'error' && (
|
||||||
<p style={{ color: '#e08869' }}>
|
<p style={{ color: '#e08869' }}>
|
||||||
|
|
@ -187,7 +210,12 @@ export default function MarketPage() {
|
||||||
<tbody>
|
<tbody>
|
||||||
{results.slice(0, 10).map((r) => (
|
{results.slice(0, 10).map((r) => (
|
||||||
<tr key={r.unique_name} className="clickable" onClick={() => select(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>
|
<td className="muted">{r.unique_name}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
@ -198,7 +226,9 @@ export default function MarketPage() {
|
||||||
{selected && (
|
{selected && (
|
||||||
<>
|
<>
|
||||||
<h4>
|
<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>
|
</h4>
|
||||||
{prices.length === 0 && (
|
{prices.length === 0 && (
|
||||||
<p className="muted">No price data yet — run a price download above, or add a manual price.</p>
|
<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)}>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@ interface Settings {
|
||||||
availableServers: string[];
|
availableServers: string[];
|
||||||
proxyUrl: string;
|
proxyUrl: string;
|
||||||
gamePath: string;
|
gamePath: string;
|
||||||
|
priceCooldownMin: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [settings, setSettings] = useState<Settings | null>(null);
|
const [settings, setSettings] = useState<Settings | null>(null);
|
||||||
const [proxyUrl, setProxyUrl] = useState('');
|
const [proxyUrl, setProxyUrl] = useState('');
|
||||||
const [gamePath, setGamePath] = useState('');
|
const [gamePath, setGamePath] = useState('');
|
||||||
|
const [cooldown, setCooldown] = useState(15);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -20,10 +22,11 @@ export default function SettingsPage() {
|
||||||
setSettings(s);
|
setSettings(s);
|
||||||
setProxyUrl(s.proxyUrl);
|
setProxyUrl(s.proxyUrl);
|
||||||
setGamePath(s.gamePath);
|
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', {
|
await fetch('/api/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
@ -78,6 +81,19 @@ export default function SettingsPage() {
|
||||||
<button className="primary" onClick={() => save({ gamePath })}>Save</button>
|
<button className="primary" onClick={() => save({ gamePath })}>Save</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue