add local production bonuses
This commit is contained in:
@@ -18,9 +18,9 @@
|
||||
<!-- Calculator page -->
|
||||
<div v-if="currentPage === 'calculator'" class="p-6 space-y-4">
|
||||
<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-variant="toggleVariant" @reset-variants="resetVariants" @set-rrr="setRrr" />
|
||||
@toggle-variant="toggleVariant" @reset-variants="resetVariants" />
|
||||
|
||||
<ProfitTable :results="profitResults" :sort-state="sortState" @sort="handleSort" />
|
||||
</div>
|
||||
@@ -66,10 +66,8 @@ const sortState = ref<SortState>({ field: 'materialCost', direction: 'asc' })
|
||||
// Filters
|
||||
const {
|
||||
filters,
|
||||
setCity,
|
||||
toggleTier,
|
||||
setSelectedItemTypes,
|
||||
setRrr,
|
||||
setNameFilter,
|
||||
toggleEnchantment,
|
||||
resetEnchantments,
|
||||
|
||||
@@ -131,29 +131,6 @@
|
||||
</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 -->
|
||||
<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 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<string> | null]
|
||||
'toggle-enchantment': [enc: Enchantment]
|
||||
'reset-enchantments': []
|
||||
'toggle-variant': [v: VariantType]
|
||||
'reset-variants': []
|
||||
'set-rrr': [rate: number]
|
||||
}>()
|
||||
|
||||
// ─── Category tree ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,12 +9,28 @@
|
||||
{{ totalRecipes }} recipes
|
||||
</span>
|
||||
</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>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
totalRecipes: number
|
||||
}>()
|
||||
import { useFilters } from '../../composables/useFilters'
|
||||
import { CITIES } from '../../data/constants'
|
||||
import type { AlbionCity } from '../../types/api'
|
||||
|
||||
defineProps<{ totalRecipes: number }>()
|
||||
|
||||
const { filters, setCity } = useFilters()
|
||||
</script>
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
<span class="text-gray-200">{{ formatSilver(result.materialCost) }}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-1.5">
|
||||
|
||||
@@ -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<ProfitResult[]>(() => {
|
||||
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,
|
||||
|
||||
@@ -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<string> | 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,
|
||||
|
||||
55
src/data/cityBonuses.ts
Normal file
55
src/data/cityBonuses.ts
Normal 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_')
|
||||
}
|
||||
@@ -179,7 +179,7 @@
|
||||
<span class="text-gray-200">{{ formatSilver(totals.rawCost) }}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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 { 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<JournalType, string> = {
|
||||
// ─── 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 }
|
||||
})
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface FilterState {
|
||||
city: AlbionCity
|
||||
tiers: Set<Tier>
|
||||
selectedItemTypes: Set<string> | null
|
||||
rrr: number
|
||||
nameFilter: string
|
||||
enchantments: Set<Enchantment> | null
|
||||
variants: Set<VariantType> | null
|
||||
|
||||
Reference in New Issue
Block a user