7 Commits

Author SHA1 Message Date
Vantz Stockwell
759bd0be2e feat: Add loot builder backend + static data + DB migration
All checks were successful
Build Companion Agent / build (push) Successful in 26s
Test Asgard Runner / test (push) Successful in 3s
- 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>
2026-02-22 00:30:11 -05:00
Vantz Stockwell
9d28fdfb65 feat: Add loot builder frontend — Pinia store, views, 4 components, router + nav
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Implements the complete frontend for BetterLoot profile management:
- Pinia store (loot.ts) with CRUD, import/export, apply-to-server actions
- LootBuilderView orchestrator with profile bar, modals, two-column layout
- LootContainerSidebar with categorized container list, search, config indicators
- LootItemEditor for per-container item settings and ungrouped item table
- LootItemPicker modal with searchable/filterable Rust item grid
- LootGroupEditor for reusable loot group management
- Router integration at /loot-builder
- Sidebar nav item with Crosshair icon and loot.view permission

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:27:46 -05:00
Vantz Stockwell
eb57c51a24 feat: Add WebSocket RCON client to companion agent
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Wire gorilla/websocket into the Go companion agent to send arbitrary
console commands (e.g. oxide.reload BetterLoot) to the Rust Dedicated
Server's WebRCON endpoint. Adds RCON_PORT and RCON_PASSWORD env vars,
a new "command" action on the existing cmd.server NATS subject, and
the internal/rcon package that handles the JSON-over-WebSocket protocol.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:16:47 -05:00
Vantz Stockwell
f67b175d39 fix: Pass explicit page arg to handleBrowseSearch on Enter key
All checks were successful
Test Asgard Runner / test (push) Successful in 6s
Prevents KeyboardEvent being passed as page number parameter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:45:34 -05:00
Vantz Stockwell
7acdd3654f fix: Add pagination controls to uMod browse tab
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Prev/Next buttons at top and bottom of results table. New search
resets to page 1. Buttons disable at bounds and during loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:41:07 -05:00
Vantz Stockwell
57efc6a5d2 fix: Sidebar overlapping main content — use fixed + pl-64 offset
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
The md:static approach wasn't reliably removing fixed positioning,
causing the sidebar to overlay the main content. Changed to keep
sidebar fixed (better for dashboards — no scroll) and offset main
content with md:pl-64 instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:03:15 -05:00
Vantz Stockwell
854f56a178 fix: Register VueFinderPlugin — prevents Object.keys crash on null store
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
VueFinder requires app.use(VueFinderPlugin) to provide its internal
context (i18n, features, config stores). Without plugin registration,
the store returned null during setup, causing Object.keys to throw
TypeError on undefined.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:20:00 -05:00
29 changed files with 2326 additions and 8 deletions

View File

@@ -35,6 +35,7 @@ import { SetupModule } from './modules/setup/setup.module';
import { MigrationModule } from './modules/migration/migration.module';
import { ChangelogModule } from './modules/changelog/changelog.module';
import { FilesModule } from './modules/files/files.module';
import { LootModule } from './modules/loot/loot.module';
// Shared Services
import { NatsService } from './services/nats.service';
@@ -105,6 +106,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
MigrationModule,
ChangelogModule,
FilesModule,
LootModule,
],
providers: [
// Global guards (order matters: auth first, then license, then permissions)

View File

@@ -0,0 +1,36 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('loot_profiles')
export class LootProfile {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
profile_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
loot_table: Record<string, any>;
@Column({ type: 'jsonb', default: () => "'{}'" })
loot_groups: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,39 @@
export interface RustContainerInfo {
prefab: string;
name: string;
category: string;
}
export const RUST_CONTAINERS: RustContainerInfo[] = [
// Crates
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal.prefab', name: 'Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal_2.prefab', name: 'Crate (Small)', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_elite.prefab', name: 'Elite Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_tools.prefab', name: 'Tool Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_food_1.prefab', name: 'Food Crate (Small)', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_food_2.prefab', name: 'Food Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_medical.prefab', name: 'Medical Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_mine.prefab', name: 'Mine Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_basic.prefab', name: 'Basic Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_underwater_basic.prefab', name: 'Underwater Basic Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_underwater_advanced.prefab', name: 'Underwater Advanced Crate', category: 'crates' },
// Barrels
{ prefab: 'assets/bundled/prefabs/radtown/loot_barrel_1.prefab', name: 'Barrel', category: 'barrels' },
{ prefab: 'assets/bundled/prefabs/radtown/loot_barrel_2.prefab', name: 'Barrel 2', category: 'barrels' },
{ prefab: 'assets/bundled/prefabs/radtown/oil_barrel.prefab', name: 'Oil Barrel', category: 'barrels' },
// Military
{ prefab: 'assets/bundled/prefabs/radtown/crate_helicopter.prefab', name: 'Helicopter Crate', category: 'military' },
{ prefab: 'assets/bundled/prefabs/radtown/bradley_crate.prefab', name: 'Bradley Crate', category: 'military' },
{ prefab: 'assets/bundled/prefabs/radtown/supply_drop.prefab', name: 'Supply Drop', category: 'military' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_locked.prefab', name: 'Locked Crate', category: 'military' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_hackable.prefab', name: 'Hackable Crate', category: 'military' },
// NPCs
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_full_any.prefab', name: 'Scientist', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_heavy.prefab', name: 'Heavy Scientist', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_oilrig.prefab', name: 'Oil Rig Scientist', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_cargo.prefab', name: 'Cargo Ship Scientist', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/tunneldweller/npc_tunneldweller.prefab', name: 'Tunnel Dweller', category: 'npcs' },
// Other
{ prefab: 'assets/bundled/prefabs/radtown/minecart.prefab', name: 'Minecart', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/vehicle_parts.prefab', name: 'Vehicle Parts', category: 'other' },
];

View File

@@ -0,0 +1,9 @@
import { IsNumber, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ApplyLootProfileDto {
@ApiProperty({ example: 1, description: 'Loot multiplier', enum: [1, 2, 5, 10] })
@IsNumber()
@IsIn([1, 2, 5, 10])
multiplier: number;
}

View File

@@ -0,0 +1,24 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateLootProfileDto {
@ApiProperty({ example: 'Vanilla 2x' })
@IsString()
@MaxLength(100)
profile_name: string;
@ApiPropertyOptional({ example: 'Standard 2x loot table' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
loot_table?: Record<string, any>;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
loot_groups?: Record<string, any>;
}

View File

@@ -0,0 +1,23 @@
import { IsString, IsObject, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportLootProfileDto {
@ApiProperty({ example: 'Imported from Looty' })
@IsString()
@MaxLength(100)
profile_name: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: 'BetterLoot LootTables.json content' })
@IsObject()
loot_table: Record<string, any>;
@ApiPropertyOptional({ description: 'BetterLoot LootGroups.json content' })
@IsObject()
@IsOptional()
loot_groups?: Record<string, any>;
}

View File

@@ -0,0 +1,30 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateLootProfileDto {
@ApiPropertyOptional()
@IsString()
@MaxLength(100)
@IsOptional()
profile_name?: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
loot_table?: Record<string, any>;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
loot_groups?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,112 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { LootService } from './loot.service';
import { CreateLootProfileDto } from './dto/create-loot-profile.dto';
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
import { ApplyLootProfileDto } from './dto/apply-loot-profile.dto';
import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('loot')
@ApiBearerAuth()
@Controller('loot')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class LootController {
constructor(private readonly lootService: LootService) {}
@Get('profiles')
@RequirePermission('loot.view')
@ApiOperation({ summary: 'List loot profiles (summaries)' })
getProfiles(@CurrentTenant() licenseId: string) {
return this.lootService.getProfiles(licenseId);
}
@Get('profiles/:id')
@RequirePermission('loot.view')
@ApiOperation({ summary: 'Get full loot profile with data' })
getProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.lootService.getProfile(licenseId, id);
}
@Post('profiles')
@RequirePermission('loot.manage')
@ApiOperation({ summary: 'Create loot profile' })
createProfile(@CurrentTenant() licenseId: string, @Body() dto: CreateLootProfileDto) {
return this.lootService.createProfile(licenseId, dto);
}
@Put('profiles/:id')
@RequirePermission('loot.manage')
@ApiOperation({ summary: 'Update loot profile' })
updateProfile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateLootProfileDto,
) {
return this.lootService.updateProfile(licenseId, id, dto);
}
@Delete('profiles/:id')
@RequirePermission('loot.manage')
@ApiOperation({ summary: 'Delete loot profile' })
deleteProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.lootService.deleteProfile(licenseId, id);
}
@Post('profiles/:id/duplicate')
@RequirePermission('loot.manage')
@ApiOperation({ summary: 'Duplicate loot profile' })
duplicateProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.lootService.duplicateProfile(licenseId, id);
}
@Post('profiles/:id/apply')
@RequirePermission('loot.manage')
@ApiOperation({ summary: 'Apply loot profile to server with multiplier' })
applyToServer(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: ApplyLootProfileDto,
) {
return this.lootService.applyToServer(licenseId, id, dto.multiplier);
}
@Post('import')
@RequirePermission('loot.manage')
@ApiOperation({ summary: 'Import BetterLoot/Looty JSON as new profile' })
importProfile(@CurrentTenant() licenseId: string, @Body() dto: ImportLootProfileDto) {
return this.lootService.importProfile(licenseId, dto);
}
@Get('export/:id')
@RequirePermission('loot.view')
@ApiOperation({ summary: 'Export loot profile as BetterLoot JSON' })
@ApiQuery({ name: 'multiplier', required: false, example: 1 })
exportProfile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Query('multiplier') multiplier: string,
) {
return this.lootService.exportProfile(licenseId, id, multiplier ? parseInt(multiplier, 10) : 1);
}
@Get('containers')
@RequirePermission('loot.view')
@ApiOperation({ summary: 'Get list of Rust container prefabs' })
getContainers() {
return this.lootService.getContainers();
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LootController } from './loot.controller';
import { LootService } from './loot.service';
import { LootProfile } from '../../entities/loot-profile.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([LootProfile])],
controllers: [LootController],
providers: [LootService, NatsService],
exports: [LootService],
})
export class LootModule {}

