add local production bonuses

This commit is contained in:
2026-03-05 02:20:21 -05:00
parent f1deb33360
commit 4214eeb659
10 changed files with 103 additions and 59 deletions

View File

@@ -18,9 +18,9 @@
<!-- Calculator page --> <!-- Calculator page -->
<div v-if="currentPage === 'calculator'" class="p-6 space-y-4"> <div v-if="currentPage === 'calculator'" class="p-6 space-y-4">
<FilterBar :filters="filters" :result-count="profitResults.length" @set-name-filter="setNameFilter" <FilterBar :filters="filters" :result-count="profitResults.length" @set-name-filter="setNameFilter"
@set-city="setCity" @toggle-tier="toggleTier" @set-selected-item-types="setSelectedItemTypes" @toggle-tier="toggleTier" @set-selected-item-types="setSelectedItemTypes"
@toggle-enchantment="toggleEnchantment" @reset-enchantments="resetEnchantments" @toggle-enchantment="toggleEnchantment" @reset-enchantments="resetEnchantments"
@toggle-variant="toggleVariant" @reset-variants="resetVariants" @set-rrr="setRrr" /> @toggle-variant="toggleVariant" @reset-variants="resetVariants" />
<ProfitTable :results="profitResults" :sort-state="sortState" @sort="handleSort" /> <ProfitTable :results="profitResults" :sort-state="sortState" @sort="handleSort" />
</div> </div>
@@ -66,10 +66,8 @@ const sortState = ref<SortState>({ field: 'materialCost', direction: 'asc' })
// Filters // Filters
const { const {
filters, filters,
setCity,
toggleTier, toggleTier,
setSelectedItemTypes, setSelectedItemTypes,
setRrr,
setNameFilter, setNameFilter,
toggleEnchantment, toggleEnchantment,
resetEnchantments, resetEnchantments,

View File

@@ -131,29 +131,6 @@
</div> </div>
</div> </div>
<!-- City select -->
<div class="flex items-center gap-1">
<span class="text-xs text-gray-400">City</span>
<select
class="bg-gray-900 border border-gray-700 rounded-lg px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-amber-500 cursor-pointer"
:value="filters.city" @change="$emit('set-city', ($event.target as HTMLSelectElement).value as AlbionCity)">
<option v-for="c in CITIES" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="h-5 w-px bg-gray-600" />
<!-- RRR -->
<div class="flex items-center gap-1.5">
<span class="text-xs text-gray-400 whitespace-nowrap">RRR</span>
<input type="number"
class="bg-gray-900 border border-gray-700 rounded-lg px-2 py-1.5 text-sm text-gray-200 w-16 focus:outline-none focus:border-amber-500 font-mono"
:value="filters.rrr" @input="$emit('set-rrr', Number(($event.target as HTMLInputElement).value))" min="0"
max="100" step="0.5" />
<span class="text-xs text-gray-500">%</span>
</div>
<!-- Result count --> <!-- Result count -->
<span class="ml-auto text-xs text-gray-500 whitespace-nowrap">{{ resultCount }} results</span> <span class="ml-auto text-xs text-gray-500 whitespace-nowrap">{{ resultCount }} results</span>
@@ -166,9 +143,8 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { tierStyle, enchantStyle } from '../../utils/formatting' import { tierStyle, enchantStyle } from '../../utils/formatting'
import type { FilterState, VariantType } from '../../types/filters' import type { FilterState, VariantType } from '../../types/filters'
import { ALL_VARIANTS } from '../../types/filters' import { ALL_VARIANTS } from '../../types/filters'
import type { AlbionCity } from '../../types/api'
import type { Tier, Enchantment } from '../../types/crafting' 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 { ITEM_TREE, ALL_ITEM_NAMES, getLeaves } from '../../data/itemTree'
import type { TreeNode } from '../../data/itemTree' import type { TreeNode } from '../../data/itemTree'
@@ -179,14 +155,12 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'set-name-filter': [value: string] 'set-name-filter': [value: string]
'set-city': [city: AlbionCity]
'toggle-tier': [tier: Tier] 'toggle-tier': [tier: Tier]
'set-selected-item-types': [v: Set<string> | null] 'set-selected-item-types': [v: Set<string> | null]
'toggle-enchantment': [enc: Enchantment] 'toggle-enchantment': [enc: Enchantment]
'reset-enchantments': [] 'reset-enchantments': []
'toggle-variant': [v: VariantType] 'toggle-variant': [v: VariantType]
'reset-variants': [] 'reset-variants': []
'set-rrr': [rate: number]
}>() }>()
// ─── Category tree ──────────────────────────────────────────────────────────── // ─── Category tree ────────────────────────────────────────────────────────────

