From 9aebed4f55f53fdc25e815e3c959e5c8c9ed994c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=97=D0=B0=D0=B8=D0=B4=20=D0=9E=D0=BC=D0=B0=D1=80=20?= =?UTF-8?q?=D0=9C=D0=B5=D0=B4=D1=85=D0=B0=D1=82?= Date: Sun, 23 Aug 2026 03:07:40 +0500 Subject: [PATCH] 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 --- server/src/economy/config.ts | 69 +++++++ server/src/economy/engine.test.ts | 197 +++++++++++++++++++ server/src/economy/engine.ts | 282 ++++++++++++++++++++++++++++ server/src/economy/service.ts | 169 +++++++++++++++++ server/src/index.ts | 2 + server/src/routes/economy.ts | 20 ++ web/src/App.tsx | 2 + web/src/pages/OpportunitiesPage.tsx | 209 +++++++++++++++++++++ 8 files changed, 950 insertions(+) create mode 100644 server/src/economy/config.ts create mode 100644 server/src/economy/engine.test.ts create mode 100644 server/src/economy/engine.ts create mode 100644 server/src/economy/service.ts create mode 100644 server/src/routes/economy.ts create mode 100644 web/src/pages/OpportunitiesPage.tsx diff --git a/server/src/economy/config.ts b/server/src/economy/config.ts new file mode 100644 index 0000000..e26648c --- /dev/null +++ b/server/src/economy/config.ts @@ -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; + 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) }; + } catch { + return DEFAULT_ECONOMY_CONFIG; + } +} + +export function setEconomyConfig(partial: Partial): void { + setMeta('economy_config', JSON.stringify(partial)); +} diff --git a/server/src/economy/engine.test.ts b/server/src/economy/engine.test.ts new file mode 100644 index 0000000..1ea5553 --- /dev/null +++ b/server/src/economy/engine.test.ts @@ -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>; + recipes: RecipeData[]; + sell: Record; + buy?: Record; +}): 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); + }); +}); diff --git a/server/src/economy/engine.ts b/server/src/economy/engine.ts new file mode 100644 index 0000000..69470e0 --- /dev/null +++ b/server/src/economy/engine.ts @@ -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 = 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, +): 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, +): 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, +): 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, + }; +} diff --git a/server/src/economy/service.ts b/server/src/economy/service.ts new file mode 100644 index 0000000..e8ecf45 --- /dev/null +++ b/server/src/economy/service.ts @@ -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(); + const buyMap = new Map(); + 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(); + const recipeCache = new Map(); + + 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; +} + +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(); + 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; +} diff --git a/server/src/index.ts b/server/src/index.ts index 980d4d3..89eef91 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,6 +4,7 @@ import { getDb } from './db/index.js'; import { gamedataRoutes } from './routes/gamedata.js'; import { itemRoutes } from './routes/items.js'; import { marketRoutes } from './routes/market.js'; +import { economyRoutes } from './routes/economy.js'; const app = Fastify({ logger: { level: 'info' } }); @@ -13,6 +14,7 @@ app.get('/api/health', async () => ({ ok: true })); await app.register(gamedataRoutes); await app.register(itemRoutes); await app.register(marketRoutes); +await app.register(economyRoutes); await app.listen({ port: API_PORT, host: '127.0.0.1' }); console.log(`API listening on http://127.0.0.1:${API_PORT}`); diff --git a/server/src/routes/economy.ts b/server/src/routes/economy.ts new file mode 100644 index 0000000..546c9f0 --- /dev/null +++ b/server/src/routes/economy.ts @@ -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 { + app.get('/api/economy/config', async () => getEconomyConfig()); + + app.put('/api/economy/config', async (req) => { + setEconomyConfig((req.body ?? {}) as Partial); + 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 }; + }); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 1e8f55d..35b9673 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,8 +3,10 @@ import ItemsPage from './pages/ItemsPage'; import GameDataPage from './pages/GameDataPage'; import MarketPage from './pages/MarketPage'; import SettingsPage from './pages/SettingsPage'; +import OpportunitiesPage from './pages/OpportunitiesPage'; const PAGES = [ + { id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage }, { id: 'market', label: 'Market Data', component: MarketPage }, { id: 'items', label: 'Item Browser', component: ItemsPage }, { id: 'gamedata', label: 'Game Data', component: GameDataPage }, diff --git a/web/src/pages/OpportunitiesPage.tsx b/web/src/pages/OpportunitiesPage.tsx new file mode 100644 index 0000000..4f3181d --- /dev/null +++ b/web/src/pages/OpportunitiesPage.tsx @@ -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; + 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 {label}; +} + +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([]); + const [loading, setLoading] = useState(false); + const [expanded, setExpanded] = useState(null); + const [ranAt, setRanAt] = useState(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 ( +
+

Opportunities

+
+
+ + + + + + {useFocus && ( + + )} + + + + {ranAt && as of {ranAt}} +
+
+ +
+ {opps.length === 0 && !loading && ( +

+ No results — make sure prices are downloaded (Market Data page) for this city and tier range. +

+ )} + {opps.length > 0 && ( + + + + + + + + + + + + + + + + {sorted.map((o) => ( + <> + setExpanded(expanded === o.recipeId ? null : o.recipeId)}> + + + + + + + + + + + {expanded === o.recipeId && ( + + + + )} + + ))} + +
ItemType setSortBy('profit')}>Profit {sortBy === 'profit' && '▾'} setSortBy('roi')}>ROI {sortBy === 'roi' && '▾'} setSortBy('focus')}>Silver/focus {sortBy === 'focus' && '▾'}CostRevenueRRRPrice age
+ {o.outputName ?? o.outputItem} + {o.enchantment > 0 && .{o.enchantment}} + T{o.tier} + {o.source === 'upgrade' ? 'enchant' : 'craft'}= 0 ? '#7fd18a' : '#e08869', fontWeight: 600 }}>{silver(o.profit)}{(o.roi * 100).toFixed(1)}%{o.silverPerFocus != null ? o.silverPerFocus.toFixed(1) : '—'}{silver(o.totalCost)}{silver(o.revenue)}{(o.rrr * 100).toFixed(1)}%{o.outputPriceSource === 'manual' && manual}
+
+

+ 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)}% +

+ + + + + + {o.inputs.map((inp) => ( + + + + + + + + + + ))} + +
InputCountAfter returnsUnit costTotalSourceAge
{o.inputNames[inp.item] ?? inp.item}{inp.count}{inp.effectiveCount.toFixed(2)}{silver(inp.unitCost)}{silver(inp.totalCost)}{inp.method === 'craft' ? 'craft it yourself' : 'buy'}
+
+
+ )} +
+
+ ); +}