add markup columns
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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 -->
|
||||||
|
|||||||
@@ -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 },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]}`),
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user