initial commit
This commit is contained in:
137
src/composables/useAlbionPrices.ts
Normal file
137
src/composables/useAlbionPrices.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import type { AlbionPriceEntry, AlbionCity, ManualPriceEntry, ManualPriceCache } from '../types/api'
|
||||
|
||||
// ─── Override duration ────────────────────────────────────────────────────────
|
||||
|
||||
export const OVERRIDE_DURATION_OPTIONS: { label: string; ms: number }[] = [
|
||||
{ label: '30 min', ms: 30 * 60 * 1000 },
|
||||
{ label: '1 hour', ms: 60 * 60 * 1000 },
|
||||
{ label: '2 hours', ms: 2 * 60 * 60 * 1000 },
|
||||
{ label: '4 hours', ms: 4 * 60 * 60 * 1000 },
|
||||
{ label: '8 hours', ms: 8 * 60 * 60 * 1000 },
|
||||
{ label: '24 hours',ms: 24 * 60 * 60 * 1000 },
|
||||
]
|
||||
|
||||
const DURATION_STORAGE_KEY = 'albion-override-duration'
|
||||
const MANUAL_STORAGE_KEY = 'albion-manual-prices'
|
||||
|
||||
function loadOverrideDuration(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(DURATION_STORAGE_KEY)
|
||||
if (raw) {
|
||||
const ms = Number(raw)
|
||||
if (OVERRIDE_DURATION_OPTIONS.some(o => o.ms === ms)) return ms
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return 24 * 60 * 60 * 1000 // default 24 hours
|
||||
}
|
||||
|
||||
function loadManualPricesFromStorage(): ManualPriceCache {
|
||||
try {
|
||||
const raw = localStorage.getItem(MANUAL_STORAGE_KEY)
|
||||
if (raw) {
|
||||
const obj = JSON.parse(raw) as Record<string, ManualPriceEntry>
|
||||
return new Map(Object.entries(obj))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return new Map()
|
||||
}
|
||||
|
||||
function saveManualPricesToStorage(map: ManualPriceCache): void {
|
||||
try {
|
||||
const obj: Record<string, ManualPriceEntry> = {}
|
||||
for (const [key, val] of map) obj[key] = val
|
||||
localStorage.setItem(MANUAL_STORAGE_KEY, JSON.stringify(obj))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── Module-level singleton state ────────────────────────────────────────────
|
||||
|
||||
const manualPrices = ref<ManualPriceCache>(loadManualPricesFromStorage())
|
||||
const overrideDurationMs = ref<number>(loadOverrideDuration())
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function cacheKey(itemId: string, city: string): string {
|
||||
return `${itemId}::${city}`
|
||||
}
|
||||
|
||||
function isManualExpired(entry: ManualPriceEntry): boolean {
|
||||
return Date.now() - new Date(entry.editedAt).getTime() > overrideDurationMs.value
|
||||
}
|
||||
|
||||
function synthesizeManualEntry(
|
||||
entry: ManualPriceEntry,
|
||||
itemId: string,
|
||||
city: AlbionCity,
|
||||
): AlbionPriceEntry {
|
||||
return {
|
||||
item_id: itemId,
|
||||
city,
|
||||
quality: 1,
|
||||
sell_price_min: entry.sell_price_min,
|
||||
sell_price_min_date: entry.editedAt,
|
||||
sell_price_max: entry.sell_price_min,
|
||||
buy_price_min: 0,
|
||||
buy_price_max: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Price accessors ──────────────────────────────────────────────────────────
|
||||
|
||||
function getPrice(itemId: string, city: AlbionCity): AlbionPriceEntry | null {
|
||||
const key = cacheKey(itemId, city)
|
||||
const manual = manualPrices.value.get(key)
|
||||
if (manual && !isManualExpired(manual)) {
|
||||
return synthesizeManualEntry(manual, itemId, city)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Manual price management ──────────────────────────────────────────────────
|
||||
|
||||
function setManualPrice(itemId: string, city: AlbionCity, price: number): void {
|
||||
const key = cacheKey(itemId, city)
|
||||
const next = new Map(manualPrices.value)
|
||||
next.set(key, { sell_price_min: price, editedAt: new Date().toISOString() })
|
||||
manualPrices.value = next
|
||||
saveManualPricesToStorage(next)
|
||||
}
|
||||
|
||||
function clearManualPrice(itemId: string, city: AlbionCity): void {
|
||||
const key = cacheKey(itemId, city)
|
||||
if (!manualPrices.value.has(key)) return
|
||||
const next = new Map(manualPrices.value)
|
||||
next.delete(key)
|
||||
manualPrices.value = next
|
||||
saveManualPricesToStorage(next)
|
||||
}
|
||||
|
||||
function isManualPrice(itemId: string, city: AlbionCity): boolean {
|
||||
const entry = manualPrices.value.get(cacheKey(itemId, city))
|
||||
return !!entry && !isManualExpired(entry)
|
||||
}
|
||||
|
||||
function getManualEntry(itemId: string, city: AlbionCity): ManualPriceEntry | undefined {
|
||||
const entry = manualPrices.value.get(cacheKey(itemId, city))
|
||||
return entry && !isManualExpired(entry) ? entry : undefined
|
||||
}
|
||||
|
||||
function setOverrideDuration(ms: number): void {
|
||||
overrideDurationMs.value = ms
|
||||
try { localStorage.setItem(DURATION_STORAGE_KEY, String(ms)) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useAlbionPrices() {
|
||||
return {
|
||||
overrideDurationMs: readonly(overrideDurationMs),
|
||||
getPrice,
|
||||
setManualPrice,
|
||||
clearManualPrice,
|
||||
isManualPrice,
|
||||
getManualEntry,
|
||||
setOverrideDuration,
|
||||
}
|
||||
}
|
||||
55
src/composables/useAutoRefresh.ts
Normal file
55
src/composables/useAutoRefresh.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { AUTO_REFRESH_INTERVAL_S } from '../data/constants'
|
||||
|
||||
export function useAutoRefresh(fetchFn: () => Promise<void>, enabled: Ref<boolean>) {
|
||||
const countdown = ref(AUTO_REFRESH_INTERVAL_S)
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let tickId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startRefresh() {
|
||||
stopRefresh()
|
||||
countdown.value = AUTO_REFRESH_INTERVAL_S
|
||||
|
||||
// Countdown tick every second
|
||||
tickId = setInterval(() => {
|
||||
countdown.value = Math.max(0, countdown.value - 1)
|
||||
}, 1000)
|
||||
|
||||
// Fetch every interval
|
||||
intervalId = setInterval(async () => {
|
||||
await fetchFn()
|
||||
countdown.value = AUTO_REFRESH_INTERVAL_S
|
||||
}, AUTO_REFRESH_INTERVAL_S * 1000)
|
||||
}
|
||||
|
||||
function stopRefresh() {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
if (tickId !== null) {
|
||||
clearInterval(tickId)
|
||||
tickId = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
enabled,
|
||||
(val) => {
|
||||
if (val) {
|
||||
startRefresh()
|
||||
} else {
|
||||
stopRefresh()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopRefresh()
|
||||
})
|
||||
|
||||
return { countdown }
|
||||
}
|
||||
131
src/composables/useCraftingProfit.ts
Normal file
131
src/composables/useCraftingProfit.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { CraftingRecipe, ProfitResult, IngredientBreakdown, SortState } from '../types/crafting'
|
||||
import type { FilterState } from '../types/filters'
|
||||
import { useAlbionPrices } from './useAlbionPrices'
|
||||
import { formatItemId } from '../utils/formatting'
|
||||
|
||||
// Returns 0=basic, 1=artifact, 2=avalon, 3=crystal
|
||||
function variantRank(outputItemId: string): number {
|
||||
const id = outputItemId.replace(/@\d$/, '') // strip enchantment suffix
|
||||
if (id.endsWith('_AVALON')) return 2
|
||||
if (id.endsWith('_CRYSTAL')) return 3
|
||||
if (/_SET[123]$/.test(id)) return 0
|
||||
// Artifact suffixes: UNDEAD, HELL, MORGANA, KEEPER, and unique named artifacts
|
||||
if (/_(?:UNDEAD|HELL|MORGANA|KEEPER|ROYAL|FEY)$/.test(id)) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
export function useCraftingProfit(
|
||||
recipes: CraftingRecipe[],
|
||||
filters: Ref<FilterState>,
|
||||
sortState: Ref<SortState>
|
||||
) {
|
||||
const { getPrice } = useAlbionPrices()
|
||||
|
||||
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()
|
||||
|
||||
for (const recipe of recipes) {
|
||||
if (!f.tiers.has(recipe.tier)) continue
|
||||
if (f.enchantments !== null && !f.enchantments.has(recipe.enchantment)) continue
|
||||
const baseName = recipe.displayName.replace(/^T\d+\.\d /, '')
|
||||
if (f.selectedItemTypes !== null && !f.selectedItemTypes.has(baseName)) continue
|
||||
if (nameLower && !recipe.displayName.toLowerCase().includes(nameLower)) continue
|
||||
|
||||
// Calculate material cost from ingredients only
|
||||
let materialCost = 0
|
||||
let missingPrices = false
|
||||
|
||||
// Track oldest price date (for status column)
|
||||
let oldestDate: string | null = null
|
||||
function trackDate(date: string) {
|
||||
if (!oldestDate || date < oldestDate) oldestDate = date
|
||||
}
|
||||
|
||||
const ingredientBreakdown: IngredientBreakdown[] = []
|
||||
|
||||
for (const ing of recipe.ingredients) {
|
||||
const ingEntry = getPrice(ing.itemId, city)
|
||||
const unitPrice = ingEntry?.sell_price_min ?? 0
|
||||
if (ingEntry === null || unitPrice === 0) {
|
||||
missingPrices = true
|
||||
ingredientBreakdown.push({
|
||||
itemId: ing.itemId,
|
||||
displayName: formatItemId(ing.itemId),
|
||||
quantity: ing.quantity,
|
||||
unitPrice: 0,
|
||||
totalCost: 0,
|
||||
})
|
||||
} else {
|
||||
trackDate(ingEntry.sell_price_min_date)
|
||||
const totalCost = unitPrice * ing.quantity
|
||||
materialCost += totalCost
|
||||
ingredientBreakdown.push({
|
||||
itemId: ing.itemId,
|
||||
displayName: formatItemId(ing.itemId),
|
||||
quantity: ing.quantity,
|
||||
unitPrice,
|
||||
totalCost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveMaterialCost = materialCost * rrrFactor
|
||||
const priceAgeMs = missingPrices ? null : (oldestDate ? Date.now() - new Date(oldestDate).getTime() : null)
|
||||
|
||||
results.push({
|
||||
recipe,
|
||||
materialCost,
|
||||
effectiveMaterialCost,
|
||||
priceAgeMs,
|
||||
missingPrices,
|
||||
ingredientBreakdown,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort results
|
||||
const { field, direction } = sortState.value
|
||||
results.sort((a, b) => {
|
||||
let aVal: number | string
|
||||
let bVal: number | string
|
||||
|
||||
switch (field) {
|
||||
case 'materialCost':
|
||||
aVal = a.effectiveMaterialCost
|
||||
bVal = b.effectiveMaterialCost
|
||||
break
|
||||
case 'displayName':
|
||||
aVal = a.recipe.displayName
|
||||
bVal = b.recipe.displayName
|
||||
break
|
||||
case 'tier':
|
||||
aVal = a.recipe.tier
|
||||
bVal = b.recipe.tier
|
||||
break
|
||||
case 'variantType':
|
||||
aVal = variantRank(a.recipe.outputItemId)
|
||||
bVal = variantRank(b.recipe.outputItemId)
|
||||
break
|
||||
default:
|
||||
aVal = a.effectiveMaterialCost
|
||||
bVal = b.effectiveMaterialCost
|
||||
}
|
||||
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
return direction === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal)
|
||||
}
|
||||
const diff = (aVal as number) - (bVal as number)
|
||||
return direction === 'asc' ? diff : -diff
|
||||
})
|
||||
|
||||
return results
|
||||
})
|
||||
|
||||
return { profitResults }
|
||||
}
|
||||
79
src/composables/useFilters.ts
Normal file
79
src/composables/useFilters.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ref } from 'vue'
|
||||
import type { FilterState } from '../types/filters'
|
||||
import type { Tier, Enchantment } from '../types/crafting'
|
||||
import type { AlbionCity } from '../types/api'
|
||||
import { ENCHANTMENTS } from '../data/constants'
|
||||
|
||||
function loadCity(): AlbionCity {
|
||||
return (localStorage.getItem('albion-city') as AlbionCity) ?? 'Caerleon'
|
||||
}
|
||||
|
||||
function loadRrr(): number {
|
||||
const v = Number(localStorage.getItem('albion-rrr'))
|
||||
return isNaN(v) || v < 0 || v > 100 ? 15 : v
|
||||
}
|
||||
|
||||
const filters = ref<FilterState>({
|
||||
city: loadCity(),
|
||||
tiers: new Set([4, 5, 6, 7, 8]),
|
||||
selectedItemTypes: null,
|
||||
rrr: loadRrr(),
|
||||
nameFilter: '',
|
||||
enchantments: null,
|
||||
})
|
||||
|
||||
function setCity(city: AlbionCity) {
|
||||
filters.value.city = city
|
||||
localStorage.setItem('albion-city', city)
|
||||
}
|
||||
|
||||
function toggleTier(tier: Tier) {
|
||||
const tiers = new Set(filters.value.tiers)
|
||||
if (tiers.has(tier)) {
|
||||
tiers.delete(tier)
|
||||
} else {
|
||||
tiers.add(tier)
|
||||
}
|
||||
filters.value.tiers = tiers
|
||||
}
|
||||
|
||||
function setSelectedItemTypes(value: Set<string> | null) {
|
||||
filters.value.selectedItemTypes = value
|
||||
}
|
||||
|
||||
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) {
|
||||
filters.value.nameFilter = value
|
||||
}
|
||||
|
||||
function toggleEnchantment(enc: Enchantment) {
|
||||
const current = filters.value.enchantments
|
||||
const next = current === null ? new Set(ENCHANTMENTS as Enchantment[]) : new Set(current)
|
||||
if (next.has(enc)) {
|
||||
next.delete(enc)
|
||||
} else {
|
||||
next.add(enc)
|
||||
}
|
||||
filters.value.enchantments = next.size === ENCHANTMENTS.length ? null : next
|
||||
}
|
||||
|
||||
function resetEnchantments() {
|
||||
filters.value.enchantments = null
|
||||
}
|
||||
|
||||
export function useFilters() {
|
||||
return {
|
||||
filters,
|
||||
setCity,
|
||||
toggleTier,
|
||||
setSelectedItemTypes,
|
||||
setRrr,
|
||||
setNameFilter,
|
||||
toggleEnchantment,
|
||||
resetEnchantments,
|
||||
}
|
||||
}
|
||||
79
src/composables/useProductionOrder.ts
Normal file
79
src/composables/useProductionOrder.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { CraftingRecipe } from '../types/crafting'
|
||||
import { ALL_RECIPES } from '../data/recipes'
|
||||
|
||||
export interface OrderItem {
|
||||
recipe: CraftingRecipe
|
||||
qty: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'albion-production-order'
|
||||
|
||||
const recipeIndex = new Map(ALL_RECIPES.map(r => [r.outputItemId, r]))
|
||||
|
||||
function load(): Map<string, OrderItem> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const items = JSON.parse(raw) as OrderItem[]
|
||||
const result: [string, OrderItem][] = []
|
||||
for (const i of items) {
|
||||
const recipe = recipeIndex.get(i.recipe.outputItemId)
|
||||
if (recipe) result.push([recipe.outputItemId, { recipe, qty: i.qty }])
|
||||
}
|
||||
return new Map(result)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return new Map()
|
||||
}
|
||||
|
||||
function save(map: Map<string, OrderItem>): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...map.values()]))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── Singleton state ──────────────────────────────────────────────────────────
|
||||
|
||||
const order = ref<Map<string, OrderItem>>(load())
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||
|
||||
function upsert(recipe: CraftingRecipe, qty: number): void {
|
||||
if (qty <= 0) { remove(recipe.outputItemId); return }
|
||||
const next = new Map(order.value)
|
||||
next.set(recipe.outputItemId, { recipe, qty })
|
||||
order.value = next
|
||||
save(next)
|
||||
}
|
||||
|
||||
function remove(outputItemId: string): void {
|
||||
const next = new Map(order.value)
|
||||
next.delete(outputItemId)
|
||||
order.value = next
|
||||
save(next)
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
order.value = new Map()
|
||||
save(order.value)
|
||||
}
|
||||
|
||||
// ─── Derived state ────────────────────────────────────────────────────────────
|
||||
|
||||
const orderItems = computed(() => [...order.value.values()])
|
||||
const orderCount = computed(() => order.value.size)
|
||||
|
||||
function getQty(outputItemId: string): number {
|
||||
return order.value.get(outputItemId)?.qty ?? 0
|
||||
}
|
||||
|
||||
function inOrder(outputItemId: string): boolean {
|
||||
return order.value.has(outputItemId)
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useProductionOrder() {
|
||||
return { orderItems, orderCount, upsert, remove, clear, getQty, inOrder }
|
||||
}
|
||||
Reference in New Issue
Block a user