View File

@@ -9,12 +9,28 @@
{{ totalRecipes }} recipes {{ totalRecipes }} recipes
</span> </span>
</div> </div>
<!-- City selector -->
<div class="flex items-center gap-1.5">
<span class="text-xs text-gray-400">City</span>
<select
class="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-amber-500 cursor-pointer"
:value="filters.city"
@change="setCity(($event.target as HTMLSelectElement).value as AlbionCity)"
>
<option v-for="c in CITIES" :key="c" :value="c">{{ c }}</option>
</select>
</div>
</div> </div>
</header> </header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ import { useFilters } from '../../composables/useFilters'
totalRecipes: number import { CITIES } from '../../data/constants'
}>() import type { AlbionCity } from '../../types/api'
defineProps<{ totalRecipes: number }>()
const { filters, setCity } = useFilters()
</script> </script>

View File

@@ -185,7 +185,7 @@
<span class="text-gray-200">{{ formatSilver(result.materialCost) }}</span> <span class="text-gray-200">{{ formatSilver(result.materialCost) }}</span>
</div> </div>
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
<span class="text-gray-400">RRR ({{ filters.rrr }}%)</span> <span class="text-gray-400">RRR ({{ result.rrr.toFixed(1) }}%)</span>
<span class="text-green-400">{{ formatSilver(result.materialCost - result.effectiveMaterialCost) }}</span> <span class="text-green-400">{{ formatSilver(result.materialCost - result.effectiveMaterialCost) }}</span>
</div> </div>
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-1.5"> <div class="flex justify-between gap-4 border-t border-gray-700/50 pt-1.5">

View File

@@ -4,6 +4,7 @@ import type { CraftingRecipe, ProfitResult, IngredientBreakdown, SortState } fro
import type { FilterState, VariantType } from '../types/filters' import type { FilterState, VariantType } from '../types/filters'
import { useAlbionPrices } from './useAlbionPrices' import { useAlbionPrices } from './useAlbionPrices'
import { formatItemId } from '../utils/formatting' import { formatItemId } from '../utils/formatting'
import { cityRrr, isRrrExempt } from '../data/cityBonuses'
function variantOf(outputItemId: string): VariantType { function variantOf(outputItemId: string): VariantType {
const id = outputItemId.replace(/@\d$/, '') // strip enchantment suffix const id = outputItemId.replace(/@\d$/, '') // strip enchantment suffix
@@ -30,7 +31,6 @@ export function useCraftingProfit(
const profitResults = computed<ProfitResult[]>(() => { const profitResults = computed<ProfitResult[]>(() => {
const f = filters.value const f = filters.value
const city = f.city const city = f.city
const rrrFactor = 1 - f.rrr / 100
const results: ProfitResult[] = [] const results: ProfitResult[] = []
const nameLower = f.nameFilter.trim().toLowerCase() const nameLower = f.nameFilter.trim().toLowerCase()
@@ -44,7 +44,8 @@ export function useCraftingProfit(
if (nameLower && !recipe.displayName.toLowerCase().includes(nameLower)) continue if (nameLower && !recipe.displayName.toLowerCase().includes(nameLower)) continue
// Calculate material cost from ingredients only // 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 let missingPrices = false
// Track oldest price date (for status column) // Track oldest price date (for status column)
@@ -70,7 +71,8 @@ export function useCraftingProfit(
} else { } else {
trackDate(ingEntry.sell_price_min_date) trackDate(ingEntry.sell_price_min_date)
const totalCost = unitPrice * ing.quantity const totalCost = unitPrice * ing.quantity
materialCost += totalCost if (isRrrExempt(ing.itemId)) artefactCost += totalCost
else basicCost += totalCost
ingredientBreakdown.push({ ingredientBreakdown.push({
itemId: ing.itemId, itemId: ing.itemId,
displayName: formatItemId(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) const priceAgeMs = missingPrices ? null : (oldestDate ? Date.now() - new Date(oldestDate).getTime() : null)
results.push({ results.push({
recipe, recipe,
materialCost, materialCost,
effectiveMaterialCost, effectiveMaterialCost,
rrr,
priceAgeMs, priceAgeMs,
missingPrices, missingPrices,
ingredientBreakdown, ingredientBreakdown,

View File

@@ -11,7 +11,6 @@ const STORAGE_KEY = 'albion-filters'
interface StoredFilters { interface StoredFilters {
city?: AlbionCity city?: AlbionCity
rrr?: number
tiers?: Tier[] tiers?: Tier[]
enchantments?: Enchantment[] | null enchantments?: Enchantment[] | null
variants?: VariantType[] | null variants?: VariantType[] | null
@@ -31,7 +30,6 @@ function buildInitialState(): FilterState {
const s = loadStored() const s = loadStored()
return { return {
city: s.city ?? 'Caerleon', 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), tiers: s.tiers ? new Set(s.tiers) : new Set(TIERS),
enchantments: s.enchantments === undefined ? null : (s.enchantments === null ? null : new Set(s.enchantments)), 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)), variants: s.variants === undefined ? null : (s.variants === null ? null : new Set(s.variants)),
@@ -48,7 +46,6 @@ watch(filters, () => {
const f = filters.value const f = filters.value
const toStore: StoredFilters = { const toStore: StoredFilters = {
city: f.city, city: f.city,
rrr: f.rrr,
tiers: [...f.tiers], tiers: [...f.tiers],
enchantments: f.enchantments === null ? null : [...f.enchantments], enchantments: f.enchantments === null ? null : [...f.enchantments],
variants: f.variants === null ? null : [...f.variants], variants: f.variants === null ? null : [...f.variants],
@@ -79,10 +76,6 @@ function setSelectedItemTypes(value: Set<string> | null) {
filters.value.selectedItemTypes = value filters.value.selectedItemTypes = value
} }
function setRrr(rate: number) {
filters.value.rrr = Math.max(0, Math.min(100, rate))
}
function setNameFilter(value: string) { function setNameFilter(value: string) {
filters.value.nameFilter = value filters.value.nameFilter = value
} }
@@ -123,7 +116,6 @@ export function useFilters() {
setCity, setCity,
toggleTier, toggleTier,
setSelectedItemTypes, setSelectedItemTypes,
setRrr,
setNameFilter, setNameFilter,
toggleEnchantment, toggleEnchantment,
resetEnchantments, resetEnchantments,

55
src/data/cityBonuses.ts Normal file
View File

@@ -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<AlbionCity>([
'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<Record<AlbionCity, RegExp>> = {
'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_')
}

View File

@@ -179,7 +179,7 @@
<span class="text-gray-200">{{ formatSilver(totals.rawCost) }}</span> <span class="text-gray-200">{{ formatSilver(totals.rawCost) }}</span>
</div> </div>
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
<span class="text-gray-400">RRR savings ({{ filters.rrr }}%)</span> <span class="text-gray-400">RRR savings</span>
<span class="text-green-400">{{ formatSilver(totals.rrrSavings) }}</span> <span class="text-green-400">{{ formatSilver(totals.rrrSavings) }}</span>
</div> </div>
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-2"> <div class="flex justify-between gap-4 border-t border-gray-700/50 pt-2">
@@ -209,6 +209,7 @@ import { useAlbionPrices } from '../composables/useAlbionPrices'
import { useFilters } from '../composables/useFilters' import { useFilters } from '../composables/useFilters'
import { formatSilver, formatItemId, formatLastUpdated, tierStyle, itemImageUrl } from '../utils/formatting' import { formatSilver, formatItemId, formatLastUpdated, tierStyle, itemImageUrl } from '../utils/formatting'
import { fameTypeOf, craftsPerJournal } from '../data/constants' import { fameTypeOf, craftsPerJournal } from '../data/constants'
import { cityRrr, isRrrExempt } from '../data/cityBonuses'
import type { Tier, JournalType } from '../types/crafting' import type { Tier, JournalType } from '../types/crafting'
const vFocus = { mounted: (el: HTMLElement) => el.focus() } const vFocus = { mounted: (el: HTMLElement) => el.focus() }
@@ -277,11 +278,11 @@ const JOURNAL_ITEM_ID: Record<JournalType, string> = {
// ─── Per-item computed values ───────────────────────────────────────────────── // ─── Per-item computed values ─────────────────────────────────────────────────
const computedItems = computed(() => { const computedItems = computed(() => {
const city = filters.value.city const city = filters.value.city
const rrrFactor = 1 - filters.value.rrr / 100
return orderItems.value.map(({ recipe, qty }) => { return orderItems.value.map(({ recipe, qty }) => {
let rawCost = 0 let basicCost = 0
let artefactCost = 0
let missingPrices = false let missingPrices = false
for (const ing of recipe.ingredients) { for (const ing of recipe.ingredients) {
@@ -289,12 +290,15 @@ const computedItems = computed(() => {
if (!entry || entry.sell_price_min === 0) { if (!entry || entry.sell_price_min === 0) {
missingPrices = true missingPrices = true
} else { } 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 const rawCost = basicCost + artefactCost
return { recipe, qty, craftCostPerUnit, totalCraftCost: craftCostPerUnit * qty, missingPrices } 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 ──────────────────────────────────────────────────────── // ─── Totals + journals ────────────────────────────────────────────────────────
const totals = computed(() => { const totals = computed(() => {
const rrrFactor = 1 - filters.value.rrr / 100
let rawCost = 0 let rawCost = 0
let effectiveCost = 0
let totalCrafts = 0 let totalCrafts = 0
let totalItems = 0 let totalItems = 0
for (const item of computedItems.value) { for (const item of computedItems.value) {
if (!item.missingPrices) { 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 totalCrafts += item.qty
totalItems += item.qty totalItems += item.qty
} }
const effectiveCost = rawCost * rrrFactor const rrrSavings = rawCost - effectiveCost
const rrrSavings = rawCost - effectiveCost
return { rawCost, effectiveCost, rrrSavings, totalCrafts, totalItems } return { rawCost, effectiveCost, rrrSavings, totalCrafts, totalItems }
}) })

View File

@@ -44,6 +44,7 @@ export interface ProfitResult {
recipe: CraftingRecipe recipe: CraftingRecipe
materialCost: number materialCost: number
effectiveMaterialCost: 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 priceAgeMs: number | null // null = missing prices; ms since oldest manual entry
missingPrices: boolean missingPrices: boolean
ingredientBreakdown: IngredientBreakdown[] ingredientBreakdown: IngredientBreakdown[]

View File

@@ -9,7 +9,6 @@ export interface FilterState {
city: AlbionCity city: AlbionCity
tiers: Set<Tier> tiers: Set<Tier>
selectedItemTypes: Set<string> | null selectedItemTypes: Set<string> | null
rrr: number
nameFilter: string nameFilter: string
enchantments: Set<Enchantment> | null enchantments: Set<Enchantment> | null
variants: Set<VariantType> | null variants: Set<VariantType> | null