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:
parent
7ff3e6ec95
commit
9aebed4f55
8 changed files with 950 additions and 0 deletions
69
server/src/economy/config.ts
Normal file
69
server/src/economy/config.ts
Normal 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));
|
||||
}
|
||||
197
server/src/economy/engine.test.ts
Normal file
197
server/src/economy/engine.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
282
server/src/economy/engine.ts
Normal file
282
server/src/economy/engine.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
169
server/src/economy/service.ts
Normal file
169
server/src/economy/service.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
20
server/src/routes/economy.ts
Normal file
20
server/src/routes/economy.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
209
web/src/pages/OpportunitiesPage.tsx
Normal file
209
web/src/pages/OpportunitiesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue