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' : ''" :class="result.missingPrices ? 'opacity-60' : ''"
@click="expanded = !expanded" @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 --> <!-- Variant badge -->
<td class="px-4 py-3"> <td class="px-4 py-3">
<span <span
@@ -15,7 +20,6 @@
<!-- Item name --> <!-- Item name -->
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-gray-500 text-xs transition-transform duration-150" :class="expanded ? 'rotate-90' : ''"></span>
<img <img
:src="itemImageUrl(result.recipe.outputItemId)" :src="itemImageUrl(result.recipe.outputItemId)"
:alt="result.recipe.displayName" :alt="result.recipe.displayName"
@@ -42,6 +46,18 @@
{{ formatSilver(result.effectiveMaterialCost) }} {{ formatSilver(result.effectiveMaterialCost) }}
</td> </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 --> <!-- Price age -->
<td class="px-4 py-3"> <td class="px-4 py-3">
<span <span
@@ -91,7 +107,7 @@
<!-- Expanded detail row --> <!-- Expanded detail row -->
<tr v-if="expanded" class="border-b border-gray-700/50 bg-gray-900/60"> <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"> <div class="flex gap-8">
<!-- Ingredients breakdown --> <!-- Ingredients breakdown -->

View File

@@ -23,11 +23,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SortField, SortState } from '../../types/crafting' 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: 'variantType', label: 'Variant', sortable: true },
{ field: 'displayName', label: 'Item', sortable: true }, { field: 'displayName', label: 'Item', sortable: true },
{ field: 'tier', label: 'Tier', 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: 'status', label: 'Price Age', sortable: false },
{ field: 'bill', label: '', 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 type { FilterState, VariantType } from '../types/filters'
import { ALL_VARIANTS } from '../types/filters' import { ALL_VARIANTS } from '../types/filters'
import type { Tier, Enchantment } from '../types/crafting' import type { Tier, Enchantment } from '../types/crafting'
import type { AlbionCity } from '../types/api' import type { AlbionCity } from '../types/api'
import { ENCHANTMENTS } from '../data/constants' import { ENCHANTMENTS, TIERS } from '../data/constants'
function loadCity(): AlbionCity { const STORAGE_KEY = 'albion-filters'
return (localStorage.getItem('albion-city') as AlbionCity) ?? 'Caerleon'
// ─── Persistence ──────────────────────────────────────────────────────────────
interface StoredFilters {
city?: AlbionCity
rrr?: number
tiers?: Tier[]
enchantments?: Enchantment[] | null
variants?: VariantType[] | null
selectedItemTypes?: string[] | null
} }
function loadRrr(): number { function loadStored(): StoredFilters {
const v = Number(localStorage.getItem('albion-rrr')) try {
return isNaN(v) || v < 0 || v > 100 ? 15 : v const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
} }
const filters = ref<FilterState>({ function buildInitialState(): FilterState {
city: loadCity(), const s = loadStored()
tiers: new Set([2, 3, 4, 5, 6, 7, 8]), return {
selectedItemTypes: null, city: s.city ?? 'Caerleon',
rrr: loadRrr(), 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: '', nameFilter: '',
enchantments: null, }
variants: null, }
})
// ─── 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) { function setCity(city: AlbionCity) {
filters.value.city = city filters.value.city = city
localStorage.setItem('albion-city', city)
} }
function toggleTier(tier: Tier) { function toggleTier(tier: Tier) {
@@ -45,7 +81,6 @@ function setSelectedItemTypes(value: Set<string> | null) {
function setRrr(rate: number) { function setRrr(rate: number) {
filters.value.rrr = Math.max(0, Math.min(100, rate)) filters.value.rrr = Math.max(0, Math.min(100, rate))
localStorage.setItem('albion-rrr', String(filters.value.rrr))
} }
function setNameFilter(value: string) { function setNameFilter(value: string) {

View File

@@ -1,5 +1,5 @@
import type { AlbionCity, AlbionQuality } from '../types/api' 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[] = [ export const CITIES: AlbionCity[] = [
'Caerleon', '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 TIERS: Tier[] = [2, 3, 4, 5, 6, 7, 8]
export const ENCHANTMENTS: Enchantment[] = [0, 1, 2, 3, 4] export const ENCHANTMENTS: Enchantment[] = [0, 1, 2, 3, 4]
export const CATEGORIES: ItemCategory[] = ['Weapons', 'Armor', 'Gathering'] 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 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' import rawRecipes from './recipes.json'
// ─── Template types ─────────────────────────────────────────────────────────── // ─── Template types ───────────────────────────────────────────────────────────
@@ -102,6 +102,8 @@ for (const entry of rawRecipes) {
return { itemId, quantity: resolveQty(ing.qty, tier) } return { itemId, quantity: resolveQty(ing.qty, tier) }
}) })
const baseFame = FAME_BY_TIER[tier][fameTypeOf(template.outputId)]
ALL_RECIPES.push({ ALL_RECIPES.push({
outputItemId, outputItemId,
displayName: `T${tier}.${enc} ${template.displayName}`, displayName: `T${tier}.${enc} ${template.displayName}`,
@@ -109,6 +111,7 @@ for (const entry of rawRecipes) {
enchantment: enc, enchantment: enc,
category: template.category, category: template.category,
journalType: inferJournalType(template.outputId), journalType: inferJournalType(template.outputId),
famePerCraft: baseFame * Math.pow(2, enc),
ingredients, ingredients,
}) })
} }

View File

@@ -146,6 +146,7 @@
<tr class="border-b border-gray-700 bg-gray-700/30"> <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-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">Qty</th>
<th class="text-right px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Total Fame</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -161,6 +162,7 @@
</div> </div>
</td> </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-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> </tr>
</tbody> </tbody>
</table> </table>
@@ -206,6 +208,7 @@ import { useProductionOrder } from '../composables/useProductionOrder'
import { useAlbionPrices } from '../composables/useAlbionPrices' 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 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() }
@@ -257,9 +260,6 @@ function priceTitle(itemId: string, currentPrice: number): string {
return exact ? `${exact} — click to set price` : 'Click to set price' 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> = { const JOURNAL_DISPLAY_NAME: Record<JournalType, string> = {
warrior: "Blacksmith's", warrior: "Blacksmith's",
hunter: "Fletcher's", hunter: "Fletcher's",
@@ -344,11 +344,12 @@ const totals = computed(() => {
}) })
const journalRows = 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) { for (const { recipe, qty } of orderItems.value) {
const key = `${recipe.journalType}::${recipe.tier}` const key = `${recipe.journalType}::${recipe.tier}`
const cur = map.get(key) ?? { journalType: recipe.journalType, tier: recipe.tier as Tier, crafts: 0 } const cur = map.get(key) ?? { journalType: recipe.journalType, tier: recipe.tier as Tier, journalFills: 0, totalFame: 0 }
map.set(key, { ...cur, crafts: cur.crafts + qty }) 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()] return [...map.values()]
@@ -356,7 +357,7 @@ const journalRows = computed(() => {
.map(row => ({ .map(row => ({
...row, ...row,
journalName: JOURNAL_DISPLAY_NAME[row.journalType], 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]}`), 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 JournalType = 'warrior' | 'mage' | 'hunter' | 'toolmaker'
export type FameType = 'twoHanded' | 'oneHanded' | 'armorChest' | 'small'
export type SortField = 'materialCost' | 'displayName' | 'tier' | 'variantType' export type SortField = 'materialCost' | 'displayName' | 'tier' | 'variantType'
export type SortDirection = 'asc' | 'desc' export type SortDirection = 'asc' | 'desc'
@@ -26,6 +28,7 @@ export interface CraftingRecipe {
enchantment: Enchantment enchantment: Enchantment
category: ItemCategory category: ItemCategory
journalType: JournalType journalType: JournalType
famePerCraft: number
ingredients: Ingredient[] ingredients: Ingredient[]
} }