add markup columns

This commit is contained in:
2026-03-05 01:40:24 -05:00
parent 7d779f0b95
commit e1860ef5f9
8 changed files with 134 additions and 32 deletions

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,11 @@
:class="result.missingPrices ? 'opacity-60' : ''"
@click="expanded = !expanded"
>
<!-- Expand arrow -->
<td class="pl-4 pr-1 py-3 w-4">
<span class="text-gray-500 text-xs transition-transform duration-150 inline-block" :class="expanded ? 'rotate-90' : ''"></span>
</td>
<!-- Variant badge -->
<td class="px-4 py-3">
<span
@@ -15,7 +20,6 @@
<!-- Item name -->
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<span class="text-gray-500 text-xs transition-transform duration-150" :class="expanded ? 'rotate-90' : ''"></span>
<img
:src="itemImageUrl(result.recipe.outputItemId)"
:alt="result.recipe.displayName"
@@ -42,6 +46,18 @@
{{ formatSilver(result.effectiveMaterialCost) }}
</td>
<!-- +15% markup -->
<td class="px-4 py-3 text-sm font-mono text-blue-300"
:title="result.effectiveMaterialCost > 0 ? Math.round(result.effectiveMaterialCost * 1.15).toLocaleString() : undefined">
{{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.15)) : '—' }}
</td>
<!-- +30% markup -->
<td class="px-4 py-3 text-sm font-mono text-emerald-300"
:title="result.effectiveMaterialCost > 0 ? Math.round(result.effectiveMaterialCost * 1.30).toLocaleString() : undefined">
{{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.30)) : '—' }}
</td>
<!-- Price age -->
<td class="px-4 py-3">
<span
@@ -91,7 +107,7 @@
<!-- Expanded detail row -->
<tr v-if="expanded" class="border-b border-gray-700/50 bg-gray-900/60">
<td colspan="6" class="px-6 py-4">
<td colspan="9" class="px-6 py-4">
<div class="flex gap-8">
<!-- Ingredients breakdown -->

View File

