Phase 3: economy engine + opportunity ranker

Pure-math engine (additive return-rate factors, focus efficiency,
station fees from computed item values, recursive buy-vs-craft input
acquisition), patch-editable economy config, ranked opportunities API
and UI with per-recipe cost breakdowns. 11 unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Заид Омар Медхат 2026-08-23 03:07:40 +05:00
parent 7ff3e6ec95
commit 9aebed4f55
8 changed files with 950 additions and 0 deletions

View file

@ -0,0 +1,69 @@
import { getMeta, setMeta } from '../db/index.js';
/**
* Game-balance constants that change with patches. Defaults reflect the live game
* as of Aug 2026; everything here is user-editable (stored as an override in the db,
* PUT /api/economy/config).
*
* Return rate mechanic: bonuses are additive factors, and
* returnRate = 1 - 100 / (100 + sum of factors)
* e.g. base 18 => 15.2%, +15 specialty => 24.8%, +59 focus => 43.5%/47.9%,
* refining specialty is +40 => 36.7% (53.9% with focus).
*/
export interface EconomyConfig {
returnRate: {
cityBaseBonus: number;
craftingSpecialtyBonus: number;
refiningSpecialtyBonus: number;
focusBonus: number;
};
/** crafting_category values that count as refining (get the bigger specialty bonus) */
refiningCategories: string[];
/** city -> crafting_category values that get the specialty bonus there */
cityBonuses: Record<string, string[]>;
salesTaxPremium: number;
salesTaxNoPremium: number;
listingFee: number;
/** Crafting stations charge silver per 100 nutrition; nutrition = itemValue * 0.1125 */
nutritionPerItemValue: number;
defaultStationFeePer100: number;
}
export const DEFAULT_ECONOMY_CONFIG: EconomyConfig = {
returnRate: {
cityBaseBonus: 18,
craftingSpecialtyBonus: 15,
refiningSpecialtyBonus: 40,
focusBonus: 59,
},
refiningCategories: ['wood', 'ore', 'hide', 'fiber', 'rock'],
cityBonuses: {
'Fort Sterling': ['hammer', 'spear', 'holystaff', 'plate_helmet', 'cloth_armor', 'wood'],
Lymhurst: ['sword', 'bow', 'arcanestaff', 'leather_helmet', 'leather_shoes', 'fiber'],
Bridgewatch: ['crossbow', 'dagger', 'cursestaff', 'plate_armor', 'cloth_shoes', 'rock'],
Martlock: ['axe', 'quarterstaff', 'froststaff', 'plate_shoes', 'offhand', 'hide'],
Thetford: ['mace', 'firestaff', 'naturestaff', 'leather_armor', 'cloth_helmet', 'ore'],
// Caerleon/Brecilien specialties shift more often between patches — edit as needed.
Caerleon: ['knuckles', 'shapeshifterstaff', 'tools', 'gatherergear', 'food'],
Brecilien: ['cape', 'bag', 'potion'],
},
salesTaxPremium: 0.04,
salesTaxNoPremium: 0.08,
listingFee: 0.025,
nutritionPerItemValue: 0.1125,
defaultStationFeePer100: 400,
};
export function getEconomyConfig(): EconomyConfig {
const override = getMeta('economy_config');
if (!override) return DEFAULT_ECONOMY_CONFIG;
try {
return { ...DEFAULT_ECONOMY_CONFIG, ...(JSON.parse(override) as Partial<EconomyConfig>) };
} catch {
return DEFAULT_ECONOMY_CONFIG;
}
}
export function setEconomyConfig(partial: Partial<EconomyConfig>): void {
setMeta('economy_config', JSON.stringify(partial));
}

View file