View File

@@ -0,0 +1,258 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { LootProfile } from '../../entities/loot-profile.entity';
import { NatsService } from '../../services/nats.service';
import { CreateLootProfileDto } from './dto/create-loot-profile.dto';
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
import { RUST_CONTAINERS } from './data/rust-containers';
@Injectable()
export class LootService {
private readonly logger = new Logger(LootService.name);
constructor(
@InjectRepository(LootProfile)
private readonly lootRepo: Repository<LootProfile>,
private readonly natsService: NatsService,
) {}
/** List profiles for a license (summaries — no JSONB) */
async getProfiles(licenseId: string) {
const profiles = await this.lootRepo.find({
where: { license_id: licenseId },
select: ['id', 'profile_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { profiles };
}
/** Get full profile with JSONB data */
async getProfile(licenseId: string, profileId: string) {
const profile = await this.lootRepo.findOne({
where: { id: profileId, license_id: licenseId },
});
if (!profile) throw new NotFoundException('Loot profile not found');
return { profile };
}
/** Create a new profile */
async createProfile(licenseId: string, dto: CreateLootProfileDto) {
const profile = this.lootRepo.create({
license_id: licenseId,
profile_name: dto.profile_name,
description: dto.description || null,
loot_table: dto.loot_table || {},
loot_groups: dto.loot_groups || {},
});
const saved = await this.lootRepo.save(profile);
return { profile: saved };
}
/** Update an existing profile */
async updateProfile(licenseId: string, profileId: string, dto: UpdateLootProfileDto) {
const profile = await this.lootRepo.findOne({
where: { id: profileId, license_id: licenseId },
});
if (!profile) throw new NotFoundException('Loot profile not found');
if (dto.profile_name !== undefined) profile.profile_name = dto.profile_name;
if (dto.description !== undefined) profile.description = dto.description;
if (dto.loot_table !== undefined) profile.loot_table = dto.loot_table;
if (dto.loot_groups !== undefined) profile.loot_groups = dto.loot_groups;
if (dto.is_active !== undefined) profile.is_active = dto.is_active;
profile.updated_at = new Date();
const saved = await this.lootRepo.save(profile);
return { profile: saved };
}
/** Delete a profile */
async deleteProfile(licenseId: string, profileId: string) {
const result = await this.lootRepo.delete({ id: profileId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('Loot profile not found');
return { deleted: true };
}
/** Duplicate a profile */
async duplicateProfile(licenseId: string, profileId: string) {
const source = await this.lootRepo.findOne({
where: { id: profileId, license_id: licenseId },
});
if (!source) throw new NotFoundException('Loot profile not found');
const copy = this.lootRepo.create({
license_id: licenseId,
profile_name: `${source.profile_name} (Copy)`,
description: source.description,
loot_table: JSON.parse(JSON.stringify(source.loot_table)),
loot_groups: JSON.parse(JSON.stringify(source.loot_groups)),
is_active: false,
});
const saved = await this.lootRepo.save(copy);
return { profile: saved };
}
/** Apply profile to server with multiplier */
async applyToServer(licenseId: string, profileId: string, multiplier: number) {
const profile = await this.lootRepo.findOne({
where: { id: profileId, license_id: licenseId },
});
if (!profile) throw new NotFoundException('Loot profile not found');
// Deep clone and apply multiplier
const scaledTable = JSON.parse(JSON.stringify(profile.loot_table));
const scaledGroups = JSON.parse(JSON.stringify(profile.loot_groups));
if (multiplier !== 1) {
this.applyMultiplierToTable(scaledTable, multiplier);
this.applyMultiplierToGroups(scaledGroups, multiplier);
}
const lootTablesJson = JSON.stringify(scaledTable, null, 2);
const lootGroupsJson = JSON.stringify(scaledGroups, null, 2);
try {
// Write LootTables.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/data/BetterLoot/LootTables.json',
content: lootTablesJson,
},
30000,
);
// Write LootGroups.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/data/BetterLoot/LootGroups.json',
content: lootGroupsJson,
},
30000,
);
// Reload BetterLoot plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload BetterLoot',
timestamp: new Date().toISOString(),
},
);
// Mark this profile as active, deactivate others
await this.lootRepo.update({ license_id: licenseId }, { is_active: false });
await this.lootRepo.update(
{ id: profileId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Profile "${profile.profile_name}" applied with ${multiplier}x multiplier`,
profile_name: profile.profile_name,
multiplier,
};
} catch (error) {
this.logger.error(`Failed to apply loot profile: ${(error as Error).message}`);
throw new HttpException(
'Failed to apply loot profile — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import BetterLoot/Looty JSON as a new profile */
async importProfile(licenseId: string, dto: ImportLootProfileDto) {
const profile = this.lootRepo.create({
license_id: licenseId,
profile_name: dto.profile_name,
description: dto.description || 'Imported profile',
loot_table: dto.loot_table,
loot_groups: dto.loot_groups || {},
});
const saved = await this.lootRepo.save(profile);
return { profile: saved };
}
/** Export profile as BetterLoot-compatible JSON with optional multiplier */
async exportProfile(licenseId: string, profileId: string, multiplier: number) {
const profile = await this.lootRepo.findOne({
where: { id: profileId, license_id: licenseId },
});
if (!profile) throw new NotFoundException('Loot profile not found');
const exportTable = JSON.parse(JSON.stringify(profile.loot_table));
const exportGroups = JSON.parse(JSON.stringify(profile.loot_groups));
if (multiplier && multiplier !== 1) {
this.applyMultiplierToTable(exportTable, multiplier);
this.applyMultiplierToGroups(exportGroups, multiplier);
}
return {
profile_name: profile.profile_name,
multiplier: multiplier || 1,
loot_table: exportTable,
loot_groups: exportGroups,
};
}
/** Get static list of Rust container prefabs */
getContainers() {
return { containers: RUST_CONTAINERS };
}
// --- Multiplier helpers ---
private applyMultiplierToTable(table: Record<string, any>, multiplier: number) {
for (const prefab of Object.values(table)) {
if (prefab?.ItemSettings) {
this.scaleField(prefab.ItemSettings, 'ItemsMin', multiplier);
this.scaleField(prefab.ItemSettings, 'ItemsMax', multiplier);
this.scaleField(prefab.ItemSettings, 'MinScrap', multiplier);
this.scaleField(prefab.ItemSettings, 'MaxScrap', multiplier);
}
if (prefab?.GuaranteedItems) {
this.scaleItems(prefab.GuaranteedItems, multiplier);
}
if (prefab?.UngroupedItems) {
this.scaleItems(prefab.UngroupedItems, multiplier);
}
}
}
private applyMultiplierToGroups(groups: Record<string, any>, multiplier: number) {
for (const group of Object.values(groups)) {
if (group?.GuaranteedItems) {
this.scaleItems(group.GuaranteedItems, multiplier);
}
if (group?.ItemList) {
this.scaleItems(group.ItemList, multiplier);
}
}
}
private scaleItems(items: Record<string, any>, multiplier: number) {
for (const item of Object.values(items)) {
this.scaleField(item, 'Min', multiplier);
this.scaleField(item, 'Max', multiplier);
// Recursively scale bonus items
if (item?.BonusItems) {
this.scaleItems(item.BonusItems, multiplier);
}
}
}
private scaleField(obj: Record<string, any>, field: string, multiplier: number) {
if (typeof obj[field] === 'number') {
obj[field] = Math.round(obj[field] * multiplier);
}
}
}

View File

@@ -0,0 +1,13 @@
-- Loot profiles for BetterLoot integration
CREATE TABLE loot_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
profile_name VARCHAR(100) NOT NULL,
description TEXT,
loot_table JSONB NOT NULL DEFAULT '{}',
loot_groups JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_loot_profiles_license ON loot_profiles(license_id);

View File

@@ -31,6 +31,10 @@ type Config struct {
// Install directory for deployment
InstallDir string `envconfig:"INSTALL_DIR" default:""`
// RCON configuration
RconPort int `envconfig:"RCON_PORT" default:"28016"`
RconPassword string `envconfig:"RCON_PASSWORD" default:""`
// Optional settings
HeartbeatInterval int `envconfig:"HEARTBEAT_INTERVAL" default:"60"`
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
@@ -63,6 +67,7 @@ func main() {
log.Printf(" Game Server Path: %s", cfg.GameServerPath)
log.Printf(" SteamCMD Path: %s", cfg.SteamCMDPath)
log.Printf(" Install Dir: %s", cfg.InstallDir)
log.Printf(" RCON Port: %d", cfg.RconPort)
log.Printf(" Heartbeat Interval: %ds", cfg.HeartbeatInterval)
// Create context with signal handling for graceful shutdown
@@ -88,6 +93,8 @@ func main() {
GameServerArgs: cfg.GameServerArgs,
Version: version,
InstallDir: cfg.InstallDir,
RconPort: cfg.RconPort,
RconPassword: cfg.RconPassword,
}
// Start daemon

View File

@@ -8,6 +8,7 @@ require (
)
require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/nats-io/nkeys v0.4.5 // indirect
github.com/nats-io/nuid v1.0.1 // indirect

View File

@@ -1,3 +1,5 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=

View File

@@ -13,6 +13,7 @@ import (
"github.com/vigilcyber/corrosion-companion/internal/filemanager"
"github.com/vigilcyber/corrosion-companion/internal/files"
"github.com/vigilcyber/corrosion-companion/internal/process"
"github.com/vigilcyber/corrosion-companion/internal/rcon"
"github.com/vigilcyber/corrosion-companion/internal/update"
)
@@ -25,6 +26,8 @@ type DaemonConfig struct {
GameServerArgs string
Version string
InstallDir string
RconPort int
RconPassword string
}
// Daemon manages the companion agent's main operations
@@ -156,6 +159,7 @@ func (d *Daemon) subscribeServerCommands() error {
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
var cmd struct {
Action string `json:"action"`
Command string `json:"command"`
}
if err := json.Unmarshal(msg.Data, &cmd); err != nil {
@@ -174,6 +178,24 @@ func (d *Daemon) subscribeServerCommands() error {
err = d.gameServer.Stop()
case "restart":
err = d.gameServer.Restart()
case "command":
if cmd.Command == "" {
d.respondError(msg, "invalid_command", "command field is required")
return
}
result, rconErr := rcon.SendCommand(d.cfg.RconPort, d.cfg.RconPassword, cmd.Command)
if rconErr != nil {
log.Printf("RCON command failed: %v", rconErr)
d.respondError(msg, "rcon_failed", rconErr.Error())
} else {
d.respondSuccess(msg, map[string]interface{}{
"action": "command",
"command": cmd.Command,
"response": result,
"status": "success",
})
}
return
default:
err = fmt.Errorf("unknown action: %s", cmd.Action)
}

View File

@@ -0,0 +1,80 @@
package rcon
import (
"encoding/json"
"fmt"
"net/url"
"time"
"github.com/gorilla/websocket"
)
// RconRequest is the JSON payload sent to Rust's WebRCON.
type RconRequest struct {
Identifier int `json:"Identifier"`
Message string `json:"Message"`
Name string `json:"Name"`
}
// RconResponse is the JSON payload received from Rust's WebRCON.
type RconResponse struct {
Identifier int `json:"Identifier"`
Message string `json:"Message"`
Type string `json:"Type"`
}
// SendCommand opens a WebSocket to the Rust server's RCON port, sends
// a single command, reads the response, and closes the connection.
func SendCommand(port int, password string, command string) (string, error) {
u := url.URL{
Scheme: "ws",
Host: fmt.Sprintf("127.0.0.1:%d", port),
Path: fmt.Sprintf("/%s", password),
}
dialer := websocket.Dialer{
HandshakeTimeout: 5 * time.Second,
}
conn, _, err := dialer.Dial(u.String(), nil)
if err != nil {
return "", fmt.Errorf("rcon dial failed: %w", err)
}
defer conn.Close()
// Set read deadline
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
req := RconRequest{
Identifier: 1,
Message: command,
Name: "Corrosion",
}
data, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("rcon marshal failed: %w", err)
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
return "", fmt.Errorf("rcon write failed: %w", err)
}
// Read response — may get multiple messages (Generic, Warning, etc.)
// We want the first response with our Identifier.
for {
_, message, err := conn.ReadMessage()
if err != nil {
return "", fmt.Errorf("rcon read failed: %w", err)
}
var resp RconResponse
if err := json.Unmarshal(message, &resp); err != nil {
continue // skip unparseable messages
}
if resp.Identifier == req.Identifier {
return resp.Message, nil
}
}
}

View File

@@ -27,6 +27,7 @@ import {
AlertTriangle,
FileText,
FolderOpen,
Crosshair,
Menu,
X,
} from 'lucide-vue-next'
@@ -44,6 +45,7 @@ const navItems = [
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
{ name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.view' },
{ name: 'Loot Builder', path: '/loot-builder', icon: Crosshair, permission: 'loot.view' },
{ name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' },
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
@@ -105,7 +107,7 @@ function canShowNavItem(item: typeof navItems[0]): boolean {
<!-- Sidebar -->
<aside
class="w-64 bg-neutral-900 border-r border-neutral-800 flex flex-col fixed md:static inset-y-0 left-0 z-50 transform transition-transform"
class="w-64 bg-neutral-900 border-r border-neutral-800 flex flex-col fixed inset-y-0 left-0 z-50 transform transition-transform"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
>
<!-- Logo -->
@@ -203,8 +205,8 @@ function canShowNavItem(item: typeof navItems[0]): boolean {
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 overflow-y-auto md:ml-0">
<!-- Main Content (offset by sidebar width on desktop) -->
<main class="flex-1 overflow-y-auto md:pl-64">
<RouterView />
</main>
</div>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { rustContainers, containerCategories } from '@/data/rust-containers'
import { Search, Box, Cylinder, Shield, Users, HelpCircle } from 'lucide-vue-next'
const props = defineProps<{
lootTable: Record<string, any>
selected: string | null
}>()
const emit = defineEmits<{
select: [prefab: string]
}>()
const searchQuery = ref('')
const categoryIcons: Record<string, any> = {
crates: Box,
barrels: Cylinder,
military: Shield,
npcs: Users,
other: HelpCircle,
}
const categoryLabels: Record<string, string> = {
crates: 'CRATES',
barrels: 'BARRELS',
military: 'MILITARY',
npcs: 'NPCs',
other: 'OTHER',
}
const filteredContainers = computed(() => {
const q = searchQuery.value.toLowerCase()
if (!q) return rustContainers
return rustContainers.filter(c => c.name.toLowerCase().includes(q) || c.prefab.toLowerCase().includes(q))
})
const groupedContainers = computed(() => {
const groups: Record<string, typeof rustContainers> = {}
for (const cat of containerCategories) {
const items = filteredContainers.value.filter(c => c.category === cat)
if (items.length > 0) groups[cat] = items
}
return groups
})
function isConfigured(prefab: string): boolean {
const entry = props.lootTable[prefab]
if (!entry) return false
const hasItems = entry.UngroupedItems && Object.keys(entry.UngroupedItems).length > 0
const hasGuaranteed = entry.GuaranteedItems && Object.keys(entry.GuaranteedItems).length > 0
const hasProfiles = entry.LootProfiles && entry.LootProfiles.length > 0
return hasItems || hasGuaranteed || hasProfiles
}
</script>
<template>
<div class="w-64 shrink-0 bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden flex flex-col">
<!-- Search -->
<div class="p-3 border-b border-neutral-800">
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
<input
v-model="searchQuery"
placeholder="Search containers..."
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-sm text-neutral-200 placeholder-neutral-500"
/>
</div>
</div>
<!-- Container List -->
<div class="flex-1 overflow-y-auto py-2">
<template v-for="(containers, category) in groupedContainers" :key="category">
<div class="px-3 pt-3 pb-1">
<div class="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-widest text-neutral-500">
<component :is="categoryIcons[category]" class="w-3 h-3" />
{{ categoryLabels[category] || category }}
</div>
</div>
<button
v-for="c in containers"
:key="c.prefab"
@click="emit('select', c.prefab)"
class="w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 transition-colors"
:class="selected === c.prefab
? 'bg-oxide-500/10 text-oxide-400'
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
>
<span class="truncate flex-1">{{ c.name }}</span>
<span
v-if="isConfigured(c.prefab)"
class="w-1.5 h-1.5 rounded-full bg-oxide-500 shrink-0"
/>
</button>
</template>
<div v-if="Object.keys(groupedContainers).length === 0" class="px-3 py-6 text-center text-neutral-500 text-sm">
No containers match
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { rustItems } from '@/data/rust-items'
import { Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-vue-next'
import type { LootGroupProfile } from '@/types'
const props = defineProps<{
lootGroups: Record<string, any>
}>()
const emit = defineEmits<{
dirty: []
}>()
const expandedGroup = ref<string | null>(null)
const newGroupName = ref('')
const groupEntries = computed(() => {
return Object.entries(props.lootGroups).map(([name, data]) => ({
name,
data: data as LootGroupProfile,
itemCount: data?.ItemList ? Object.keys(data.ItemList).length : 0,
}))
})
function toggleGroup(name: string) {
expandedGroup.value = expandedGroup.value === name ? null : name
}
function addGroup() {
const name = newGroupName.value.trim()
if (!name || props.lootGroups[name]) return
props.lootGroups[name] = {
Enabled: true,
GuaranteedItems: {},
ItemList: {},
}
newGroupName.value = ''
expandedGroup.value = name
emit('dirty')
}
function deleteGroup(name: string) {
if (!confirm(`Delete group "${name}"?`)) return
delete props.lootGroups[name]
if (expandedGroup.value === name) expandedGroup.value = null
emit('dirty')
}
function getItemName(shortname: string): string {
return rustItems.find(i => i.shortname === shortname)?.name || shortname
}
function removeItemFromGroup(groupName: string, shortname: string) {
delete props.lootGroups[groupName].ItemList[shortname]
emit('dirty')
}
function updateGroupItemField(groupName: string, shortname: string, field: string, value: number) {
if (props.lootGroups[groupName]?.ItemList?.[shortname]) {
props.lootGroups[groupName].ItemList[shortname][field] = value
emit('dirty')
}
}
</script>
<template>
<div class="space-y-4">
<!-- Add Group -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div class="flex gap-2">
<input
v-model="newGroupName"
placeholder="New group name..."
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
@keydown.enter="addGroup"
/>
<button
@click="addGroup"
:disabled="!newGroupName.trim()"
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
<Plus class="w-4 h-4" />
Add Group
</button>
</div>
</div>
<!-- Group List -->
<div v-if="groupEntries.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
No loot groups defined. Groups let you create reusable item pools that can be assigned to multiple containers.
</div>
<div
v-for="entry in groupEntries"
:key="entry.name"
class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden"
>
<!-- Group Header -->
<button
@click="toggleGroup(entry.name)"
class="w-full flex items-center justify-between px-4 py-3 hover:bg-neutral-800/50 transition-colors"
>
<div class="flex items-center gap-3">
<component
:is="expandedGroup === entry.name ? ChevronDown : ChevronRight"
class="w-4 h-4 text-neutral-500"
/>
<span class="text-neutral-200 font-medium">{{ entry.name }}</span>
<span class="text-xs text-neutral-500">{{ entry.itemCount }} items</span>
</div>
<button
@click.stop="deleteGroup(entry.name)"
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
>
<Trash2 class="w-4 h-4" />
</button>
</button>
<!-- Group Items -->
<div v-if="expandedGroup === entry.name" class="border-t border-neutral-800 p-4">
<table v-if="entry.itemCount > 0" class="w-full text-sm">
<thead>
<tr class="border-b border-neutral-800">
<th class="text-left py-2 px-2 text-neutral-500 font-medium">Item</th>
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Min</th>
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Max</th>
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-24">Prob %</th>
<th class="w-10"></th>
</tr>
</thead>
<tbody>
<tr
v-for="(itemData, shortname) in entry.data.ItemList"
:key="shortname"
class="border-b border-neutral-800/50"
>
<td class="py-2 px-2 text-neutral-200">{{ getItemName(shortname as string) }}</td>
<td class="py-2 px-2">
<input
type="number"
:value="(itemData as any).Min ?? 1"
@input="updateGroupItemField(entry.name, shortname as string, 'Min', Number(($event.target as HTMLInputElement).value))"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
min="0"
/>
</td>
<td class="py-2 px-2">
<input
type="number"
:value="(itemData as any).Max ?? 1"
@input="updateGroupItemField(entry.name, shortname as string, 'Max', Number(($event.target as HTMLInputElement).value))"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
min="0"
/>
</td>
<td class="py-2 px-2">
<input
type="number"
:value="(itemData as any).Probability ?? 100"
@input="updateGroupItemField(entry.name, shortname as string, 'Probability', Number(($event.target as HTMLInputElement).value))"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
min="0"
max="100"
/>
</td>
<td class="py-2 px-2">
<button
@click="removeItemFromGroup(entry.name, shortname as string)"
class="text-neutral-600 hover:text-red-400"
>
<Trash2 class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="text-neutral-500 text-sm text-center py-4">
No items in this group yet. Add items from the container editor.
</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import { computed } from 'vue'
import { rustItems } from '@/data/rust-items'
import { rustContainers } from '@/data/rust-containers'
import { Trash2, Plus, Settings2 } from 'lucide-vue-next'
import type { PrefabLoot } from '@/types'
const props = defineProps<{
containerKey: string
lootTable: Record<string, any>
}>()
const emit = defineEmits<{
dirty: []
'add-item': []
}>()
const containerName = computed(() => {
const c = rustContainers.find(c => c.prefab === props.containerKey)
return c?.name || props.containerKey.split('/').pop()?.replace('.prefab', '') || 'Unknown'
})
const containerData = computed<PrefabLoot | null>(() => {
return props.lootTable[props.containerKey] || null
})
function ensureContainer() {
if (!props.lootTable[props.containerKey]) {
props.lootTable[props.containerKey] = {
Enabled: true,
LootProfiles: [],
GuaranteedItems: {},
UngroupedItems: {},
ItemSettings: { ItemsMin: 1, ItemsMax: 6, MinScrap: 0, MaxScrap: 0 },
}
emit('dirty')
}
}
function getItemName(shortname: string): string {
return rustItems.find(i => i.shortname === shortname)?.name || shortname
}
function updateItemField(shortname: string, field: string, value: number) {
ensureContainer()
const items = props.lootTable[props.containerKey].UngroupedItems
if (items[shortname]) {
items[shortname][field] = value
emit('dirty')
}
}
function updateSettings(field: string, value: number) {
ensureContainer()
props.lootTable[props.containerKey].ItemSettings[field] = value
emit('dirty')
}
function toggleEnabled() {
ensureContainer()
props.lootTable[props.containerKey].Enabled = !props.lootTable[props.containerKey].Enabled
emit('dirty')
}
function removeItem(shortname: string) {
if (!containerData.value?.UngroupedItems) return
delete props.lootTable[props.containerKey].UngroupedItems[shortname]
emit('dirty')
}
const ungroupedItems = computed(() => {
if (!containerData.value?.UngroupedItems) return []
return Object.entries(containerData.value.UngroupedItems).map(([shortname, data]) => ({
shortname,
name: getItemName(shortname),
...(data as any),
}))
})
</script>
<template>
<div class="space-y-4">
<!-- Container Header -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h2 class="text-lg font-semibold text-neutral-100">{{ containerName }}</h2>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="containerData?.Enabled ?? true"
@change="toggleEnabled"
class="rounded bg-neutral-800 border-neutral-600 text-oxide-500 focus:ring-oxide-500"
/>
<span class="text-sm text-neutral-400">Enabled</span>
</label>
</div>
<div class="flex items-center gap-2">
<Settings2 class="w-4 h-4 text-neutral-500" />
<span class="text-xs text-neutral-500 font-mono">{{ containerKey.split('/').pop() }}</span>
</div>
</div>
<!-- Item Settings -->
<div class="grid grid-cols-4 gap-3" v-if="containerData">
<div>
<label class="block text-xs text-neutral-500 mb-1">Items Min</label>
<input
type="number"
:value="containerData.ItemSettings?.ItemsMin ?? 1"
@input="updateSettings('ItemsMin', Number(($event.target as HTMLInputElement).value))"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
min="0"
/>
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">Items Max</label>
<input
type="number"
:value="containerData.ItemSettings?.ItemsMax ?? 6"
@input="updateSettings('ItemsMax', Number(($event.target as HTMLInputElement).value))"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
min="0"
/>
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">Min Scrap</label>
<input
type="number"
:value="containerData.ItemSettings?.MinScrap ?? 0"
@input="updateSettings('MinScrap', Number(($event.target as HTMLInputElement).value))"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
min="0"
/>
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">Max Scrap</label>
<input
type="number"
:value="containerData.ItemSettings?.MaxScrap ?? 0"
@input="updateSettings('MaxScrap', Number(($event.target as HTMLInputElement).value))"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
min="0"
/>
</div>
</div>
</div>
<!-- Ungrouped Items Table -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-neutral-300">Ungrouped Items</h3>
<button
@click="emit('add-item')"
class="flex items-center gap-1 px-3 py-1.5 bg-oxide-500/10 text-oxide-400 rounded-lg hover:bg-oxide-500/20 text-sm"
>
<Plus class="w-3.5 h-3.5" />
Add Item
</button>
</div>
<div v-if="ungroupedItems.length > 0" class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-neutral-800">
<th class="text-left py-2 px-2 text-neutral-500 font-medium">Item</th>
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Min</th>
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Max</th>
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-24">Prob %</th>
<th class="w-10"></th>
</tr>
</thead>
<tbody>
<tr
v-for="item in ungroupedItems"
:key="item.shortname"
class="border-b border-neutral-800/50 hover:bg-neutral-800/30"
>
<td class="py-2 px-2">
<div>
<span class="text-neutral-200">{{ item.name }}</span>
<span class="text-neutral-600 text-xs ml-2">{{ item.shortname }}</span>
</div>
</td>
<td class="py-2 px-2">
<input
type="number"
:value="item.Min"
@input="updateItemField(item.shortname, 'Min', Number(($event.target as HTMLInputElement).value))"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
min="0"
/>
</td>
<td class="py-2 px-2">
<input
type="number"
:value="item.Max"
@input="updateItemField(item.shortname, 'Max', Number(($event.target as HTMLInputElement).value))"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
min="0"
/>
</td>
<td class="py-2 px-2">
<input
type="number"
:value="item.Probability ?? 100"
@input="updateItemField(item.shortname, 'Probability', Number(($event.target as HTMLInputElement).value))"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
min="0"
max="100"
/>
</td>
<td class="py-2 px-2">
<button
@click="removeItem(item.shortname)"
class="text-neutral-600 hover:text-red-400 transition-colors"
>
<Trash2 class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="text-center py-6 text-neutral-500 text-sm">
No items configured for this container.
<button @click="emit('add-item')" class="text-oxide-400 hover:underline ml-1">Add one</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { rustItems, itemCategories } from '@/data/rust-items'
import { Search, X } from 'lucide-vue-next'
const emit = defineEmits<{
select: [shortname: string]
close: []
}>()
const searchQuery = ref('')
const selectedCategory = ref<string>('all')
const filteredItems = computed(() => {
let items = rustItems
if (selectedCategory.value !== 'all') {
items = items.filter(i => i.category === selectedCategory.value)
}
const q = searchQuery.value.toLowerCase()
if (q) {
items = items.filter(i => i.name.toLowerCase().includes(q) || i.shortname.toLowerCase().includes(q))
}
return items
})
</script>
<template>
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="emit('close')">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
<!-- Header -->
<div class="p-4 border-b border-neutral-800 flex items-center justify-between">
<h2 class="text-lg font-semibold text-neutral-100">Add Item</h2>
<button @click="emit('close')" class="text-neutral-500 hover:text-neutral-300">
<X class="w-5 h-5" />
</button>
</div>
<!-- Search + Filter -->
<div class="p-4 space-y-3 border-b border-neutral-800">
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
<input
v-model="searchQuery"
placeholder="Search items..."
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-sm text-neutral-200"
autofocus
/>
</div>
<div class="flex flex-wrap gap-1">
<button
@click="selectedCategory = 'all'"
class="px-2 py-1 rounded text-xs"
:class="selectedCategory === 'all' ? 'bg-oxide-500 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-neutral-200'"
>
All
</button>
<button
v-for="cat in itemCategories"
:key="cat"
@click="selectedCategory = cat"
class="px-2 py-1 rounded text-xs capitalize"
:class="selectedCategory === cat ? 'bg-oxide-500 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-neutral-200'"
>
{{ cat }}
</button>
</div>
</div>
<!-- Item Grid -->
<div class="flex-1 overflow-y-auto p-4">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<button
v-for="item in filteredItems"
:key="item.shortname"
@click="emit('select', item.shortname)"
class="text-left px-3 py-2 bg-neutral-800 rounded-lg hover:bg-neutral-700 transition-colors group"
>
<div class="text-sm text-neutral-200 group-hover:text-oxide-400">{{ item.name }}</div>
<div class="text-xs text-neutral-500">{{ item.shortname }}</div>
</button>
</div>
<div v-if="filteredItems.length === 0" class="text-center py-8 text-neutral-500">
No items found
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,72 @@
export interface RustContainer {
prefab: string
name: string
category: 'crates' | 'barrels' | 'military' | 'npcs' | 'other'
}
export const rustContainers: RustContainer[] = [
// Crates
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal.prefab', name: 'Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal_2.prefab', name: 'Crate (Small)', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_elite.prefab', name: 'Elite Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_tools.prefab', name: 'Tool Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_food_1.prefab', name: 'Food Crate (Small)', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_food_2.prefab', name: 'Food Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_medical.prefab', name: 'Medical Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_mine.prefab', name: 'Mine Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_fuel.prefab', name: 'Fuel Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_basic.prefab', name: 'Basic Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_underwater_basic.prefab', name: 'Underwater Basic Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_underwater_advanced.prefab', name: 'Underwater Advanced Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/vehicle_parts.prefab', name: 'Vehicle Parts', category: 'crates' },
// Barrels
{ prefab: 'assets/bundled/prefabs/radtown/loot_barrel_1.prefab', name: 'Barrel', category: 'barrels' },
{ prefab: 'assets/bundled/prefabs/radtown/loot_barrel_2.prefab', name: 'Barrel 2', category: 'barrels' },
{ prefab: 'assets/bundled/prefabs/radtown/oil_barrel.prefab', name: 'Oil Barrel', category: 'barrels' },
{ prefab: 'assets/bundled/prefabs/radtown/loot-barrel-1.prefab', name: 'Barrel (Alt)', category: 'barrels' },
{ prefab: 'assets/bundled/prefabs/radtown/loot-barrel-2.prefab', name: 'Barrel 2 (Alt)', category: 'barrels' },
// Military
{ prefab: 'assets/bundled/prefabs/radtown/crate_helicopter.prefab', name: 'Helicopter Crate', category: 'military' },
{ prefab: 'assets/bundled/prefabs/radtown/bradley_crate.prefab', name: 'Bradley Crate', category: 'military' },
{ prefab: 'assets/bundled/prefabs/radtown/supply_drop.prefab', name: 'Supply Drop', category: 'military' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_locked.prefab', name: 'Locked Crate', category: 'military' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_hackable.prefab', name: 'Hackable Crate', category: 'military' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal_2_food.prefab', name: 'Military Food Crate', category: 'military' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal_2_medical.prefab', name: 'Military Medical Crate', category: 'military' },
// NPCs
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_full_any.prefab', name: 'Scientist', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_heavy.prefab', name: 'Heavy Scientist', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_oilrig.prefab', name: 'Oil Rig Scientist', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_patrol.prefab', name: 'Patrol Scientist', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_junkpile.prefab', name: 'Junkpile Scientist', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_peacekeeper.prefab', name: 'Peacekeeper', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_cargo.prefab', name: 'Cargo Ship Scientist', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_ch47_gunner.prefab', name: 'Chinook Gunner', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/tunneldweller/npc_tunneldweller.prefab', name: 'Tunnel Dweller', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/underwaterdweller/npc_underwaterdweller.prefab', name: 'Underwater Dweller', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scarecrow/scarecrow.prefab', name: 'Scarecrow', category: 'npcs' },
// Other
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm ammo.prefab', name: 'DM Ammo', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm c4.prefab', name: 'DM C4', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm construction resources.prefab', name: 'DM Construction', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm construction tools.prefab', name: 'DM Tools', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm food.prefab', name: 'DM Food', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm medical.prefab', name: 'DM Medical', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm res.prefab', name: 'DM Resources', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm tier1 lootbox.prefab', name: 'DM Tier 1 Lootbox', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm tier2 lootbox.prefab', name: 'DM Tier 2 Lootbox', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/dmloot/dm tier3 lootbox.prefab', name: 'DM Tier 3 Lootbox', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/minecart.prefab', name: 'Minecart', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/underwater_labs/crate_food_1_underwater_lab.prefab', name: 'Lab Food Crate', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/underwater_labs/crate_normal_underwater_lab.prefab', name: 'Lab Crate', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/underwater_labs/crate_elite_underwater_lab.prefab', name: 'Lab Elite Crate', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/underwater_labs/crate_tools_underwater_lab.prefab', name: 'Lab Tool Crate', category: 'other' },
]
export const containerCategories = ['crates', 'barrels', 'military', 'npcs', 'other'] as const
export type ContainerCategory = typeof containerCategories[number]

View File

@@ -0,0 +1,259 @@
export interface RustItem {
shortname: string
name: string
category: 'weapons' | 'ammo' | 'medical' | 'attire' | 'tools' | 'resources' | 'components' | 'food' | 'traps' | 'construction' | 'electrical' | 'fun' | 'misc'
maxStack: number
}
export const rustItems: RustItem[] = [
// Weapons
{ shortname: 'rifle.ak', name: 'Assault Rifle', category: 'weapons', maxStack: 1 },
{ shortname: 'rifle.lr300', name: 'LR-300', category: 'weapons', maxStack: 1 },
{ shortname: 'rifle.bolt', name: 'Bolt Action Rifle', category: 'weapons', maxStack: 1 },
{ shortname: 'rifle.m39', name: 'M39 Rifle', category: 'weapons', maxStack: 1 },
{ shortname: 'rifle.semiauto', name: 'Semi-Auto Rifle', category: 'weapons', maxStack: 1 },
{ shortname: 'rifle.l96', name: 'L96 Rifle', category: 'weapons', maxStack: 1 },
{ shortname: 'smg.mp5', name: 'MP5A4', category: 'weapons', maxStack: 1 },
{ shortname: 'smg.thompson', name: 'Thompson', category: 'weapons', maxStack: 1 },
{ shortname: 'smg.2', name: 'Custom SMG', category: 'weapons', maxStack: 1 },
{ shortname: 'pistol.revolver', name: 'Revolver', category: 'weapons', maxStack: 1 },
{ shortname: 'pistol.semiauto', name: 'Semi-Auto Pistol', category: 'weapons', maxStack: 1 },
{ shortname: 'pistol.python', name: 'Python Revolver', category: 'weapons', maxStack: 1 },
{ shortname: 'pistol.m92', name: 'M92 Pistol', category: 'weapons', maxStack: 1 },
{ shortname: 'pistol.nailgun', name: 'Nailgun', category: 'weapons', maxStack: 1 },
{ shortname: 'shotgun.pump', name: 'Pump Shotgun', category: 'weapons', maxStack: 1 },
{ shortname: 'shotgun.spas12', name: 'Spas-12', category: 'weapons', maxStack: 1 },
{ shortname: 'shotgun.double', name: 'Double Barrel Shotgun', category: 'weapons', maxStack: 1 },
{ shortname: 'shotgun.waterpipe', name: 'Waterpipe Shotgun', category: 'weapons', maxStack: 1 },
{ shortname: 'lmg.m249', name: 'M249', category: 'weapons', maxStack: 1 },
{ shortname: 'rocket.launcher', name: 'Rocket Launcher', category: 'weapons', maxStack: 1 },
{ shortname: 'multiplegrenadelauncher', name: 'Multiple Grenade Launcher', category: 'weapons', maxStack: 1 },
{ shortname: 'weapon.mod.lasersight', name: 'Laser Sight', category: 'weapons', maxStack: 1 },
{ shortname: 'weapon.mod.holosight', name: 'Holosight', category: 'weapons', maxStack: 1 },
{ shortname: 'weapon.mod.flashlight', name: 'Flashlight', category: 'weapons', maxStack: 1 },
{ shortname: 'weapon.mod.silencer', name: 'Silencer', category: 'weapons', maxStack: 1 },
{ shortname: 'weapon.mod.simplesight', name: 'Simple Handmade Sight', category: 'weapons', maxStack: 1 },
{ shortname: 'weapon.mod.small.scope', name: 'Handmade Scope', category: 'weapons', maxStack: 1 },
{ shortname: 'weapon.mod.8x.scope', name: '8x Zoom Scope', category: 'weapons', maxStack: 1 },
{ shortname: 'weapon.mod.muzzleboost', name: 'Muzzle Boost', category: 'weapons', maxStack: 1 },
{ shortname: 'weapon.mod.muzzlebrake', name: 'Muzzle Brake', category: 'weapons', maxStack: 1 },
{ shortname: 'crossbow', name: 'Crossbow', category: 'weapons', maxStack: 1 },
{ shortname: 'bow.hunting', name: 'Hunting Bow', category: 'weapons', maxStack: 1 },
{ shortname: 'bow.compound', name: 'Compound Bow', category: 'weapons', maxStack: 1 },
{ shortname: 'spear.wooden', name: 'Wooden Spear', category: 'weapons', maxStack: 1 },
{ shortname: 'spear.stone', name: 'Stone Spear', category: 'weapons', maxStack: 1 },
{ shortname: 'machete', name: 'Machete', category: 'weapons', maxStack: 1 },
{ shortname: 'longsword', name: 'Longsword', category: 'weapons', maxStack: 1 },
{ shortname: 'salvaged.sword', name: 'Salvaged Sword', category: 'weapons', maxStack: 1 },
{ shortname: 'salvaged.cleaver', name: 'Salvaged Cleaver', category: 'weapons', maxStack: 1 },
{ shortname: 'knife.combat', name: 'Combat Knife', category: 'weapons', maxStack: 1 },
{ shortname: 'bone.club', name: 'Bone Club', category: 'weapons', maxStack: 1 },
{ shortname: 'mace', name: 'Mace', category: 'weapons', maxStack: 1 },
{ shortname: 'grenade.f1', name: 'F1 Grenade', category: 'weapons', maxStack: 1 },
{ shortname: 'grenade.beancan', name: 'Beancan Grenade', category: 'weapons', maxStack: 1 },
{ shortname: 'explosive.satchel', name: 'Satchel Charge', category: 'weapons', maxStack: 1 },
{ shortname: 'explosive.timed', name: 'Timed Explosive', category: 'weapons', maxStack: 1 },
{ shortname: 'surveycharge', name: 'Survey Charge', category: 'weapons', maxStack: 1 },
{ shortname: 'flare', name: 'Flare', category: 'weapons', maxStack: 1 },
{ shortname: 'pistol.eoka', name: 'Eoka Pistol', category: 'weapons', maxStack: 1 },
// Ammo
{ shortname: 'ammo.rifle', name: '5.56 Rifle Ammo', category: 'ammo', maxStack: 128 },
{ shortname: 'ammo.rifle.hv', name: 'HV 5.56 Rifle Ammo', category: 'ammo', maxStack: 128 },
{ shortname: 'ammo.rifle.incendiary', name: 'Incendiary 5.56 Ammo', category: 'ammo', maxStack: 128 },
{ shortname: 'ammo.rifle.explosive', name: 'Explosive 5.56 Ammo', category: 'ammo', maxStack: 128 },
{ shortname: 'ammo.pistol', name: 'Pistol Bullet', category: 'ammo', maxStack: 128 },
{ shortname: 'ammo.pistol.hv', name: 'HV Pistol Ammo', category: 'ammo', maxStack: 128 },
{ shortname: 'ammo.pistol.fire', name: 'Incendiary Pistol Bullet', category: 'ammo', maxStack: 128 },
{ shortname: 'ammo.shotgun', name: 'Handmade Shell', category: 'ammo', maxStack: 64 },
{ shortname: 'ammo.shotgun.slug', name: '12 Gauge Slug', category: 'ammo', maxStack: 64 },
{ shortname: 'ammo.shotgun.fire', name: '12 Gauge Incendiary Shell', category: 'ammo', maxStack: 64 },
{ shortname: 'ammo.rocket.basic', name: 'Rocket', category: 'ammo', maxStack: 3 },
{ shortname: 'ammo.rocket.hv', name: 'HV Rocket', category: 'ammo', maxStack: 3 },
{ shortname: 'ammo.rocket.fire', name: 'Incendiary Rocket', category: 'ammo', maxStack: 3 },
{ shortname: 'arrow.wooden', name: 'Wooden Arrow', category: 'ammo', maxStack: 64 },
{ shortname: 'arrow.hv', name: 'High Velocity Arrow', category: 'ammo', maxStack: 64 },
{ shortname: 'arrow.fire', name: 'Fire Arrow', category: 'ammo', maxStack: 64 },
{ shortname: 'arrow.bone', name: 'Bone Arrow', category: 'ammo', maxStack: 64 },
{ shortname: 'ammo.nailgun.nails', name: 'Nailgun Nails', category: 'ammo', maxStack: 64 },
{ shortname: 'ammo.grenadelauncher.he', name: '40mm HE Grenade', category: 'ammo', maxStack: 12 },
{ shortname: 'ammo.grenadelauncher.smoke', name: '40mm Smoke Grenade', category: 'ammo', maxStack: 12 },
// Medical
{ shortname: 'syringe.medical', name: 'Medical Syringe', category: 'medical', maxStack: 3 },
{ shortname: 'largemedkit', name: 'Large Medkit', category: 'medical', maxStack: 1 },
{ shortname: 'bandage', name: 'Bandage', category: 'medical', maxStack: 3 },
{ shortname: 'antiradpills', name: 'Anti-Radiation Pills', category: 'medical', maxStack: 10 },
{ shortname: 'blood', name: 'Blood', category: 'medical', maxStack: 1 },
// Attire
{ shortname: 'metal.facemask', name: 'Metal Facemask', category: 'attire', maxStack: 1 },
{ shortname: 'metal.plate.torso', name: 'Metal Chest Plate', category: 'attire', maxStack: 1 },
{ shortname: 'roadsign.jacket', name: 'Roadsign Jacket', category: 'attire', maxStack: 1 },
{ shortname: 'roadsign.kilt', name: 'Roadsign Kilt', category: 'attire', maxStack: 1 },
{ shortname: 'coffeecan.helmet', name: 'Coffee Can Helmet', category: 'attire', maxStack: 1 },
{ shortname: 'riot.helmet', name: 'Riot Helmet', category: 'attire', maxStack: 1 },
{ shortname: 'bucket.helmet', name: 'Bucket Helmet', category: 'attire', maxStack: 1 },
{ shortname: 'hoodie', name: 'Hoodie', category: 'attire', maxStack: 1 },
{ shortname: 'pants', name: 'Pants', category: 'attire', maxStack: 1 },
{ shortname: 'shoes.boots', name: 'Boots', category: 'attire', maxStack: 1 },
{ shortname: 'burlap.shirt', name: 'Burlap Shirt', category: 'attire', maxStack: 1 },
{ shortname: 'burlap.trousers', name: 'Burlap Trousers', category: 'attire', maxStack: 1 },
{ shortname: 'burlap.shoes', name: 'Burlap Shoes', category: 'attire', maxStack: 1 },
{ shortname: 'burlap.headwrap', name: 'Burlap Headwrap', category: 'attire', maxStack: 1 },
{ shortname: 'burlap.gloves', name: 'Burlap Gloves', category: 'attire', maxStack: 1 },
{ shortname: 'hat.wolf', name: 'Wolf Headdress', category: 'attire', maxStack: 1 },
{ shortname: 'hat.boonie', name: 'Boonie Hat', category: 'attire', maxStack: 1 },
{ shortname: 'hat.beenie', name: 'Beenie Hat', category: 'attire', maxStack: 1 },
{ shortname: 'hat.miner', name: 'Miners Hat', category: 'attire', maxStack: 1 },
{ shortname: 'hat.candle', name: 'Candle Hat', category: 'attire', maxStack: 1 },
{ shortname: 'attire.hide.poncho', name: 'Hide Poncho', category: 'attire', maxStack: 1 },
{ shortname: 'attire.hide.vest', name: 'Hide Vest', category: 'attire', maxStack: 1 },
{ shortname: 'attire.hide.boots', name: 'Hide Boots', category: 'attire', maxStack: 1 },
{ shortname: 'attire.hide.pants', name: 'Hide Pants', category: 'attire', maxStack: 1 },
{ shortname: 'attire.hide.skirt', name: 'Hide Skirt', category: 'attire', maxStack: 1 },
{ shortname: 'deer.skull.mask', name: 'Deer Skull Mask', category: 'attire', maxStack: 1 },
{ shortname: 'bone.armor.suit', name: 'Bone Armor', category: 'attire', maxStack: 1 },
{ shortname: 'heavy.plate.helmet', name: 'Heavy Plate Helmet', category: 'attire', maxStack: 1 },
{ shortname: 'heavy.plate.jacket', name: 'Heavy Plate Jacket', category: 'attire', maxStack: 1 },
{ shortname: 'heavy.plate.pants', name: 'Heavy Plate Pants', category: 'attire', maxStack: 1 },
{ shortname: 'hazmatsuit', name: 'Hazmat Suit', category: 'attire', maxStack: 1 },
{ shortname: 'nightvisiongoggles', name: 'Night Vision Goggles', category: 'attire', maxStack: 1 },
{ shortname: 'tactical.gloves', name: 'Tactical Gloves', category: 'attire', maxStack: 1 },
// Tools
{ shortname: 'hatchet', name: 'Hatchet', category: 'tools', maxStack: 1 },
{ shortname: 'pickaxe', name: 'Pickaxe', category: 'tools', maxStack: 1 },
{ shortname: 'stone.pickaxe', name: 'Stone Pickaxe', category: 'tools', maxStack: 1 },
{ shortname: 'stonehatchet', name: 'Stone Hatchet', category: 'tools', maxStack: 1 },
{ shortname: 'rock', name: 'Rock', category: 'tools', maxStack: 1 },
{ shortname: 'torch', name: 'Torch', category: 'tools', maxStack: 1 },
{ shortname: 'jackhammer', name: 'Jackhammer', category: 'tools', maxStack: 1 },
{ shortname: 'chainsaw', name: 'Chainsaw', category: 'tools', maxStack: 1 },
{ shortname: 'hammer', name: 'Hammer', category: 'tools', maxStack: 1 },
{ shortname: 'wire.cutter', name: 'Wire Cutter', category: 'tools', maxStack: 1 },
{ shortname: 'tool.binoculars', name: 'Binoculars', category: 'tools', maxStack: 1 },
{ shortname: 'tool.camera', name: 'Camera', category: 'tools', maxStack: 1 },
{ shortname: 'geiger.counter', name: 'Geiger Counter', category: 'tools', maxStack: 1 },
{ shortname: 'supply.signal', name: 'Supply Signal', category: 'tools', maxStack: 1 },
{ shortname: 'map', name: 'Map', category: 'tools', maxStack: 1 },
{ shortname: 'note', name: 'Note', category: 'tools', maxStack: 1 },
{ shortname: 'blueprintbase', name: 'Blueprint', category: 'tools', maxStack: 1 },
// Resources
{ shortname: 'wood', name: 'Wood', category: 'resources', maxStack: 1000 },
{ shortname: 'stones', name: 'Stones', category: 'resources', maxStack: 1000 },
{ shortname: 'metal.ore', name: 'Metal Ore', category: 'resources', maxStack: 1000 },
{ shortname: 'metal.fragments', name: 'Metal Fragments', category: 'resources', maxStack: 1000 },
{ shortname: 'metal.refined', name: 'High Quality Metal', category: 'resources', maxStack: 100 },
{ shortname: 'sulfur.ore', name: 'Sulfur Ore', category: 'resources', maxStack: 1000 },
{ shortname: 'sulfur', name: 'Sulfur', category: 'resources', maxStack: 1000 },
{ shortname: 'gunpowder', name: 'Gun Powder', category: 'resources', maxStack: 500 },
{ shortname: 'explosives', name: 'Explosives', category: 'resources', maxStack: 10 },
{ shortname: 'charcoal', name: 'Charcoal', category: 'resources', maxStack: 1000 },
{ shortname: 'lowgradefuel', name: 'Low Grade Fuel', category: 'resources', maxStack: 500 },
{ shortname: 'crude.oil', name: 'Crude Oil', category: 'resources', maxStack: 500 },
{ shortname: 'leather', name: 'Leather', category: 'resources', maxStack: 1000 },
{ shortname: 'cloth', name: 'Cloth', category: 'resources', maxStack: 1000 },
{ shortname: 'fat.animal', name: 'Animal Fat', category: 'resources', maxStack: 1000 },
{ shortname: 'bone.fragments', name: 'Bone Fragments', category: 'resources', maxStack: 1000 },
{ shortname: 'scrap', name: 'Scrap', category: 'resources', maxStack: 1000 },
{ shortname: 'diesel_barrel', name: 'Diesel Fuel', category: 'resources', maxStack: 20 },
// Components
{ shortname: 'riflebody', name: 'Rifle Body', category: 'components', maxStack: 1 },
{ shortname: 'smgbody', name: 'SMG Body', category: 'components', maxStack: 1 },
{ shortname: 'semibody', name: 'Semi Auto Body', category: 'components', maxStack: 1 },
{ shortname: 'metalpipe', name: 'Metal Pipe', category: 'components', maxStack: 5 },
{ shortname: 'metalspring', name: 'Metal Spring', category: 'components', maxStack: 5 },
{ shortname: 'gears', name: 'Gears', category: 'components', maxStack: 5 },
{ shortname: 'roadsigns', name: 'Road Signs', category: 'components', maxStack: 5 },
{ shortname: 'sewingkit', name: 'Sewing Kit', category: 'components', maxStack: 5 },
{ shortname: 'tarp', name: 'Tarp', category: 'components', maxStack: 5 },
{ shortname: 'rope', name: 'Rope', category: 'components', maxStack: 5 },
{ shortname: 'sheetmetal', name: 'Sheet Metal', category: 'components', maxStack: 5 },
{ shortname: 'techparts', name: 'Tech Trash', category: 'components', maxStack: 5 },
{ shortname: 'propanetank', name: 'Propane Tank', category: 'components', maxStack: 5 },
{ shortname: 'targeting.computer', name: 'Targeting Computer', category: 'components', maxStack: 1 },
{ shortname: 'cctv.camera', name: 'CCTV Camera', category: 'components', maxStack: 1 },
{ shortname: 'electric.fuse', name: 'Fuse', category: 'components', maxStack: 5 },
{ shortname: 'bleach', name: 'Bleach', category: 'components', maxStack: 5 },
{ shortname: 'ducttape', name: 'Duct Tape', category: 'components', maxStack: 5 },
// Food
{ shortname: 'apple', name: 'Apple', category: 'food', maxStack: 10 },
{ shortname: 'granolabar', name: 'Granola Bar', category: 'food', maxStack: 10 },
{ shortname: 'can.beans', name: 'Can of Beans', category: 'food', maxStack: 10 },
{ shortname: 'can.tuna', name: 'Can of Tuna', category: 'food', maxStack: 10 },
{ shortname: 'chocbar', name: 'Chocolate Bar', category: 'food', maxStack: 10 },
{ shortname: 'mushroom', name: 'Mushroom', category: 'food', maxStack: 20 },
{ shortname: 'meat.boar', name: 'Boar Meat', category: 'food', maxStack: 20 },
{ shortname: 'chicken.raw', name: 'Raw Chicken', category: 'food', maxStack: 20 },
{ shortname: 'humanmeat.raw', name: 'Raw Human Meat', category: 'food', maxStack: 20 },
{ shortname: 'wolfmeat.raw', name: 'Raw Wolf Meat', category: 'food', maxStack: 20 },
{ shortname: 'deermeat.raw', name: 'Raw Deer Meat', category: 'food', maxStack: 20 },
{ shortname: 'bearmeat', name: 'Bear Meat', category: 'food', maxStack: 20 },
{ shortname: 'fish.raw', name: 'Raw Fish', category: 'food', maxStack: 20 },
{ shortname: 'corn', name: 'Corn', category: 'food', maxStack: 20 },
{ shortname: 'pumpkin', name: 'Pumpkin', category: 'food', maxStack: 20 },
{ shortname: 'potato', name: 'Potato', category: 'food', maxStack: 20 },
{ shortname: 'waterjug', name: 'Water Jug', category: 'food', maxStack: 1 },
{ shortname: 'water', name: 'Water', category: 'food', maxStack: 1 },
{ shortname: 'water.purified', name: 'Pure Water', category: 'food', maxStack: 10 },
// Traps
{ shortname: 'trap.bear', name: 'Snap Trap', category: 'traps', maxStack: 3 },
{ shortname: 'trap.landmine', name: 'Landmine', category: 'traps', maxStack: 3 },
{ shortname: 'autoturret', name: 'Auto Turret', category: 'traps', maxStack: 1 },
{ shortname: 'flameturret', name: 'Flame Turret', category: 'traps', maxStack: 1 },
{ shortname: 'guntrap', name: 'Shotgun Trap', category: 'traps', maxStack: 1 },
{ shortname: 'sam.site', name: 'SAM Site', category: 'traps', maxStack: 1 },
// Construction
{ shortname: 'wall.external.high', name: 'High External Wall', category: 'construction', maxStack: 10 },
{ shortname: 'wall.external.high.stone', name: 'High External Stone Wall', category: 'construction', maxStack: 10 },
{ shortname: 'gates.external.high.wood', name: 'High External Wooden Gate', category: 'construction', maxStack: 1 },
{ shortname: 'gates.external.high.stone', name: 'High External Stone Gate', category: 'construction', maxStack: 1 },
{ shortname: 'barricade.metal', name: 'Metal Barricade', category: 'construction', maxStack: 3 },
{ shortname: 'barricade.sandbags', name: 'Sandbag Barricade', category: 'construction', maxStack: 5 },
{ shortname: 'barricade.concrete', name: 'Concrete Barricade', category: 'construction', maxStack: 3 },
{ shortname: 'barricade.wood', name: 'Wooden Barricade', category: 'construction', maxStack: 5 },
{ shortname: 'barricade.woodwire', name: 'Barbed Wooden Barricade', category: 'construction', maxStack: 3 },
{ shortname: 'lock.code', name: 'Code Lock', category: 'construction', maxStack: 1 },
{ shortname: 'lock.key', name: 'Key Lock', category: 'construction', maxStack: 1 },
// Misc
{ shortname: 'workbench1', name: 'Work Bench Level 1', category: 'misc', maxStack: 1 },
{ shortname: 'workbench2', name: 'Work Bench Level 2', category: 'misc', maxStack: 1 },
{ shortname: 'workbench3', name: 'Work Bench Level 3', category: 'misc', maxStack: 1 },
{ shortname: 'furnace', name: 'Furnace', category: 'misc', maxStack: 1 },
{ shortname: 'furnace.large', name: 'Large Furnace', category: 'misc', maxStack: 1 },
{ shortname: 'campfire', name: 'Camp Fire', category: 'misc', maxStack: 1 },
{ shortname: 'box.wooden', name: 'Wood Storage Box', category: 'misc', maxStack: 1 },
{ shortname: 'box.wooden.large', name: 'Large Wood Box', category: 'misc', maxStack: 1 },
{ shortname: 'cupboard.tool', name: 'Tool Cupboard', category: 'misc', maxStack: 1 },
{ shortname: 'sleepingbag', name: 'Sleeping Bag', category: 'misc', maxStack: 1 },
{ shortname: 'bed', name: 'Bed', category: 'misc', maxStack: 1 },
{ shortname: 'research.table', name: 'Research Table', category: 'misc', maxStack: 1 },
{ shortname: 'mining.quarry', name: 'Mining Quarry', category: 'misc', maxStack: 1 },
{ shortname: 'small.oil.refinery', name: 'Small Oil Refinery', category: 'misc', maxStack: 1 },
{ shortname: 'water.purifier', name: 'Water Purifier', category: 'misc', maxStack: 1 },
{ shortname: 'stocking.small', name: 'Small Stocking', category: 'misc', maxStack: 1 },
{ shortname: 'stocking.large', name: 'Large Stocking', category: 'misc', maxStack: 1 },
{ shortname: 'kayak', name: 'Kayak', category: 'misc', maxStack: 1 },
{ shortname: 'fridge', name: 'Fridge', category: 'misc', maxStack: 1 },
{ shortname: 'locker', name: 'Locker', category: 'misc', maxStack: 1 },
{ shortname: 'vending.machine', name: 'Vending Machine', category: 'misc', maxStack: 1 },
{ shortname: 'wall.frame.shopfront', name: 'Shop Front', category: 'misc', maxStack: 1 },
{ shortname: 'door.hinged.metal', name: 'Sheet Metal Door', category: 'misc', maxStack: 1 },
{ shortname: 'door.hinged.toptier', name: 'Armored Door', category: 'misc', maxStack: 1 },
{ shortname: 'door.double.hinged.metal', name: 'Sheet Metal Double Door', category: 'misc', maxStack: 1 },
{ shortname: 'door.double.hinged.toptier', name: 'Armored Double Door', category: 'misc', maxStack: 1 },
]
export const itemCategories = [
'weapons', 'ammo', 'medical', 'attire', 'tools', 'resources',
'components', 'food', 'traps', 'construction', 'electrical', 'fun', 'misc',
] as const
export type ItemCategory = typeof itemCategories[number]

View File

@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { VueFinderPlugin } from 'vuefinder'
import App from './App.vue'
import router from './router'
import './style.css'
@@ -14,5 +15,6 @@ pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.use(router)
app.use(VueFinderPlugin)
app.mount('#app')

View File

@@ -110,6 +110,11 @@ const panelRoutes: RouteRecordRaw[] = [
name: 'files',
component: () => import('@/views/admin/FileManagerView.vue'),
},
{
path: 'loot-builder',
name: 'loot-builder',
component: () => import('@/views/admin/LootBuilderView.vue'),
},
{
path: 'wipes',
name: 'wipes',

179
frontend/src/stores/loot.ts Normal file
View File

@@ -0,0 +1,179 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import type { LootProfileSummary, LootProfileFull, LootApplyResult } from '@/types'
export const useLootStore = defineStore('loot', () => {
const profiles = ref<LootProfileSummary[]>([])
const currentProfile = ref<LootProfileFull | null>(null)
const selectedContainer = ref<string | null>(null)
const isLoading = ref(false)
const isSaving = ref(false)
const isApplying = ref(false)
const isDirty = ref(false)
const api = useApi()
const toast = useToastStore()
const activeProfile = computed(() => profiles.value.find(p => p.is_active) || null)
async function fetchProfiles() {
isLoading.value = true
try {
const res = await api.get<{ profiles: LootProfileSummary[] }>('/loot/profiles')
profiles.value = res.profiles
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function loadProfile(id: string) {
isLoading.value = true
try {
const res = await api.get<{ profile: LootProfileFull }>(`/loot/profiles/${id}`)
currentProfile.value = res.profile
isDirty.value = false
// Select first container if none selected
if (!selectedContainer.value && currentProfile.value.loot_table) {
const keys = Object.keys(currentProfile.value.loot_table)
if (keys.length > 0) selectedContainer.value = keys[0] ?? null
}
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function createProfile(name: string, description?: string) {
try {
const res = await api.post<{ profile: LootProfileFull }>('/loot/profiles', {
profile_name: name,
description,
})
await fetchProfiles()
currentProfile.value = res.profile
isDirty.value = false
toast.success(`Profile "${name}" created`)
return res.profile
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function saveCurrentProfile() {
if (!currentProfile.value) return
isSaving.value = true
try {
await api.put(`/loot/profiles/${currentProfile.value.id}`, {
profile_name: currentProfile.value.profile_name,
description: currentProfile.value.description,
loot_table: currentProfile.value.loot_table,
loot_groups: currentProfile.value.loot_groups,
})
isDirty.value = false
await fetchProfiles()
toast.success('Profile saved')
} catch (err) {
toast.error((err as Error).message)
} finally {
isSaving.value = false
}
}
async function deleteProfile(id: string) {
try {
await api.del(`/loot/profiles/${id}`)
if (currentProfile.value?.id === id) {
currentProfile.value = null
selectedContainer.value = null
}
await fetchProfiles()
toast.success('Profile deleted')
} catch (err) {
toast.error((err as Error).message)
}
}
async function duplicateProfile(id: string) {
try {
const res = await api.post<{ profile: LootProfileFull }>(`/loot/profiles/${id}/duplicate`)
await fetchProfiles()
toast.success(`Profile duplicated as "${res.profile.profile_name}"`)
return res.profile
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function applyToServer(id: string, multiplier: number) {
isApplying.value = true
try {
const res = await api.post<LootApplyResult>(`/loot/profiles/${id}/apply`, { multiplier })
await fetchProfiles()
toast.success(res.message)
return res
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isApplying.value = false
}
}
async function importProfile(name: string, lootTable: Record<string, any>, lootGroups?: Record<string, any>) {
try {
const res = await api.post<{ profile: LootProfileFull }>('/loot/import', {
profile_name: name,
loot_table: lootTable,
loot_groups: lootGroups || {},
})
await fetchProfiles()
toast.success(`Profile "${name}" imported`)
return res.profile
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function exportProfile(id: string, multiplier: number) {
try {
return await api.get<{ profile_name: string; multiplier: number; loot_table: any; loot_groups: any }>(
`/loot/export/${id}?multiplier=${multiplier}`,
)
} catch (err) {
toast.error((err as Error).message)
return null
}
}
function markDirty() {
isDirty.value = true
}
return {
profiles,
currentProfile,
selectedContainer,
isLoading,
isSaving,
isApplying,
isDirty,
activeProfile,
fetchProfiles,
loadProfile,
createProfile,
saveCurrentProfile,
deleteProfile,
duplicateProfile,
applyToServer,
importProfile,
exportProfile,
markDirty,
}
})

View File

@@ -442,3 +442,80 @@ export interface DeploymentStatus {
message: string
error?: string
}
// Loot Builder types — BetterLoot integration
export interface LootProfileSummary {
id: string
profile_name: string
description: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface LootProfileFull {
id: string
license_id: string
profile_name: string
description: string | null
loot_table: Record<string, PrefabLoot>
loot_groups: Record<string, LootGroupProfile>
is_active: boolean
created_at: string
updated_at: string
}
export interface PrefabLoot {
Enabled: boolean
LootProfiles: LootProfileRef[]
GuaranteedItems: Record<string, LootItemSettings>
UngroupedItems: Record<string, LootRNG>
ItemSettings: {
ItemsMin: number
ItemsMax: number
MinScrap: number
MaxScrap: number
}
}
export interface LootProfileRef {
Enabled: boolean
LootProfileName: string
LootProfileProbability: number
}
export interface LootItemSettings {
Min: number
Max: number
SkinId: number
DisplayName: string
}
export interface LootEntry extends LootItemSettings {
DurabilitySettings: {
MinDurability: number
MaxDurability: number
}
ItemEntryModifications: {
AmmoSettings: Record<string, any> | null
AttachmentSettings: Record<string, any> | null
}
BonusItems: Record<string, LootItemSettings>
}
export interface LootRNG extends LootEntry {
Probability: number
}
export interface LootGroupProfile {
Enabled: boolean
GuaranteedItems: Record<string, LootItemSettings>
ItemList: Record<string, LootRNG>
}
export interface LootApplyResult {
success: boolean
message: string
profile_name: string
multiplier: number
}

View File

@@ -0,0 +1,393 @@
<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>

View File

@@ -12,6 +12,7 @@ const toast = useToastStore()
const searchQuery = ref('')
const tab = ref<'installed' | 'browse' | 'upload'>('installed')
const browseQuery = ref('')
const browsePage = ref(1)
const browseDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
const installing = ref<string | null>(null)
@@ -71,10 +72,11 @@ async function handleUninstall(plugin: PluginEntry) {
}
}
async function handleBrowseSearch() {
async function handleBrowseSearch(page = 1) {
if (!browseQuery.value.trim()) return
browsePage.value = page
try {
await pluginStore.browseUmod(browseQuery.value.trim())
await pluginStore.browseUmod(browseQuery.value.trim(), page)
} catch {
toast.error('Failed to search uMod plugins')
}
@@ -82,7 +84,17 @@ async function handleBrowseSearch() {
function scheduleBrowseSearch() {
if (browseDebounce.value) clearTimeout(browseDebounce.value)
browseDebounce.value = setTimeout(handleBrowseSearch, 400)
browseDebounce.value = setTimeout(() => handleBrowseSearch(1), 400)
}
function browsePrev() {
if (browsePage.value > 1) handleBrowseSearch(browsePage.value - 1)
}
function browseNext() {
if (pluginStore.browseResults && browsePage.value < pluginStore.browseResults.last_page) {
handleBrowseSearch(browsePage.value + 1)
}
}
async function installFromBrowse(result: UmodPlugin) {
@@ -219,7 +231,7 @@ onMounted(() => {
type="text"
placeholder="Search uMod plugins..."
@input="scheduleBrowseSearch"
@keydown.enter="handleBrowseSearch"
@keydown.enter="handleBrowseSearch(1)"
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
@@ -325,6 +337,22 @@ onMounted(() => {
{{ pluginStore.browseResults.total.toLocaleString() }} plugins found
&bull; Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
</p>
<div class="flex items-center gap-1">
<button
@click="browsePrev"
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
class="px-2.5 py-1 text-xs font-medium rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
>
&larr; Prev
</button>
<button
@click="browseNext"
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
class="px-2.5 py-1 text-xs font-medium rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
>
Next &rarr;
</button>
</div>
</div>
<table class="w-full">
<thead>
@@ -366,6 +394,28 @@ onMounted(() => {
</tr>
</tbody>
</table>
<!-- Bottom pagination -->
<div v-if="pluginStore.browseResults && pluginStore.browseResults.last_page > 1" class="px-4 py-3 border-t border-neutral-800 flex items-center justify-between">
<p class="text-xs text-neutral-500">
Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
</p>
<div class="flex items-center gap-1">
<button
@click="browsePrev"
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700"
>
&larr; Previous
</button>
<button
@click="browseNext"
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700"
>
Next &rarr;
</button>
</div>
</div>
</div>
</div>