@@ -23,11 +23,14 @@
<script setup lang="ts">
import type { SortField, SortState } from '../../types/crafting'
const columns: { field: SortField | 'status' | 'bill'; label: string; sortable: boolean }[] = [
const columns: { field: SortField | 'status' | 'bill' | 'markup15' | 'markup30' | 'expand'; label: string; sortable: boolean }[] = [
{ field: 'expand', label: '', sortable: false },
{ field: 'variantType', label: 'Variant', sortable: true },
{ field: 'displayName', label: 'Item', sortable: true },
{ field: 'tier', label: 'Tier', sortable: true },
{ field: 'materialCost', label: 'Craft Cost', sortable: true },
{ field: 'materialCost', label: 'Cost', sortable: true },
{ field: 'markup15', label: '+15%', sortable: false },
{ field: 'markup30', label: '+30%', sortable: false },
{ field: 'status', label: 'Price Age', sortable: false },
{ field: 'bill', label: '', sortable: false },
]

View File

@@ -1,32 +1,68 @@
import { ref } from 'vue'
import { ref, watch } from 'vue'
import type { FilterState, VariantType } from '../types/filters'
import { ALL_VARIANTS } from '../types/filters'
import type { Tier, Enchantment } from '../types/crafting'
import type { AlbionCity } from '../types/api'
import { ENCHANTMENTS } from '../data/constants'
import { ENCHANTMENTS, TIERS } from '../data/constants'
function loadCity(): AlbionCity {
return (localStorage.getItem('albion-city') as AlbionCity) ?? 'Caerleon'
const STORAGE_KEY = 'albion-filters'
// ─── Persistence ──────────────────────────────────────────────────────────────
interface StoredFilters {
city?: AlbionCity
rrr?: number
tiers?: Tier[]
enchantments?: Enchantment[] | null
variants?: VariantType[] | null
selectedItemTypes?: string[] | null
}
function loadRrr(): number {
const v = Number(localStorage.getItem('albion-rrr'))
return isNaN(v) || v < 0 || v > 100 ? 15 : v
function loadStored(): StoredFilters {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
const filters = ref<FilterState>({
city: loadCity(),
tiers: new Set([2, 3, 4, 5, 6, 7, 8]),
selectedItemTypes: null,
rrr: loadRrr(),
nameFilter: '',
enchantments: null,
variants: null,
})
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)),
selectedItemTypes: s.selectedItemTypes === undefined ? null : (s.selectedItemTypes === null ? null : new Set(s.selectedItemTypes)),
nameFilter: '',
}
}
// ─── State ────────────────────────────────────────────────────────────────────
const filters = ref<FilterState>(buildInitialState())
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],
selectedItemTypes: f.selectedItemTypes === null ? null : [...f.selectedItemTypes],
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore))
} catch {}
}, { deep: true })
// ─── Setters ──────────────────────────────────────────────────────────────────
function setCity(city: AlbionCity) {
filters.value.city = city
localStorage.setItem('albion-city', city)
}
function toggleTier(tier: Tier) {
@@ -45,7 +81,6 @@ function setSelectedItemTypes(value: Set<string> | null) {
function setRrr(rate: number) {
filters.value.rrr = Math.max(0, Math.min(100, rate))
localStorage.setItem('albion-rrr', String(filters.value.rrr))
}
function setNameFilter(value: string) {

View File

@@ -1,5 +1,5 @@
import type { AlbionCity, AlbionQuality } from '../types/api'
import type { Tier, ItemCategory, Enchantment } from '../types/crafting'
import type { Tier, ItemCategory, Enchantment, FameType } from '../types/crafting'
export const CITIES: AlbionCity[] = [
'Caerleon',
@@ -22,3 +22,41 @@ export const QUALITIES: { label: string; value: AlbionQuality }[] = [
export const TIERS: Tier[] = [2, 3, 4, 5, 6, 7, 8]
export const ENCHANTMENTS: Enchantment[] = [0, 1, 2, 3, 4]
export const CATEGORIES: ItemCategory[] = ['Weapons', 'Armor', 'Gathering']
// ─── Crafting fame per craft (at enchantment 0) ───────────────────────────────
// Source: https://albiononlinegrind.com/table/item-crafting-fame
// T2/T3 extrapolated by dividing T4 values by 4 and 16 respectively.
// For enchanted items: multiply by 2^enchantment.
export const FAME_BY_TIER: Record<Tier, Record<FameType, number>> = {
2: { twoHanded: 45, oneHanded: 34, armorChest: 23, small: 11 },
3: { twoHanded: 180, oneHanded: 135, armorChest: 90, small: 45 },
4: { twoHanded: 720, oneHanded: 540, armorChest: 360, small: 180 },
5: { twoHanded: 2880, oneHanded: 2160, armorChest: 1440, small: 720 },
6: { twoHanded: 8640, oneHanded: 6480, armorChest: 4320, small: 2160 },
7: { twoHanded: 20640, oneHanded: 15480, armorChest: 10320, small: 5160 },
8: { twoHanded: 44640, oneHanded: 33480, armorChest: 22320, small: 11160 },
}
// Crafts needed to fill one journal at enchantment 0.
// Scales with enchantment: divide by 2^enc (ceil).
// Independent of tier — journal capacity scales proportionally to item fame.
export const JOURNAL_CRAFTS_BASE: Record<FameType, number> = {
twoHanded: 13,
oneHanded: 17,
armorChest: 25,
small: 50,
}
// Returns the FameType for a given output item ID
export function fameTypeOf(outputId: string): FameType {
if (outputId.includes('_2H_TOOL_')) return 'small' // ~8 resources = same as small items
if (outputId.includes('_2H_')) return 'twoHanded'
if (outputId.includes('_MAIN_')) return 'oneHanded'
if (outputId.includes('_ARMOR_')) return 'armorChest'
return 'small' // HEAD_, SHOES_, OFF_
}
// Crafts needed to fill one journal for a given fame type and enchantment level
export function craftsPerJournal(fameType: FameType, enchantment: Enchantment): number {
return Math.ceil(JOURNAL_CRAFTS_BASE[fameType] / Math.pow(2, enchantment))
}

View File

@@ -1,5 +1,5 @@
import type { CraftingRecipe, Tier, Enchantment, ItemCategory, JournalType } from '../types/crafting'
import { ENCHANTMENTS } from './constants'
import { ENCHANTMENTS, FAME_BY_TIER, fameTypeOf } from './constants'
import rawRecipes from './recipes.json'
// ─── Template types ───────────────────────────────────────────────────────────
@@ -102,6 +102,8 @@ for (const entry of rawRecipes) {
return { itemId, quantity: resolveQty(ing.qty, tier) }
})
const baseFame = FAME_BY_TIER[tier][fameTypeOf(template.outputId)]
ALL_RECIPES.push({
outputItemId,
displayName: `T${tier}.${enc} ${template.displayName}`,
@@ -109,6 +111,7 @@ for (const entry of rawRecipes) {
enchantment: enc,
category: template.category,
journalType: inferJournalType(template.outputId),
famePerCraft: baseFame * Math.pow(2, enc),
ingredients,
})
}

View File

@@ -146,6 +146,7 @@
<tr class="border-b border-gray-700 bg-gray-700/30">
<th class="text-left px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Journal</th>
<th class="text-right px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Qty</th>
<th class="text-right px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Total Fame</th>
</tr>
</thead>
<tbody>
@@ -161,6 +162,7 @@
</div>
</td>
<td class="px-4 py-1.5 text-right font-mono text-gray-200 font-medium">{{ row.journals.toLocaleString() }}</td>
<td class="px-4 py-1.5 text-right font-mono text-gray-300">{{ row.totalFame.toLocaleString() }}</td>
</tr>
</tbody>
</table>
@@ -206,6 +208,7 @@ import { useProductionOrder } from '../composables/useProductionOrder'
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 type { Tier, JournalType } from '../types/crafting'
const vFocus = { mounted: (el: HTMLElement) => el.focus() }
@@ -257,9 +260,6 @@ function priceTitle(itemId: string, currentPrice: number): string {
return exact ? `${exact} — click to set price` : 'Click to set price'
}
// Laborer journal fills ≈ 13 crafts per book (ratio is constant across tiers)
const CRAFTS_PER_JOURNAL = 13
const JOURNAL_DISPLAY_NAME: Record<JournalType, string> = {
warrior: "Blacksmith's",
hunter: "Fletcher's",
@@ -344,11 +344,12 @@ const totals = computed(() => {
})
const journalRows = computed(() => {
const map = new Map<string, { journalType: JournalType; tier: Tier; crafts: number }>()
const map = new Map<string, { journalType: JournalType; tier: Tier; journalFills: number; totalFame: number }>()
for (const { recipe, qty } of orderItems.value) {
const key = `${recipe.journalType}::${recipe.tier}`
const cur = map.get(key) ?? { journalType: recipe.journalType, tier: recipe.tier as Tier, crafts: 0 }
map.set(key, { ...cur, crafts: cur.crafts + qty })
const cur = map.get(key) ?? { journalType: recipe.journalType, tier: recipe.tier as Tier, journalFills: 0, totalFame: 0 }
const fills = qty / craftsPerJournal(fameTypeOf(recipe.outputItemId), recipe.enchantment)
map.set(key, { ...cur, journalFills: cur.journalFills + fills, totalFame: cur.totalFame + recipe.famePerCraft * qty })
}
return [...map.values()]
@@ -356,7 +357,7 @@ const journalRows = computed(() => {
.map(row => ({
...row,
journalName: JOURNAL_DISPLAY_NAME[row.journalType],
journals: Math.ceil(row.crafts / CRAFTS_PER_JOURNAL),
journals: Math.ceil(row.journalFills),
journalImgUrl: itemImageUrl(`T${row.tier}_${JOURNAL_ITEM_ID[row.journalType]}`),
}))
})

View File

@@ -5,6 +5,8 @@ export type ItemCategory = 'Weapons' | 'Armor' | 'Gathering'
export type JournalType = 'warrior' | 'mage' | 'hunter' | 'toolmaker'
export type FameType = 'twoHanded' | 'oneHanded' | 'armorChest' | 'small'
export type SortField = 'materialCost' | 'displayName' | 'tier' | 'variantType'
export type SortDirection = 'asc' | 'desc'
@@ -26,6 +28,7 @@ export interface CraftingRecipe {
enchantment: Enchantment
category: ItemCategory
journalType: JournalType
famePerCraft: number
ingredients: Ingredient[]
}