initial commit

This commit is contained in:
Jo Blo
2026-03-04 17:01:08 -05:00
commit 39d61d797d
51 changed files with 7864 additions and 0 deletions

View 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,
}
}

View 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 }
}

View 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 }
}

View 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,
}
}

View 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 }
}