diff --git a/src/App.vue b/src/App.vue index 4cfdd12..d86af66 100644 --- a/src/App.vue +++ b/src/App.vue @@ -18,9 +18,9 @@
+ @toggle-variant="toggleVariant" @reset-variants="resetVariants" />
@@ -66,10 +66,8 @@ const sortState = ref({ field: 'materialCost', direction: 'asc' }) // Filters const { filters, - setCity, toggleTier, setSelectedItemTypes, - setRrr, setNameFilter, toggleEnchantment, resetEnchantments, diff --git a/src/components/filters/FilterBar.vue b/src/components/filters/FilterBar.vue index ce3ad99..4a057f1 100644 --- a/src/components/filters/FilterBar.vue +++ b/src/components/filters/FilterBar.vue @@ -131,29 +131,6 @@ - -
- City - -
- -
- - -
- RRR - - % -
- - {{ resultCount }} results @@ -166,9 +143,8 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import { tierStyle, enchantStyle } from '../../utils/formatting' import type { FilterState, VariantType } from '../../types/filters' import { ALL_VARIANTS } from '../../types/filters' -import type { AlbionCity } from '../../types/api' import type { Tier, Enchantment } from '../../types/crafting' -import { TIERS, CITIES, ENCHANTMENTS } from '../../data/constants' +import { TIERS, ENCHANTMENTS } from '../../data/constants' import { ITEM_TREE, ALL_ITEM_NAMES, getLeaves } from '../../data/itemTree' import type { TreeNode } from '../../data/itemTree' @@ -179,14 +155,12 @@ const props = defineProps<{ const emit = defineEmits<{ 'set-name-filter': [value: string] - 'set-city': [city: AlbionCity] 'toggle-tier': [tier: Tier] 'set-selected-item-types': [v: Set | null] 'toggle-enchantment': [enc: Enchantment] 'reset-enchantments': [] 'toggle-variant': [v: VariantType] 'reset-variants': [] - 'set-rrr': [rate: number] }>() // ─── Category tree ──────────────────────────────────────────────────────────── diff --git a/src/components/layout/AppHeader.vue b/src/components/layout/AppHeader.vue index f8c6ffb..c24319e 100644 --- a/src/components/layout/AppHeader.vue +++ b/src/components/layout/AppHeader.vue @@ -9,12 +9,28 @@ {{ totalRecipes }} recipes
+ + +
+ City + +
diff --git a/src/components/table/ProfitRow.vue b/src/components/table/ProfitRow.vue index 7b48bd8..41f23af 100644 --- a/src/components/table/ProfitRow.vue +++ b/src/components/table/ProfitRow.vue @@ -185,7 +185,7 @@ {{ formatSilver(result.materialCost) }}
- RRR ({{ filters.rrr }}%) + RRR ({{ result.rrr.toFixed(1) }}%) −{{ formatSilver(result.materialCost - result.effectiveMaterialCost) }}
diff --git a/src/composables/useCraftingProfit.ts b/src/composables/useCraftingProfit.ts index 1c3b04f..3e7ea7a 100644 --- a/src/composables/useCraftingProfit.ts +++ b/src/composables/useCraftingProfit.ts @@ -4,6 +4,7 @@ import type { CraftingRecipe, ProfitResult, IngredientBreakdown, SortState } fro import type { FilterState, VariantType } from '../types/filters' import { useAlbionPrices } from './useAlbionPrices' import { formatItemId } from '../utils/formatting' +import { cityRrr, isRrrExempt } from '../data/cityBonuses' function variantOf(outputItemId: string): VariantType { const id = outputItemId.replace(/@\d$/, '') // strip enchantment suffix @@ -30,7 +31,6 @@ export function useCraftingProfit( const profitResults = computed(() => { const f = filters.value const city = f.city - const rrrFactor = 1 - f.rrr / 100 const results: ProfitResult[] = [] const nameLower = f.nameFilter.trim().toLowerCase() @@ -44,7 +44,8 @@ export function useCraftingProfit( if (nameLower && !recipe.displayName.toLowerCase().includes(nameLower)) continue // Calculate material cost from ingredients only - let materialCost = 0 + let basicCost = 0 // resources subject to RRR + let artefactCost = 0 // artefacts / ALCHEMY_RARE — not subject to RRR let missingPrices = false // Track oldest price date (for status column) @@ -70,7 +71,8 @@ export function useCraftingProfit( } else { trackDate(ingEntry.sell_price_min_date) const totalCost = unitPrice * ing.quantity - materialCost += totalCost + if (isRrrExempt(ing.itemId)) artefactCost += totalCost + else basicCost += totalCost ingredientBreakdown.push({ itemId: ing.itemId, displayName: formatItemId(ing.itemId), @@ -81,13 +83,16 @@ export function useCraftingProfit( } } - const effectiveMaterialCost = materialCost * rrrFactor + const materialCost = basicCost + artefactCost + const rrr = cityRrr(city, recipe.outputItemId) + const effectiveMaterialCost = basicCost * (1 - rrr / 100) + artefactCost const priceAgeMs = missingPrices ? null : (oldestDate ? Date.now() - new Date(oldestDate).getTime() : null) results.push({ recipe, materialCost, effectiveMaterialCost, + rrr, priceAgeMs, missingPrices, ingredientBreakdown, diff --git a/src/composables/useFilters.ts b/src/composables/useFilters.ts index 5e4fd47..7736976 100644 --- a/src/composables/useFilters.ts +++ b/src/composables/useFilters.ts @@ -11,7 +11,6 @@ const STORAGE_KEY = 'albion-filters' interface StoredFilters { city?: AlbionCity - rrr?: number tiers?: Tier[] enchantments?: Enchantment[] | null variants?: VariantType[] | null @@ -31,7 +30,6 @@ function buildInitialState(): FilterState { const s = loadStored() return { city: s.city ?? 'Caerleon', - rrr: (s.rrr != null && s.rrr >= 0 && s.rrr <= 100) ? s.rrr : 15, tiers: s.tiers ? new Set(s.tiers) : new Set(TIERS), enchantments: s.enchantments === undefined ? null : (s.enchantments === null ? null : new Set(s.enchantments)), variants: s.variants === undefined ? null : (s.variants === null ? null : new Set(s.variants)), @@ -48,7 +46,6 @@ watch(filters, () => { const f = filters.value const toStore: StoredFilters = { city: f.city, - rrr: f.rrr, tiers: [...f.tiers], enchantments: f.enchantments === null ? null : [...f.enchantments], variants: f.variants === null ? null : [...f.variants], @@ -79,10 +76,6 @@ function setSelectedItemTypes(value: Set | null) { filters.value.selectedItemTypes = value } -function setRrr(rate: number) { - filters.value.rrr = Math.max(0, Math.min(100, rate)) -} - function setNameFilter(value: string) { filters.value.nameFilter = value } @@ -123,7 +116,6 @@ export function useFilters() { setCity, toggleTier, setSelectedItemTypes, - setRrr, setNameFilter, toggleEnchantment, resetEnchantments, diff --git a/src/data/cityBonuses.ts b/src/data/cityBonuses.ts new file mode 100644 index 0000000..cc06246 --- /dev/null +++ b/src/data/cityBonuses.ts @@ -0,0 +1,55 @@ +import type { AlbionCity } from '../types/api' + +// Source: Albion Online Wiki — Production Bonuses +// Formula: RRR% = (1 - 1 / (1 + LPB/100)) × 100 +// +// Royal cities base LPB: 18% → RRR ≈ 15.3% +// Specialization bonus: +15% → total 33% LPB → RRR ≈ 24.8% +// Black Market (not a crafting location): 0% LPB → RRR = 0% + +const BASE_ROYAL_LPB = 18 +const SPECIALIZATION_BONUS = 15 + +const ROYAL_CITIES = new Set([ + 'Fort Sterling', 'Lymhurst', 'Bridgewatch', 'Martlock', 'Thetford', 'Caerleon', +]) + +// Each regex matches all output item IDs that receive the +15% specialization bonus +// in that city, including artifact / avalon / crystal variants. +const SPECIALIZATION_PATTERNS: Partial> = { + 'Fort Sterling': /_MAIN_HAMMER|_2H_HAMMER|_2H_POLEHAMMER|_2H_DUALHAMMER|_2H_RAM_|_MAIN_SPEAR|_2H_SPEAR|_2H_GLAIVE|_2H_HARPOON|_2H_TRIDENT|_HOLYSTAFF|_DIVINESTAFF|_HEAD_PLATE_|_ARMOR_CLOTH_/, + 'Lymhurst': /_MAIN_SWORD|_2H_CLAYMORE|_2H_DUALSWORD|_SCIMITAR|_2H_CLEAVER|_2H_BOW|_LONGBOW|_WARBOW|_ARCANESTAFF|_ENIGMATICSTAFF|_ENIGMATICORB|_ARCANE_RINGPAIR|_HEAD_LEATHER_|_SHOES_LEATHER_/, + 'Bridgewatch': /_CROSSBOW|_1HCROSSBOW|_DAGGER|_CLAWPAIR|_RAPIER|_DUALSICKLE|_CURSEDSTAFF|_DEMONICSTAFF|_SKULLORB|_ARMOR_PLATE_|_SHOES_CLOTH_/, + 'Martlock': /_MAIN_AXE|_2H_AXE|_2H_HALBERD|_2H_SCYTHE|_2H_DUALAXE|_QUARTERSTAFF|_IRONCLADEDSTAFF|_DOUBLEBLADEDSTAFF|_COMBATSTAFF|_TWINSCYTHE|_ROCKSTAFF|_FROSTSTAFF|_GLACIALSTAFF|_ICEGAUNTLETS|_ICECRYSTAL|_SHOES_PLATE_|_OFF_/, + 'Thetford': /_MAIN_MACE|_2H_MACE|_2H_FLAIL|_ROCKMACE|_2H_DUALMACE|_NATURESTAFF|_WILDSTAFF|_FIRESTAFF|_INFERNOSTAFF|_FIRE_RINGPAIR|_ARMOR_LEATHER_|_HEAD_CLOTH_/, + 'Caerleon': /_2H_KNUCKLES|_SHAPESHIFTER|_2H_TOOL_/, +} + +// Returns the Local Production Bonus (%) for crafting this item in the given city. +export function localProductionBonus(city: AlbionCity, outputItemId: string): number { + if (!ROYAL_CITIES.has(city)) return 0 // Black Market: no bonus + + const pattern = SPECIALIZATION_PATTERNS[city] + const baseId = outputItemId.replace(/@\d$/, '') // strip enchantment suffix + const isSpecialized = pattern ? pattern.test(baseId) : false + + return BASE_ROYAL_LPB + (isSpecialized ? SPECIALIZATION_BONUS : 0) +} + +// Converts a Local Production Bonus (%) to the resulting Resource Return Rate (%). +// Formula: RRR = 1 - 1/(1 + LPB/100) +export function rrrFromBonus(lpb: number): number { + if (lpb === 0) return 0 + return (1 - 1 / (1 + lpb / 100)) * 100 +} + +// Returns the effective RRR (%) for crafting this item in the given city. +export function cityRrr(city: AlbionCity, outputItemId: string): number { + return rrrFromBonus(localProductionBonus(city, outputItemId)) +} + +// Returns true for ingredients that are NOT subject to RRR (artefacts, ALCHEMY_RARE). +// Only basic resources (bars, planks, leather, cloth) are returned by the city bonus. +export function isRrrExempt(itemId: string): boolean { + return itemId.includes('_ARTEFACT_') || itemId.includes('_ALCHEMY_RARE_') +} diff --git a/src/pages/ProductionPage.vue b/src/pages/ProductionPage.vue index 5d9c96e..3a64d57 100644 --- a/src/pages/ProductionPage.vue +++ b/src/pages/ProductionPage.vue @@ -179,7 +179,7 @@ {{ formatSilver(totals.rawCost) }}
- RRR savings ({{ filters.rrr }}%) + RRR savings −{{ formatSilver(totals.rrrSavings) }}
@@ -209,6 +209,7 @@ import { useAlbionPrices } from '../composables/useAlbionPrices' import { useFilters } from '../composables/useFilters' import { formatSilver, formatItemId, formatLastUpdated, tierStyle, itemImageUrl } from '../utils/formatting' import { fameTypeOf, craftsPerJournal } from '../data/constants' +import { cityRrr, isRrrExempt } from '../data/cityBonuses' import type { Tier, JournalType } from '../types/crafting' const vFocus = { mounted: (el: HTMLElement) => el.focus() } @@ -277,11 +278,11 @@ const JOURNAL_ITEM_ID: Record = { // ─── Per-item computed values ───────────────────────────────────────────────── const computedItems = computed(() => { - const city = filters.value.city - const rrrFactor = 1 - filters.value.rrr / 100 + const city = filters.value.city return orderItems.value.map(({ recipe, qty }) => { - let rawCost = 0 + let basicCost = 0 + let artefactCost = 0 let missingPrices = false for (const ing of recipe.ingredients) { @@ -289,12 +290,15 @@ const computedItems = computed(() => { if (!entry || entry.sell_price_min === 0) { missingPrices = true } else { - rawCost += entry.sell_price_min * ing.quantity + if (isRrrExempt(ing.itemId)) artefactCost += entry.sell_price_min * ing.quantity + else basicCost += entry.sell_price_min * ing.quantity } } - const craftCostPerUnit = rawCost * rrrFactor - return { recipe, qty, craftCostPerUnit, totalCraftCost: craftCostPerUnit * qty, missingPrices } + const rawCost = basicCost + artefactCost + const rrr = cityRrr(city, recipe.outputItemId) + const craftCostPerUnit = basicCost * (1 - rrr / 100) + artefactCost + return { recipe, qty, rawCostPerUnit: rawCost, craftCostPerUnit, totalCraftCost: craftCostPerUnit * qty, rrr, missingPrices } }) }) @@ -323,21 +327,21 @@ const aggregatedMaterials = computed(() => { // ─── Totals + journals ──────────────────────────────────────────────────────── const totals = computed(() => { - const rrrFactor = 1 - filters.value.rrr / 100 let rawCost = 0 + let effectiveCost = 0 let totalCrafts = 0 let totalItems = 0 for (const item of computedItems.value) { if (!item.missingPrices) { - rawCost += item.craftCostPerUnit / rrrFactor * item.qty // back to raw + rawCost += item.rawCostPerUnit * item.qty + effectiveCost += item.craftCostPerUnit * item.qty } totalCrafts += item.qty totalItems += item.qty } - const effectiveCost = rawCost * rrrFactor - const rrrSavings = rawCost - effectiveCost + const rrrSavings = rawCost - effectiveCost return { rawCost, effectiveCost, rrrSavings, totalCrafts, totalItems } }) diff --git a/src/types/crafting.ts b/src/types/crafting.ts index 23ced04..7813e94 100644 --- a/src/types/crafting.ts +++ b/src/types/crafting.ts @@ -44,6 +44,7 @@ export interface ProfitResult { recipe: CraftingRecipe materialCost: number effectiveMaterialCost: number + rrr: number // effective RRR % for this item in the selected city priceAgeMs: number | null // null = missing prices; ms since oldest manual entry missingPrices: boolean ingredientBreakdown: IngredientBreakdown[] diff --git a/src/types/filters.ts b/src/types/filters.ts index 51461bd..74402a5 100644 --- a/src/types/filters.ts +++ b/src/types/filters.ts @@ -9,7 +9,6 @@ export interface FilterState { city: AlbionCity tiers: Set selectedItemTypes: Set | null - rrr: number nameFilter: string enchantments: Set | null variants: Set | null