@ -0,0 +1,197 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_ECONOMY_CONFIG as CFG } from './config.js';
import {
computeItemValue,
computeOpportunity,
craftCost,
focusCostMultiplier,
returnRate,
type CalcOptions,
type DataProvider,
type ItemData,
type PricePoint,
type RecipeData,
} from './engine.js';
function fakeProvider(opts: {
items: Record<string, Partial<ItemData>>;
recipes: RecipeData[];
sell: Record<string, number>;
buy?: Record<string, number>;
}): DataProvider {
const price = (p: number | undefined): PricePoint | null =>
p == null ? null : { price: p, ageHours: 1, source: 'api' };
return {
item: (name) =>
opts.items[name]
? { uniqueName: name, tier: 4, itemValue: null, craftingCategory: null, ...opts.items[name] }
: null,
recipesFor: (name) => opts.recipes.filter((r) => r.outputItem === name),
sellMin: (item) => price(opts.sell[item]),
buyMax: (item) => price(opts.buy?.[item]),
};
}
const BAG_RECIPE: RecipeData = {
id: 1,
outputItem: 'T4_BAG',
amountCrafted: 1,
silverCost: 0,
focusCost: 858,
source: 'crafting',
inputs: [
{ item: 'T4_CLOTH', count: 8, maxReturnAmount: null },
{ item: 'T4_LEATHER', count: 8, maxReturnAmount: null },
],
};
const baseOpts: CalcOptions = {
city: 'Martlock',
useFocus: false,
focusEfficiency: 0,
premium: true,
stationFeePer100: 400,
sellMode: 'order',
maxCraftDepth: 0,
};
describe('returnRate', () => {
it('matches the known in-game percentages', () => {
// base 18 => 15.2%
expect(returnRate(CFG, 'Caerleon', 'sword', false)).toBeCloseTo(0.1525, 3);
// crafting specialty 18+15 => 24.8%
expect(returnRate(CFG, 'Lymhurst', 'sword', false)).toBeCloseTo(0.2481, 3);
// refining specialty 18+40 => 36.7%
expect(returnRate(CFG, 'Fort Sterling', 'wood', false)).toBeCloseTo(0.3671, 3);
// focus, no specialty 18+59 => 43.5%
expect(returnRate(CFG, 'Caerleon', 'sword', true)).toBeCloseTo(0.435, 3);
// focus + crafting specialty 18+15+59 => 47.9%
expect(returnRate(CFG, 'Lymhurst', 'sword', true)).toBeCloseTo(0.4792, 3);
// focus + refining specialty 18+40+59 => 53.9%
expect(returnRate(CFG, 'Fort Sterling', 'wood', true)).toBeCloseTo(0.5392, 3);
});
});
describe('focusCostMultiplier', () => {
it('halves focus cost per 10000 efficiency', () => {
expect(focusCostMultiplier(0)).toBe(1);
expect(focusCostMultiplier(10000)).toBeCloseTo(0.5, 10);
expect(focusCostMultiplier(20000)).toBeCloseTo(0.25, 10);
});
});
describe('computeItemValue', () => {
it('sums input item values recursively when the item has none', () => {
const provider = fakeProvider({
items: {
T4_BAG: {},
T4_CLOTH: { itemValue: 16 },
T4_LEATHER: { itemValue: 16 },
},
recipes: [BAG_RECIPE],
sell: {},
});
expect(computeItemValue(provider, 'T4_BAG')).toBe(8 * 16 + 8 * 16); // 256
});
});
describe('craftCost', () => {
const provider = fakeProvider({
items: {
T4_BAG: { craftingCategory: 'bag' },
T4_CLOTH: { itemValue: 16 },
T4_LEATHER: { itemValue: 16 },
},
recipes: [BAG_RECIPE],
sell: { T4_CLOTH: 100, T4_LEATHER: 120 },
});
it('applies return rate to input counts and adds the station fee', () => {
const cost = craftCost(provider, CFG, BAG_RECIPE, baseOpts, 0, new Map())!;
const rrr = 1 - 100 / 118; // Martlock has no bag bonus
const expectedInputs = 8 * (1 - rrr) * 100 + 8 * (1 - rrr) * 120;
const expectedFee = 256 * 0.1125 * (400 / 100); // itemValue*nutrition*fee
expect(cost.rrr).toBeCloseTo(rrr, 10);
expect(cost.totalCost).toBeCloseTo(expectedInputs + expectedFee, 6);
});
it('gets the specialty bonus in the right city (Brecilien for bags)', () => {
const cost = craftCost(provider, CFG, BAG_RECIPE, { ...baseOpts, city: 'Brecilien' }, 0, new Map())!;
expect(cost.rrr).toBeCloseTo(1 - 100 / 133, 10); // 24.8%
});
it('never returns non-returnable inputs (maxReturnAmount 0)', () => {
const artifactRecipe: RecipeData = {
...BAG_RECIPE,
id: 2,
inputs: [{ item: 'T4_CLOTH', count: 1, maxReturnAmount: 0 }],
};
const cost = craftCost(provider, CFG, artifactRecipe, baseOpts, 0, new Map())!;
expect(cost.inputs[0].effectiveCount).toBe(1);
});
it('returns null when an input is unobtainable', () => {
const noPrices = fakeProvider({ items: { T4_BAG: {}, T4_CLOTH: { itemValue: 16 }, T4_LEATHER: { itemValue: 16 } }, recipes: [BAG_RECIPE], sell: { T4_CLOTH: 100 } });
expect(craftCost(noPrices, CFG, BAG_RECIPE, baseOpts, 0, new Map())).toBeNull();
});
});
describe('acquisition (buy vs craft)', () => {
it('crafts an input itself when cheaper than buying it', () => {
const refineRecipe: RecipeData = {
id: 10,
outputItem: 'T4_CLOTH',
amountCrafted: 1,
silverCost: 0,
focusCost: 20,
source: 'crafting',
inputs: [{ item: 'T4_FIBER', count: 2, maxReturnAmount: null }],
};
const provider = fakeProvider({
items: {
T4_BAG: { craftingCategory: 'bag' },
T4_CLOTH: { itemValue: 16, craftingCategory: 'fiber' },
T4_LEATHER: { itemValue: 16 },
T4_FIBER: { itemValue: 4 },
},
recipes: [BAG_RECIPE, refineRecipe],
sell: { T4_CLOTH: 1000, T4_LEATHER: 120, T4_FIBER: 10 },
});
// With depth 1 the engine may refine fiber (≈ tiny cost) instead of buying cloth at 1000.
const withCraft = craftCost(provider, CFG, BAG_RECIPE, baseOpts, 1, new Map())!;
const buyOnly = craftCost(provider, CFG, BAG_RECIPE, baseOpts, 0, new Map())!;
expect(withCraft.totalCost).toBeLessThan(buyOnly.totalCost);
expect(withCraft.inputs.find((i) => i.item === 'T4_CLOTH')!.method).toBe('craft');
});
});
describe('computeOpportunity', () => {
const provider = fakeProvider({
items: {
T4_BAG: { craftingCategory: 'bag' },
T4_CLOTH: { itemValue: 16 },
T4_LEATHER: { itemValue: 16 },
},
recipes: [BAG_RECIPE],
sell: { T4_BAG: 5000, T4_CLOTH: 100, T4_LEATHER: 120 },
buy: { T4_BAG: 4500 },
});
it('computes revenue after premium tax + listing fee in order mode', () => {
const opp = computeOpportunity(provider, CFG, BAG_RECIPE, baseOpts, new Map())!;
expect(opp.revenue).toBeCloseTo(5000 * (1 - 0.04 - 0.025), 6);
expect(opp.profit).toBeCloseTo(opp.revenue - opp.totalCost, 6);
});
it('uses buy orders and skips the listing fee in instant mode', () => {
const opp = computeOpportunity(provider, CFG, BAG_RECIPE, { ...baseOpts, sellMode: 'instant' }, new Map())!;
expect(opp.revenue).toBeCloseTo(4500 * (1 - 0.04), 6);
});
it('reports positive silver-per-focus when focus saves resources', () => {
const opp = computeOpportunity(provider, CFG, BAG_RECIPE, { ...baseOpts, useFocus: true }, new Map())!;
expect(opp.focusUsed).toBe(858);
expect(opp.silverPerFocus).not.toBeNull();
expect(opp.silverPerFocus!).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,282 @@
import type { EconomyConfig } from './config.js';
/**
* Pure profit math — no database access. The service layer wires a DataProvider
* backed by SQLite; tests wire fake data.
*/
export interface ItemData {
uniqueName: string;
tier: number | null;
itemValue: number | null;
craftingCategory: string | null;
}
export interface RecipeInputData {
item: string;
count: number;
/** 0 = never returned by RRR (artifacts, upgrade inputs); null = normal returnable resource */
maxReturnAmount: number | null;
}
export interface RecipeData {
id: number;
outputItem: string;
amountCrafted: number;
silverCost: number;
focusCost: number;
source: 'crafting' | 'upgrade';
inputs: RecipeInputData[];
}
export interface PricePoint {
price: number;
ageHours: number | null;
source: 'api' | 'manual';
}
export interface DataProvider {
item(name: string): ItemData | null;
recipesFor(item: string): RecipeData[];
/** cheapest sell order (what you pay to buy instantly) at quality 1 */
sellMin(item: string, city: string): PricePoint | null;
/** highest buy order (what you get selling instantly) at quality 1 */
buyMax(item: string, city: string): PricePoint | null;
}
export interface CalcOptions {
city: string;
useFocus: boolean;
/** destiny-board focus efficiency points (0 = none); halves focus cost per 10000 */
focusEfficiency: number;
premium: boolean;
stationFeePer100: number;
/** 'order': list a sell order (pay tax + listing fee); 'instant': sell to buy orders (tax only) */
sellMode: 'order' | 'instant';
/** how many crafting steps deep to consider crafting the inputs yourself */
maxCraftDepth: number;
}
// ---------------------------------------------------------------------------
export function returnRate(cfg: EconomyConfig, city: string, craftingCategory: string | null, useFocus: boolean): number {
let bonus = cfg.returnRate.cityBaseBonus;
if (craftingCategory && cfg.cityBonuses[city]?.includes(craftingCategory)) {
bonus += cfg.refiningCategories.includes(craftingCategory)
? cfg.returnRate.refiningSpecialtyBonus
: cfg.returnRate.craftingSpecialtyBonus;
}
if (useFocus) bonus += cfg.returnRate.focusBonus;
return 1 - 100 / (100 + bonus);
}
export function focusCostMultiplier(focusEfficiency: number): number {
return Math.pow(0.5, focusEfficiency / 10000);
}
/**
* Item value drives station fees. Where the game data has no explicit value
* (most equipment), it is the sum of the crafting inputs' item values.
*/
export function computeItemValue(provider: DataProvider, item: string, seen: Set<string> = new Set()): number {
const it = provider.item(item);
if (!it) return 0;
if (it.itemValue != null) return it.itemValue;
if (seen.has(item)) return 0;
seen.add(item);
const recipe = provider.recipesFor(item).find((r) => r.source === 'crafting');
if (!recipe) return 0;
const total = recipe.inputs.reduce((sum, inp) => sum + inp.count * computeItemValue(provider, inp.item, seen), 0);
return total / recipe.amountCrafted;
}
export function stationFee(cfg: EconomyConfig, itemValue: number, stationFeePer100: number, amountCrafted: number): number {
const nutrition = itemValue * cfg.nutritionPerItemValue * amountCrafted;
return (nutrition * stationFeePer100) / 100;
}
// ---------------------------------------------------------------------------
export interface AcquisitionResult {
unitCost: number;
method: 'buy' | 'craft';
priceAgeHours: number | null;
priceSource: 'api' | 'manual' | null;
}
/**
* Cheapest way to obtain one unit of an item in a city: buy from the market or
* craft it (recursively, without focus — focus is only modeled on the top-level
* craft where the caller decides to spend it).
*/
export function acquisitionCost(
provider: DataProvider,
cfg: EconomyConfig,
item: string,
opts: CalcOptions,
depth: number,
cache: Map<string, AcquisitionResult | null>,
): AcquisitionResult | null {
const cacheKey = `${item}|${depth > 0 ? 1 : 0}`;
if (cache.has(cacheKey)) return cache.get(cacheKey)!;
cache.set(cacheKey, null); // cycle guard
const buy = provider.sellMin(item, opts.city);
let best: AcquisitionResult | null = buy
? { unitCost: buy.price, method: 'buy', priceAgeHours: buy.ageHours, priceSource: buy.source }
: null;
if (depth > 0) {
for (const recipe of provider.recipesFor(item)) {
const cost = craftCost(provider, cfg, recipe, { ...opts, useFocus: false }, depth - 1, cache);
if (cost == null) continue;
const unit = cost.totalCost / recipe.amountCrafted;
if (best == null || unit < best.unitCost) {
best = { unitCost: unit, method: 'craft', priceAgeHours: null, priceSource: null };
}
}
}
cache.set(cacheKey, best);
return best;
}
export interface CraftInputBreakdown {
item: string;
count: number;
effectiveCount: number;
unitCost: number;
totalCost: number;
method: 'buy' | 'craft';
priceAgeHours: number | null;
priceSource: 'api' | 'manual' | null;
}
export interface CraftCostResult {
totalCost: number; // for the whole craft (amountCrafted units)
inputs: CraftInputBreakdown[];
fee: number;
silverCost: number;
rrr: number;
}
export function craftCost(
provider: DataProvider,
cfg: EconomyConfig,
recipe: RecipeData,
opts: CalcOptions,
depth: number,
cache: Map<string, AcquisitionResult | null>,
): CraftCostResult | null {
const output = provider.item(recipe.outputItem);
// Upgrade (enchanting) recipes consume everything; RRR only applies to real crafting.
const rrr = recipe.source === 'crafting' ? returnRate(cfg, opts.city, output?.craftingCategory ?? null, opts.useFocus) : 0;
const inputs: CraftInputBreakdown[] = [];
let inputTotal = 0;
for (const inp of recipe.inputs) {
const acq = acquisitionCost(provider, cfg, inp.item, opts, depth, cache);
if (acq == null) return null; // an input has no obtainable price -> can't cost this recipe
const returnable = inp.maxReturnAmount !== 0;
const effectiveCount = returnable ? inp.count * (1 - rrr) : inp.count;
const totalCost = effectiveCount * acq.unitCost;
inputTotal += totalCost;
inputs.push({
item: inp.item,
count: inp.count,
effectiveCount,
unitCost: acq.unitCost,
totalCost,
method: acq.method,
priceAgeHours: acq.priceAgeHours,
priceSource: acq.priceSource,
});
}
const iv = computeItemValue(provider, recipe.outputItem);
const fee = stationFee(cfg, iv, opts.stationFeePer100, recipe.amountCrafted);
return {
totalCost: inputTotal + fee + recipe.silverCost,
inputs,
fee,
silverCost: recipe.silverCost,
rrr,
};
}
// ---------------------------------------------------------------------------
export interface Opportunity {
recipeId: number;
outputItem: string;
source: 'crafting' | 'upgrade';
city: string;
amountCrafted: number;
revenue: number;
totalCost: number;
profit: number;
roi: number; // profit / cost
rrr: number;
focusUsed: number;
silverPerFocus: number | null; // extra profit per focus point vs crafting without focus
outputPrice: number;
outputPriceAgeHours: number | null;
outputPriceSource: 'api' | 'manual';
inputs: CraftInputBreakdown[];
fee: number;
}
export function computeOpportunity(
provider: DataProvider,
cfg: EconomyConfig,
recipe: RecipeData,
opts: CalcOptions,
cache: Map<string, AcquisitionResult | null>,
): Opportunity | null {
const out =
opts.sellMode === 'order'
? provider.sellMin(recipe.outputItem, opts.city)
: provider.buyMax(recipe.outputItem, opts.city);
if (!out) return null;
const tax = opts.premium ? cfg.salesTaxPremium : cfg.salesTaxNoPremium;
const feeRate = opts.sellMode === 'order' ? tax + cfg.listingFee : tax;
const revenue = out.price * recipe.amountCrafted * (1 - feeRate);
const cost = craftCost(provider, cfg, recipe, opts, opts.maxCraftDepth, cache);
if (cost == null) return null;
const profit = revenue - cost.totalCost;
let focusUsed = 0;
let silverPerFocus: number | null = null;
if (opts.useFocus && recipe.focusCost > 0) {
focusUsed = recipe.focusCost * focusCostMultiplier(opts.focusEfficiency);
// acquisition costs are focus-independent (sub-crafts never spend focus), so the cache is shared
const noFocus = craftCost(provider, cfg, recipe, { ...opts, useFocus: false }, opts.maxCraftDepth, cache);
if (noFocus != null && focusUsed > 0) {
const profitNoFocus = revenue - noFocus.totalCost;
silverPerFocus = (profit - profitNoFocus) / focusUsed;
}
}
return {
recipeId: recipe.id,
outputItem: recipe.outputItem,
source: recipe.source,
city: opts.city,
amountCrafted: recipe.amountCrafted,
revenue,
totalCost: cost.totalCost,
profit,
roi: cost.totalCost > 0 ? profit / cost.totalCost : 0,
rrr: cost.rrr,
focusUsed,
silverPerFocus,
outputPrice: out.price,
outputPriceAgeHours: out.ageHours,
outputPriceSource: out.source,
inputs: cost.inputs,
fee: cost.fee,
};
}

View file

@ -0,0 +1,169 @@
import { getDb } from '../db/index.js';
import { getEconomyConfig } from './config.js';
import {
computeOpportunity,
type AcquisitionResult,
type CalcOptions,
type DataProvider,
type ItemData,
type Opportunity,
type PricePoint,
type RecipeData,
} from './engine.js';
function ageHours(iso: string | null): number | null {
if (!iso) return null;
const t = Date.parse(iso);
return Number.isFinite(t) ? Math.max(0, (Date.now() - t) / 3600000) : null;
}
/**
* DataProvider backed by SQLite. Prices for the requested city are preloaded
* into maps (quality 1, manual overrides taking precedence); items/recipes are
* loaded lazily with memoization.
*/
export function buildProvider(city: string): DataProvider {
const db = getDb();
const sellMap = new Map<string, PricePoint>();
const buyMap = new Map<string, PricePoint>();
const priceRows = db
.prepare('SELECT item, sell_price_min, sell_min_date, buy_price_max, buy_max_date FROM market_prices WHERE city = ? AND quality = 1')
.all(city) as any[];
for (const r of priceRows) {
if (r.sell_price_min != null) sellMap.set(r.item, { price: r.sell_price_min, ageHours: ageHours(r.sell_min_date), source: 'api' });
if (r.buy_price_max != null) buyMap.set(r.item, { price: r.buy_price_max, ageHours: ageHours(r.buy_max_date), source: 'api' });
}
const overrideRows = db
.prepare('SELECT item, sell_price, buy_price, updated_at FROM price_overrides WHERE city = ? AND quality = 1')
.all(city) as any[];
for (const o of overrideRows) {
if (o.sell_price != null) sellMap.set(o.item, { price: o.sell_price, ageHours: ageHours(o.updated_at), source: 'manual' });
if (o.buy_price != null) buyMap.set(o.item, { price: o.buy_price, ageHours: ageHours(o.updated_at), source: 'manual' });
}
const itemStmt = db.prepare('SELECT unique_name, tier, item_value, crafting_category FROM items WHERE unique_name = ?');
const recipesStmt = db.prepare('SELECT * FROM recipes WHERE output_item = ?');
const inputsStmt = db.prepare('SELECT input_item, count, max_return_amount FROM recipe_inputs WHERE recipe_id = ?');
const itemCache = new Map<string, ItemData | null>();
const recipeCache = new Map<string, RecipeData[]>();
return {
item(name: string): ItemData | null {
if (itemCache.has(name)) return itemCache.get(name)!;
const row = itemStmt.get(name) as any;
const data: ItemData | null = row
? { uniqueName: row.unique_name, tier: row.tier, itemValue: row.item_value, craftingCategory: row.crafting_category }
: null;
itemCache.set(name, data);
return data;
},
recipesFor(name: string): RecipeData[] {
if (recipeCache.has(name)) return recipeCache.get(name)!;
const rows = recipesStmt.all(name) as any[];
const recipes: RecipeData[] = rows.map((r) => ({
id: r.id,
outputItem: r.output_item,
amountCrafted: r.amount_crafted,
silverCost: r.silver_cost,
focusCost: r.focus_cost,
source: r.source,
inputs: (inputsStmt.all(r.id) as any[]).map((i) => ({
item: i.input_item,
count: i.count,
maxReturnAmount: i.max_return_amount,
})),
}));
recipeCache.set(name, recipes);
return recipes;
},
sellMin: (item) => sellMap.get(item) ?? null,
buyMax: (item) => buyMap.get(item) ?? null,
};
}
export interface RankRequest {
city: string;
minTier?: number;
maxTier?: number;
categories?: string[]; // crafting_category filter
useFocus?: boolean;
focusEfficiency?: number;
premium?: boolean;
stationFeePer100?: number;
sellMode?: 'order' | 'instant';
maxCraftDepth?: number;
limit?: number;
}
export interface RankedOpportunity extends Opportunity {
outputName: string | null;
tier: number | null;
enchantment: number;
craftingCategory: string | null;
inputNames: Record<string, string>;
}
export function rankOpportunities(req: RankRequest): RankedOpportunity[] {
const cfg = getEconomyConfig();
const provider = buildProvider(req.city);
const opts: CalcOptions = {
city: req.city,
useFocus: req.useFocus ?? false,
focusEfficiency: req.focusEfficiency ?? 0,
premium: req.premium ?? true,
stationFeePer100: req.stationFeePer100 ?? cfg.defaultStationFeePer100,
sellMode: req.sellMode ?? 'order',
maxCraftDepth: req.maxCraftDepth ?? 2,
};
const db = getDb();
const params: unknown[] = [req.minTier ?? 2, req.maxTier ?? 8];
let categoryFilter = '';
if (req.categories && req.categories.length > 0) {
categoryFilter = ` AND i.crafting_category IN (${req.categories.map(() => '?').join(',')})`;
params.push(...req.categories);
}
const candidates = db
.prepare(
`SELECT r.id, r.output_item, i.name_en, i.tier, i.enchantment, i.crafting_category
FROM recipes r
JOIN items i ON i.unique_name = r.output_item
WHERE i.tier BETWEEN ? AND ?${categoryFilter}`,
)
.all(...params) as { id: number; output_item: string; name_en: string | null; tier: number | null; enchantment: number; crafting_category: string | null }[];
const cache = new Map<string, AcquisitionResult | null>();
const results: RankedOpportunity[] = [];
for (const c of candidates) {
const recipe = provider.recipesFor(c.output_item).find((r) => r.id === c.id);
if (!recipe) continue;
const opp = computeOpportunity(provider, cfg, recipe, opts, cache);
if (!opp) continue;
results.push({
...opp,
outputName: c.name_en,
tier: c.tier,
enchantment: c.enchantment,
craftingCategory: c.crafting_category,
inputNames: {},
});
}
results.sort((a, b) => b.profit - a.profit);
const top = results.slice(0, req.limit ?? 200);
// resolve input display names only for the returned rows
const nameStmt = db.prepare('SELECT name_en FROM items WHERE unique_name = ?');
for (const r of top) {
for (const inp of r.inputs) {
if (r.inputNames[inp.item]) continue;
const it = nameStmt.get(inp.item) as { name_en: string | null } | undefined;
if (it?.name_en) r.inputNames[inp.item] = it.name_en;
}
}
return top;
}

View file

@ -4,6 +4,7 @@ import { getDb } from './db/index.js';
import { gamedataRoutes } from './routes/gamedata.js'; import { gamedataRoutes } from './routes/gamedata.js';
import { itemRoutes } from './routes/items.js'; import { itemRoutes } from './routes/items.js';
import { marketRoutes } from './routes/market.js'; import { marketRoutes } from './routes/market.js';
import { economyRoutes } from './routes/economy.js';
const app = Fastify({ logger: { level: 'info' } }); const app = Fastify({ logger: { level: 'info' } });
@ -13,6 +14,7 @@ app.get('/api/health', async () => ({ ok: true }));
await app.register(gamedataRoutes); await app.register(gamedataRoutes);
await app.register(itemRoutes); await app.register(itemRoutes);
await app.register(marketRoutes); await app.register(marketRoutes);
await app.register(economyRoutes);
await app.listen({ port: API_PORT, host: '127.0.0.1' }); await app.listen({ port: API_PORT, host: '127.0.0.1' });
console.log(`API listening on http://127.0.0.1:${API_PORT}`); console.log(`API listening on http://127.0.0.1:${API_PORT}`);

View file

@ -0,0 +1,20 @@
import type { FastifyInstance } from 'fastify';
import { getEconomyConfig, setEconomyConfig, type EconomyConfig } from '../economy/config.js';
import { rankOpportunities, type RankRequest } from '../economy/service.js';
export async function economyRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/economy/config', async () => getEconomyConfig());
app.put('/api/economy/config', async (req) => {
setEconomyConfig((req.body ?? {}) as Partial<EconomyConfig>);
return { ok: true, config: getEconomyConfig() };
});
app.post('/api/economy/opportunities', async (req, reply) => {
const body = (req.body ?? {}) as RankRequest;
if (!body.city) return reply.code(400).send({ error: 'city is required' });
const started = Date.now();
const opportunities = rankOpportunities(body);
return { opportunities, tookMs: Date.now() - started };
});
}

View file

@ -3,8 +3,10 @@ import ItemsPage from './pages/ItemsPage';
import GameDataPage from './pages/GameDataPage'; import GameDataPage from './pages/GameDataPage';
import MarketPage from './pages/MarketPage'; import MarketPage from './pages/MarketPage';
import SettingsPage from './pages/SettingsPage'; import SettingsPage from './pages/SettingsPage';
import OpportunitiesPage from './pages/OpportunitiesPage';
const PAGES = [ const PAGES = [
{ id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage },
{ id: 'market', label: 'Market Data', component: MarketPage }, { id: 'market', label: 'Market Data', component: MarketPage },
{ id: 'items', label: 'Item Browser', component: ItemsPage }, { id: 'items', label: 'Item Browser', component: ItemsPage },
{ id: 'gamedata', label: 'Game Data', component: GameDataPage }, { id: 'gamedata', label: 'Game Data', component: GameDataPage },

View file

@ -0,0 +1,209 @@
import { useEffect, useState } from 'react';
interface InputBreakdown {
item: string;
count: number;
effectiveCount: number;
unitCost: number;
totalCost: number;
method: 'buy' | 'craft';
priceAgeHours: number | null;
priceSource: string | null;
}
interface Opp {
recipeId: number;
outputItem: string;
outputName: string | null;
source: 'crafting' | 'upgrade';
tier: number | null;
enchantment: number;
craftingCategory: string | null;
city: string;
amountCrafted: number;
revenue: number;
totalCost: number;
profit: number;
roi: number;
rrr: number;
focusUsed: number;
silverPerFocus: number | null;
outputPrice: number;
outputPriceAgeHours: number | null;
outputPriceSource: string;
inputs: InputBreakdown[];
inputNames: Record<string, string>;
fee: number;
}
const CITIES = ['Bridgewatch', 'Caerleon', 'Fort Sterling', 'Lymhurst', 'Martlock', 'Thetford', 'Brecilien', 'Black Market'];
const silver = (n: number) => Math.round(n).toLocaleString('en-US');
function Age({ hours }: { hours: number | null }) {
if (hours == null) return null;
const label = hours < 1 ? `${Math.round(hours * 60)}m` : hours < 48 ? `${Math.round(hours)}h` : `${Math.round(hours / 24)}d`;
const color = hours < 6 ? '#7fd18a' : hours < 24 ? '#e0c069' : '#e08869';
return <span className="tag" style={{ color }}>{label}</span>;
}
export default function OpportunitiesPage() {
const [city, setCity] = useState('Lymhurst');
const [minTier, setMinTier] = useState(4);
const [maxTier, setMaxTier] = useState(6);
const [useFocus, setUseFocus] = useState(false);
const [premium, setPremium] = useState(true);
const [sellMode, setSellMode] = useState<'order' | 'instant'>('order');
const [stationFee, setStationFee] = useState(400);
const [focusEff, setFocusEff] = useState(0);
const [sortBy, setSortBy] = useState<'profit' | 'roi' | 'focus'>('profit');
const [opps, setOpps] = useState<Opp[]>([]);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState<number | null>(null);
const [ranAt, setRanAt] = useState<string | null>(null);
const run = async () => {
setLoading(true);
setExpanded(null);
try {
const res = await fetch('/api/economy/opportunities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
city,
minTier,
maxTier,
useFocus,
premium,
sellMode,
stationFeePer100: stationFee,
focusEfficiency: focusEff,
limit: 200,
}),
});
const data = await res.json();
setOpps(data.opportunities ?? []);
setRanAt(new Date().toLocaleTimeString());
} finally {
setLoading(false);
}
};
useEffect(() => {
run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sorted = [...opps].sort((a, b) =>
sortBy === 'profit' ? b.profit - a.profit : sortBy === 'roi' ? b.roi - a.roi : (b.silverPerFocus ?? -1e18) - (a.silverPerFocus ?? -1e18),
);
return (
<div>
<h2>Opportunities</h2>
<div className="card">
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<label>City <select value={city} onChange={(e) => setCity(e.target.value)}>
{CITIES.map((c) => <option key={c}>{c}</option>)}
</select></label>
<label>Tier <select value={minTier} onChange={(e) => setMinTier(Number(e.target.value))}>
{[2, 3, 4, 5, 6, 7, 8].map((t) => <option key={t} value={t}>{t}</option>)}
</select></label>
<label>to <select value={maxTier} onChange={(e) => setMaxTier(Number(e.target.value))}>
{[2, 3, 4, 5, 6, 7, 8].map((t) => <option key={t} value={t}>{t}</option>)}
</select></label>
<label>Sell <select value={sellMode} onChange={(e) => setSellMode(e.target.value as 'order' | 'instant')}>
<option value="order">sell order</option>
<option value="instant">instant (to buy orders)</option>
</select></label>
<label><input type="checkbox" checked={useFocus} onChange={(e) => setUseFocus(e.target.checked)} /> use focus</label>
{useFocus && (
<label>focus efficiency <input type="number" value={focusEff} style={{ width: 90 }}
onChange={(e) => setFocusEff(Number(e.target.value))} /></label>
)}
<label><input type="checkbox" checked={premium} onChange={(e) => setPremium(e.target.checked)} /> premium</label>
<label>station fee <input type="number" value={stationFee} style={{ width: 80 }}
onChange={(e) => setStationFee(Number(e.target.value))} /></label>
<button className="primary" disabled={loading} onClick={run}>{loading ? 'Computing…' : 'Compute'}</button>
{ranAt && <span className="muted">as of {ranAt}</span>}
</div>
</div>
<div className="card">
{opps.length === 0 && !loading && (
<p className="muted">
No results — make sure prices are downloaded (Market Data page) for this city and tier range.
</p>
)}
{opps.length > 0 && (
<table>
<thead>
<tr>
<th>Item</th>
<th>Type</th>
<th style={{ cursor: 'pointer' }} onClick={() => setSortBy('profit')}>Profit {sortBy === 'profit' && '▾'}</th>
<th style={{ cursor: 'pointer' }} onClick={() => setSortBy('roi')}>ROI {sortBy === 'roi' && '▾'}</th>
<th style={{ cursor: 'pointer' }} onClick={() => setSortBy('focus')}>Silver/focus {sortBy === 'focus' && '▾'}</th>
<th>Cost</th>
<th>Revenue</th>
<th>RRR</th>
<th>Price age</th>
</tr>
</thead>
<tbody>
{sorted.map((o) => (
<>
<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>
</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>{(o.roi * 100).toFixed(1)}%</td>
<td>{o.silverPerFocus != null ? o.silverPerFocus.toFixed(1) : '—'}</td>
<td>{silver(o.totalCost)}</td>
<td>{silver(o.revenue)}</td>
<td>{(o.rrr * 100).toFixed(1)}%</td>
<td><Age hours={o.outputPriceAgeHours} />{o.outputPriceSource === 'manual' && <span className="tag" style={{ color: '#7fb3ff' }}>manual</span>}</td>
</tr>
{expanded === o.recipeId && (
<tr key={`${o.recipeId}-detail`}>
<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)} ·
{o.focusUsed > 0 ? ` uses ${Math.round(o.focusUsed)} focus ·` : ''} return rate {(o.rrr * 100).toFixed(1)}%
</p>
<table>
<thead>
<tr><th>Input</th><th>Count</th><th>After returns</th><th>Unit cost</th><th>Total</th><th>Source</th><th>Age</th></tr>
</thead>
<tbody>
{o.inputs.map((inp) => (
<tr key={inp.item}>
<td>{o.inputNames[inp.item] ?? inp.item}</td>
<td>{inp.count}</td>
<td>{inp.effectiveCount.toFixed(2)}</td>
<td>{silver(inp.unitCost)}</td>
<td>{silver(inp.totalCost)}</td>
<td className="muted">{inp.method === 'craft' ? 'craft it yourself' : 'buy'}</td>
<td><Age hours={inp.priceAgeHours} /></td>
</tr>
))}
</tbody>
</table>
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
)}
</div>
</div>
);
}