- Migration 013: loot_profiles table (JSONB loot_table + loot_groups, license-scoped) - TypeORM entity matching migration schema exactly - NestJS loot module: 10 endpoints (CRUD, duplicate, apply, import, export, containers) - Multiplier logic recursively scales Min/Max/Scrap across loot tables and groups - Apply-to-server writes BetterLoot JSON via NATS file manager + RCON reload - Frontend static data: 191 Rust items, 51 container prefabs - TypeScript types for BetterLoot data model (PrefabLoot, LootEntry, LootRNG, etc.) - Fix vue-tsc errors: UngroupedItems uses LootRNG, null safety in store/view Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
394 lines
14 KiB
Vue
394 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { useLootStore } from '@/stores/loot'
|
|
import { useToastStore } from '@/stores/toast'
|
|
import LootContainerSidebar from '@/components/loot/LootContainerSidebar.vue'
|
|
import LootItemEditor from '@/components/loot/LootItemEditor.vue'
|
|
import LootGroupEditor from '@/components/loot/LootGroupEditor.vue'
|
|
import LootItemPicker from '@/components/loot/LootItemPicker.vue'
|
|
import { Save, Upload, Download, Play, Copy, Trash2, Plus, Layers } from 'lucide-vue-next'
|
|
|
|
const loot = useLootStore()
|
|
const toast = useToastStore()
|
|
|
|
const showCreateModal = ref(false)
|
|
const showImportModal = ref(false)
|
|
const showItemPicker = ref(false)
|
|
const newProfileName = ref('')
|
|
const newProfileDesc = ref('')
|
|
const selectedMultiplier = ref(1)
|
|
const showApplyDropdown = ref(false)
|
|
const importJson = ref('')
|
|
const importName = ref('')
|
|
const activeTab = ref<'items' | 'groups'>('items')
|
|
|
|
const multipliers = [1, 2, 5, 10]
|
|
|
|
onMounted(async () => {
|
|
await loot.fetchProfiles()
|
|
if (loot.profiles.length > 0 && loot.profiles[0]) {
|
|
await loot.loadProfile(loot.profiles[0].id)
|
|
}
|
|
})
|
|
|
|
async function handleCreateProfile() {
|
|
if (!newProfileName.value.trim()) return
|
|
const profile = await loot.createProfile(newProfileName.value.trim(), newProfileDesc.value.trim() || undefined)
|
|
if (profile) {
|
|
showCreateModal.value = false
|
|
newProfileName.value = ''
|
|
newProfileDesc.value = ''
|
|
}
|
|
}
|
|
|
|
async function handleDeleteProfile() {
|
|
if (!loot.currentProfile) return
|
|
if (!confirm(`Delete "${loot.currentProfile.profile_name}"?`)) return
|
|
await loot.deleteProfile(loot.currentProfile.id)
|
|
}
|
|
|
|
async function handleDuplicate() {
|
|
if (!loot.currentProfile) return
|
|
const dup = await loot.duplicateProfile(loot.currentProfile.id)
|
|
if (dup) await loot.loadProfile(dup.id)
|
|
}
|
|
|
|
async function handleApply(mult: number) {
|
|
if (!loot.currentProfile) return
|
|
showApplyDropdown.value = false
|
|
if (loot.isDirty) {
|
|
await loot.saveCurrentProfile()
|
|
}
|
|
await loot.applyToServer(loot.currentProfile.id, mult)
|
|
}
|
|
|
|
async function handleImport() {
|
|
if (!importName.value.trim() || !importJson.value.trim()) return
|
|
try {
|
|
const parsed = JSON.parse(importJson.value)
|
|
// Support both full export format and raw LootTables format
|
|
const lootTable = parsed.loot_table || parsed
|
|
const lootGroups = parsed.loot_groups || {}
|
|
await loot.importProfile(importName.value.trim(), lootTable, lootGroups)
|
|
showImportModal.value = false
|
|
importJson.value = ''
|
|
importName.value = ''
|
|
} catch {
|
|
toast.error('Invalid JSON')
|
|
}
|
|
}
|
|
|
|
async function handleExport() {
|
|
if (!loot.currentProfile) return
|
|
const data = await loot.exportProfile(loot.currentProfile.id, selectedMultiplier.value)
|
|
if (!data) return
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `${data.profile_name}_${data.multiplier}x.json`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
async function handleProfileChange(id: string) {
|
|
if (loot.isDirty) {
|
|
if (!confirm('Unsaved changes will be lost. Continue?')) return
|
|
}
|
|
await loot.loadProfile(id)
|
|
}
|
|
|
|
function handleAddItem(shortname: string) {
|
|
if (!loot.currentProfile || !loot.selectedContainer) return
|
|
const table = loot.currentProfile.loot_table
|
|
if (!table[loot.selectedContainer]) {
|
|
table[loot.selectedContainer] = {
|
|
Enabled: true,
|
|
LootProfiles: [],
|
|
GuaranteedItems: {},
|
|
UngroupedItems: {},
|
|
ItemSettings: { ItemsMin: 1, ItemsMax: 6, MinScrap: 0, MaxScrap: 0 },
|
|
}
|
|
}
|
|
const container = table[loot.selectedContainer]!
|
|
if (!container.UngroupedItems) container.UngroupedItems = {}
|
|
if (!container.UngroupedItems[shortname]) {
|
|
container.UngroupedItems[shortname] = {
|
|
Min: 1,
|
|
Max: 1,
|
|
SkinId: 0,
|
|
DisplayName: '',
|
|
Probability: 50,
|
|
DurabilitySettings: { MinDurability: 1, MaxDurability: 1 },
|
|
ItemEntryModifications: { AmmoSettings: null, AttachmentSettings: null },
|
|
BonusItems: {},
|
|
}
|
|
loot.markDirty()
|
|
}
|
|
showItemPicker.value = false
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="p-6 space-y-4">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between">
|
|
<h1 class="text-2xl font-bold text-neutral-100">Loot Builder</h1>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
@click="showCreateModal = true"
|
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
>
|
|
<Plus class="w-4 h-4" />
|
|
New Profile
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Profile Bar -->
|
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
|
<div class="flex items-center gap-3 flex-wrap">
|
|
<!-- Profile Selector -->
|
|
<select
|
|
v-if="loot.profiles.length > 0"
|
|
:value="loot.currentProfile?.id || ''"
|
|
@change="handleProfileChange(($event.target as HTMLSelectElement).value)"
|
|
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
|
>
|
|
<option v-for="p in loot.profiles" :key="p.id" :value="p.id">
|
|
{{ p.profile_name }}
|
|
<template v-if="p.is_active"> (Active)</template>
|
|
</option>
|
|
</select>
|
|
<span v-else class="text-neutral-500 text-sm">No profiles yet</span>
|
|
|
|
<!-- Save -->
|
|
<button
|
|
@click="loot.saveCurrentProfile()"
|
|
:disabled="!loot.currentProfile || !loot.isDirty || loot.isSaving"
|
|
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
|
>
|
|
<Save class="w-4 h-4" />
|
|
{{ loot.isSaving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
|
|
<!-- Apply Dropdown -->
|
|
<div class="relative">
|
|
<button
|
|
@click="showApplyDropdown = !showApplyDropdown"
|
|
:disabled="!loot.currentProfile || loot.isApplying"
|
|
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
|
>
|
|
<Play class="w-4 h-4" />
|
|
{{ loot.isApplying ? 'Applying...' : 'Apply to Server' }}
|
|
</button>
|
|
<div
|
|
v-if="showApplyDropdown"
|
|
class="absolute top-full mt-1 right-0 bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl z-10 py-1 min-w-[140px]"
|
|
>
|
|
<button
|
|
v-for="m in multipliers"
|
|
:key="m"
|
|
@click="handleApply(m)"
|
|
class="w-full text-left px-4 py-2 text-sm text-neutral-300 hover:bg-neutral-700"
|
|
>
|
|
{{ m }}x Multiplier
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Duplicate -->
|
|
<button
|
|
@click="handleDuplicate"
|
|
:disabled="!loot.currentProfile"
|
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 disabled:opacity-50 text-sm"
|
|
>
|
|
<Copy class="w-4 h-4" />
|
|
Duplicate
|
|
</button>
|
|
|
|
<!-- Import -->
|
|
<button
|
|
@click="showImportModal = true"
|
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
>
|
|
<Upload class="w-4 h-4" />
|
|
Import
|
|
</button>
|
|
|
|
<!-- Export -->
|
|
<button
|
|
@click="handleExport"
|
|
:disabled="!loot.currentProfile"
|
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 disabled:opacity-50 text-sm"
|
|
>
|
|
<Download class="w-4 h-4" />
|
|
Export
|
|
</button>
|
|
|
|
<!-- Delete -->
|
|
<button
|
|
@click="handleDeleteProfile"
|
|
:disabled="!loot.currentProfile"
|
|
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
|
>
|
|
<Trash2 class="w-4 h-4" />
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<div v-if="loot.currentProfile" class="flex gap-4" style="height: calc(100vh - 250px)">
|
|
<!-- Sidebar -->
|
|
<LootContainerSidebar
|
|
:loot-table="loot.currentProfile.loot_table"
|
|
:selected="loot.selectedContainer"
|
|
@select="loot.selectedContainer = $event"
|
|
/>
|
|
|
|
<!-- Editor Area -->
|
|
<div class="flex-1 flex flex-col min-w-0">
|
|
<!-- Tabs -->
|
|
<div class="flex border-b border-neutral-800 mb-4">
|
|
<button
|
|
@click="activeTab = 'items'"
|
|
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
|
:class="activeTab === 'items' ? 'border-oxide-500 text-oxide-400' : 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
|
>
|
|
Container Items
|
|
</button>
|
|
<button
|
|
@click="activeTab = 'groups'"
|
|
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-2"
|
|
:class="activeTab === 'groups' ? 'border-oxide-500 text-oxide-400' : 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
|
>
|
|
<Layers class="w-4 h-4" />
|
|
Loot Groups
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto">
|
|
<LootItemEditor
|
|
v-if="activeTab === 'items' && loot.selectedContainer"
|
|
:container-key="loot.selectedContainer"
|
|
:loot-table="loot.currentProfile.loot_table"
|
|
@dirty="loot.markDirty()"
|
|
@add-item="showItemPicker = true"
|
|
/>
|
|
<div v-else-if="activeTab === 'items'" class="flex items-center justify-center h-full text-neutral-500">
|
|
Select a container from the sidebar
|
|
</div>
|
|
|
|
<LootGroupEditor
|
|
v-if="activeTab === 'groups'"
|
|
:loot-groups="loot.currentProfile.loot_groups"
|
|
@dirty="loot.markDirty()"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-else-if="!loot.isLoading" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
|
<Layers class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
|
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Loot Profile Selected</h2>
|
|
<p class="text-neutral-500 mb-4">Create a new profile or select one from the dropdown above.</p>
|
|
<button
|
|
@click="showCreateModal = true"
|
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
|
>
|
|
Create First Profile
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="loot.isLoading" class="flex items-center justify-center py-20">
|
|
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
|
</div>
|
|
|
|
<!-- Create Modal -->
|
|
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New Loot Profile</h2>
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm text-neutral-400 mb-1">Profile Name</label>
|
|
<input
|
|
v-model="newProfileName"
|
|
placeholder="e.g. Vanilla 2x"
|
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
|
@keydown.enter="handleCreateProfile"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
|
<textarea
|
|
v-model="newProfileDesc"
|
|
rows="2"
|
|
placeholder="What is this profile for?"
|
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
|
/>
|
|
</div>
|
|
<div class="flex justify-end gap-2">
|
|
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
|
<button
|
|
@click="handleCreateProfile"
|
|
:disabled="!newProfileName.trim()"
|
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
>
|
|
Create
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Import Modal -->
|
|
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-lg">
|
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import Loot Profile</h2>
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm text-neutral-400 mb-1">Profile Name</label>
|
|
<input
|
|
v-model="importName"
|
|
placeholder="Name for imported profile"
|
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-neutral-400 mb-1">BetterLoot JSON</label>
|
|
<textarea
|
|
v-model="importJson"
|
|
rows="10"
|
|
placeholder="Paste LootTables.json content here..."
|
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm font-mono resize-none"
|
|
/>
|
|
</div>
|
|
<div class="flex justify-end gap-2">
|
|
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
|
<button
|
|
@click="handleImport"
|
|
:disabled="!importName.trim() || !importJson.trim()"
|
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
>
|
|
Import
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Item Picker Modal -->
|
|
<LootItemPicker
|
|
v-if="showItemPicker"
|
|
@select="handleAddItem"
|
|
@close="showItemPicker = false"
|
|
/>
|
|
|
|
<!-- Click-away for apply dropdown -->
|
|
<div v-if="showApplyDropdown" class="fixed inset-0 z-0" @click="showApplyDropdown = false" />
|
|
</div>
|
|
</template>
|