| 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