13 Commits

Author SHA1 Message Date
Vantz Stockwell
6461417b50 feat: Add one-click Oxide/uMod installer — backend + frontend
All checks were successful
Build Companion Agent / build (push) Successful in 24s
Test Asgard Runner / test (push) Successful in 3s
POST /servers/install-oxide endpoint, NATS bridge for oxide.status,
server store installOxide method, ServerView Install Oxide card with
progress tracker matching the Deploy card pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:56:59 -05:00
Vantz Stockwell
380ab2700c feat: Add Oxide/uMod installer package + wire into companion agent daemon
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
New oxide package downloads latest Oxide.Rust release from GitHub,
extracts over the server directory, and restarts the game server.
Progress published to NATS (corrosion.{license_id}.oxide.status).
Heartbeat now reports oxide_installed status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:53:27 -05:00
Vantz Stockwell
585e8aa3f7 feat: Add teleport_configs DB migration + TypeORM entity
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:16:08 -05:00
Vantz Stockwell
4d087132db feat: Add teleport config frontend — Pinia store, views, 2 components, router + nav
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:14:59 -05:00
Vantz Stockwell
16f378eada feat: Add CorrosionTeleportGUI uMod plugin — in-game teleport CUI
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Standalone C# uMod plugin that provides a full-screen CUI teleport
interface for Rust game servers, integrating with NTeleportation.

Features:
- /tpgui chat command opens tabbed overlay (Teleport, Homes, Warps, Settings)
- 4x5 player grid with search filtering and pagination for TPR
- Home management (teleport, set, delete) via NTeleportation API
- Server warp list with teleport buttons
- Incoming TPR accept/deny popup with 30s auto-dismiss
- Settings tab showing cooldowns, limits, NTeleportation status
- Oxide-orange color scheme matching Corrosion brand

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:12:34 -05:00
Vantz Stockwell
3e1af29b38 feat: Add teleport module backend — NestJS CRUD + NATS deploy/import
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Seven endpoints for managing NTeleportation configs: list summaries,
get full config, create, update, delete, deploy to server via NATS,
and import live config from server. Follows loot module pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:11:44 -05:00
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
50 changed files with 5912 additions and 23 deletions

View File

@@ -35,6 +35,8 @@ 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';
import { TeleportModule } from './modules/teleport/teleport.module';
// Shared Services
import { NatsService } from './services/nats.service';
@@ -105,6 +107,8 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
MigrationModule,
ChangelogModule,
FilesModule,
LootModule,
TeleportModule,
],
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,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('teleport_configs')
export class TeleportConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: 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

@@ -73,4 +73,11 @@ export class ServersController {
) {
return await this.serversService.deployServer(licenseId, dto);
}
@Post('install-oxide')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Install Oxide/uMod via companion agent' })
async installOxide(@CurrentTenant() licenseId: string) {
return await this.serversService.installOxide(licenseId);
}
}

View File

@@ -103,4 +103,12 @@ export class ServersService {
await this.natsService.sendDeployCommand(licenseId, { ...dto });
return { message: 'Deployment started' };
}
/**
* Install Oxide/uMod via companion agent
*/
async installOxide(licenseId: string) {
await this.natsService.sendOxideInstallCommand(licenseId);
return { message: 'Oxide installation started' };
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTeleportConfigDto {
@ApiProperty({ example: 'Default Config' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard NTeleportation settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportTeleportConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateTeleportConfigDto {
@ApiPropertyOptional({ example: 'Updated Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TeleportService } from './teleport.service';
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
import { ImportTeleportConfigDto } from './dto/import-teleport-config.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('teleport')
@ApiBearerAuth()
@Controller('teleport')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class TeleportController {
constructor(private readonly teleportService: TeleportService) {}
@Get('configs')
@RequirePermission('teleport.view')
@ApiOperation({ summary: 'List teleport configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.teleportService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('teleport.view')
@ApiOperation({ summary: 'Get full teleport config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.teleportService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Create teleport config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateTeleportConfigDto) {
return this.teleportService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Update teleport config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateTeleportConfigDto,
) {
return this.teleportService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Delete teleport config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.teleportService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Deploy teleport config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.teleportService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Import NTeleportation.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportTeleportConfigDto) {
return this.teleportService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TeleportController } from './teleport.controller';
import { TeleportService } from './teleport.service';
import { TeleportConfig } from '../../entities/teleport-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([TeleportConfig])],
controllers: [TeleportController],
providers: [TeleportService, NatsService],
exports: [TeleportService],
})
export class TeleportModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TeleportConfig } from '../../entities/teleport-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
@Injectable()
export class TeleportService {
private readonly logger = new Logger(TeleportService.name);
constructor(
@InjectRepository(TeleportConfig)
private readonly teleportRepo: Repository<TeleportConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.teleportRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.teleportRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Teleport config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateTeleportConfigDto) {
const config = this.teleportRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.teleportRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateTeleportConfigDto) {
const config = await this.teleportRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Teleport config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.teleportRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.teleportRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('Teleport config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.teleportRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Teleport config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write NTeleportation.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/NTeleportation.json',
content: jsonString,
},
30000,
);
// Reload NTeleportation plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload NTeleportation',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.teleportRepo.update({ license_id: licenseId }, { is_active: false });
await this.teleportRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy teleport config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy teleport config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import NTeleportation.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read NTeleportation.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/NTeleportation.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new teleport config row
const config = this.teleportRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.teleportRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import teleport config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import teleport config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -39,6 +39,11 @@ export class NatsBridgeService implements OnModuleInit {
this.emit(licenseId, 'deploy_status', data);
});
this.nats.subscribe('corrosion.*.oxide.status', (data, subject) => {
const licenseId = subject.split('.')[1];
this.emit(licenseId, 'oxide_status', data);
});
this.logger.log('NATS bridge subscriptions initialized');
}

View File

@@ -79,4 +79,12 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
timestamp: new Date().toISOString(),
});
}
/** Publish an Oxide install command to a specific license's companion agent */
async sendOxideInstallCommand(licenseId: string): Promise<void> {
await this.publish(`corrosion.${licenseId}.cmd.oxide`, {
action: 'install_oxide',
timestamp: new Date().toISOString(),
});
}
}

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

@@ -0,0 +1,12 @@
-- Teleport configuration profiles for NTeleportation integration
CREATE TABLE teleport_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data 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_teleport_configs_license ON teleport_configs(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

@@ -11,8 +11,10 @@ import (
"github.com/nats-io/nats.go"
"github.com/vigilcyber/corrosion-companion/internal/deploy"
"github.com/vigilcyber/corrosion-companion/internal/filemanager"
"github.com/vigilcyber/corrosion-companion/internal/oxide"
"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 +27,8 @@ type DaemonConfig struct {
GameServerArgs string
Version string
InstallDir string
RconPort int
RconPassword string
}
// Daemon manages the companion agent's main operations
@@ -36,6 +40,7 @@ type Daemon struct {
fm *filemanager.FileManager
updater *update.Updater
deployer *deploy.Deployer
oxideInstaller *oxide.OxideInstaller
subscriptions []*nats.Subscription
}
@@ -53,6 +58,7 @@ type HeartbeatPayload struct {
OS string `json:"os"`
Arch string `json:"arch"`
ServerInstalled bool `json:"server_installed"`
OxideInstalled bool `json:"oxide_installed"`
}
// gameServerAdapter wraps process.GameServer to satisfy deploy.GameServerStarter
@@ -71,6 +77,15 @@ func (a *gameServerAdapter) UpdatePath(path string) {
*a.gs = *process.NewGameServer(path, a.cfg.GameServerArgs)
}
// restartAdapter wraps process.GameServer to satisfy oxide.GameServerRestarter
type restartAdapter struct {
gs *process.GameServer
}
func (a *restartAdapter) Restart() error {
return a.gs.Restart()
}
// NewDaemon creates a new daemon instance
func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
@@ -79,6 +94,8 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
updater := update.NewUpdater(cfg.Version)
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
restarter := &restartAdapter{gs: gameServer}
oxideInst := oxide.NewOxideInstaller(nc, cfg.LicenseID, cfg.InstallDir, restarter)
d := &Daemon{
nc: nc,
@@ -88,6 +105,7 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
fm: fm,
updater: updater,
deployer: deployer,
oxideInstaller: oxideInst,
}
return d, nil
@@ -122,6 +140,11 @@ func (d *Daemon) Run(ctx context.Context) error {
return fmt.Errorf("failed to subscribe to deploy commands: %w", err)
}
// Subscribe to Oxide install commands
if err := d.subscribeOxideInstall(); err != nil {
return fmt.Errorf("failed to subscribe to oxide install commands: %w", err)
}
// Subscribe to file manager commands (VueFinder-compatible request-reply)
if err := d.subscribeFileManager(); err != nil {
return fmt.Errorf("failed to subscribe to file manager commands: %w", err)
@@ -156,6 +179,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 +198,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)
}
@@ -367,6 +409,38 @@ func (d *Daemon) subscribeFileManager() error {
return nil
}
// subscribeOxideInstall subscribes to Oxide installation commands
func (d *Daemon) subscribeOxideInstall() error {
subject := fmt.Sprintf("corrosion.%s.cmd.oxide", d.cfg.LicenseID)
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
log.Println("Received Oxide install command")
// Run installation in goroutine (it's long-running)
go func() {
if err := d.oxideInstaller.Install(); err != nil {
log.Printf("Oxide installation failed: %v", err)
} else {
log.Println("Oxide installation completed successfully")
}
}()
// Immediately acknowledge the command
d.respondSuccess(msg, map[string]interface{}{
"status": "accepted",
"message": "Oxide installation started, progress will be published to oxide.status",
})
})
if err != nil {
return err
}
d.subscriptions = append(d.subscriptions, sub)
log.Printf("Subscribed to: %s", subject)
return nil
}
// handleFileOperation processes file operation requests
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
// Parse common fields
@@ -437,6 +511,7 @@ func (d *Daemon) publishHeartbeat() {
OS: runtime.GOOS,
Arch: runtime.GOARCH,
ServerInstalled: deploy.CheckServerInstalled(d.cfg.InstallDir),
OxideInstalled: oxide.CheckOxideInstalled(d.cfg.InstallDir),
}
data, err := json.Marshal(payload)

View File

@@ -0,0 +1,250 @@
package oxide
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/nats-io/nats.go"
)
// GameServerRestarter abstracts the game server process manager so the installer
// can restart the server after extracting Oxide files.
type GameServerRestarter interface {
Restart() error
}
// OxideInstaller handles downloading and extracting Oxide/uMod over a Rust server installation.
type OxideInstaller struct {
nc *nats.Conn
licenseID string
installDir string
gameServer GameServerRestarter
}
// NewOxideInstaller creates a new OxideInstaller instance.
func NewOxideInstaller(nc *nats.Conn, licenseID, installDir string, gs GameServerRestarter) *OxideInstaller {
return &OxideInstaller{
nc: nc,
licenseID: licenseID,
installDir: installDir,
gameServer: gs,
}
}
// githubRelease represents the relevant fields from the GitHub Releases API response.
type githubRelease struct {
TagName string `json:"tag_name"`
Assets []githubAsset `json:"assets"`
}
type githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// Install performs the full Oxide installation pipeline:
// 1. Fetch latest release info from GitHub
// 2. Download the zip
// 3. Extract over {installDir}/server/
// 4. Restart the game server
func (o *OxideInstaller) Install() error {
// Stage 1: Fetch latest release
log.Printf("Oxide: fetching latest release for license %s", o.licenseID)
o.publishStatus("fetching_release", 0, "Checking latest Oxide release...")
release, err := o.fetchLatestRelease()
if err != nil {
o.publishStatus("failed", 0, "Failed to fetch Oxide release info", err.Error())
return fmt.Errorf("fetch release failed: %w", err)
}
if len(release.Assets) == 0 {
err := fmt.Errorf("no assets found in release %s", release.TagName)
o.publishStatus("failed", 0, "No download assets in release", err.Error())
return err
}
downloadURL := release.Assets[0].BrowserDownloadURL
version := release.TagName
log.Printf("Oxide: latest version is %s, download URL: %s", version, downloadURL)
o.publishStatus("fetching_release", 100, fmt.Sprintf("Found Oxide %s", version))
// Stage 2: Download zip
log.Printf("Oxide: downloading %s", downloadURL)
o.publishStatus("downloading", 0, fmt.Sprintf("Downloading Oxide %s...", version))
tmpPath := filepath.Join(os.TempDir(), "oxide-latest.zip")
if err := o.downloadFile(downloadURL, tmpPath); err != nil {
o.publishStatus("failed", 0, "Failed to download Oxide", err.Error())
return fmt.Errorf("download failed: %w", err)
}
defer os.Remove(tmpPath)
log.Printf("Oxide: download complete")
o.publishStatus("downloading", 100, "Download complete")
// Stage 3: Extract over server directory
serverDir := filepath.Join(o.installDir, "server")
log.Printf("Oxide: extracting to %s", serverDir)
o.publishStatus("installing", 0, "Extracting Oxide over server directory...")
if err := o.extractZip(tmpPath, serverDir); err != nil {
o.publishStatus("failed", 0, "Failed to extract Oxide", err.Error())
return fmt.Errorf("extract failed: %w", err)
}
log.Printf("Oxide: extraction complete")
o.publishStatus("installing", 100, "Oxide files extracted")
// Stage 4: Restart server
log.Printf("Oxide: restarting server")
o.publishStatus("restarting", 0, "Restarting server to load Oxide...")
if err := o.gameServer.Restart(); err != nil {
o.publishStatus("failed", 0, "Server restart failed", err.Error())
return fmt.Errorf("server restart failed: %w", err)
}
log.Printf("Oxide: server restarted, installation complete")
o.publishStatus("complete", 100, fmt.Sprintf("Oxide %s installed successfully", version))
return nil
}
// fetchLatestRelease queries the GitHub API for the latest Oxide.Rust release.
func (o *OxideInstaller) fetchLatestRelease() (*githubRelease, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get("https://api.github.com/repos/OxideMod/Oxide.Rust/releases/latest")
if err != nil {
return nil, fmt.Errorf("GitHub API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("failed to parse GitHub API response: %w", err)
}
return &release, nil
}
// downloadFile downloads a URL to a local file path.
func (o *OxideInstaller) downloadFile(url, destPath string) error {
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("HTTP GET failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned status %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", destPath, err)
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("failed to write download: %w", err)
}
return nil
}
// extractZip extracts a zip file to a destination directory, overwriting existing files.
// This is used to overlay Oxide's DLLs over the Rust server's Managed directory
// and create the oxide/ folder structure.
func (o *OxideInstaller) extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("failed to open zip: %w", err)
}
defer r.Close()
for _, f := range r.File {
targetPath := filepath.Join(destDir, f.Name)
// Security: prevent path traversal
if !strings.HasPrefix(targetPath, filepath.Clean(destDir)+string(os.PathSeparator)) && targetPath != filepath.Clean(destDir) {
log.Printf("Oxide: skipping potentially unsafe path: %s", f.Name)
continue
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(targetPath, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", targetPath, err)
}
continue
}
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return fmt.Errorf("failed to create parent directory for %s: %w", targetPath, err)
}
outFile, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return fmt.Errorf("failed to create file %s: %w", targetPath, err)
}
rc, err := f.Open()
if err != nil {
outFile.Close()
return fmt.Errorf("failed to open zip entry %s: %w", f.Name, err)
}
_, err = io.Copy(outFile, rc)
rc.Close()
outFile.Close()
if err != nil {
return fmt.Errorf("failed to extract %s: %w", f.Name, err)
}
}
return nil
}
// publishStatus publishes an OxideStatus message to NATS. Publish errors are logged
// but do not fail the installation — losing a progress update is not fatal.
func (o *OxideInstaller) publishStatus(stage string, progress int, message string, errDetail ...string) {
subject := fmt.Sprintf("corrosion.%s.oxide.status", o.licenseID)
status := OxideStatus{
Stage: stage,
Progress: progress,
Message: message,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if len(errDetail) > 0 && errDetail[0] != "" {
status.Error = errDetail[0]
}
data, err := json.Marshal(status)
if err != nil {
log.Printf("Failed to marshal oxide status: %v", err)
return
}
if err := o.nc.Publish(subject, data); err != nil {
log.Printf("Failed to publish oxide status to %s: %v", subject, err)
}
}

View File

@@ -0,0 +1,31 @@
package oxide
import (
"os"
"path/filepath"
)
// OxideStatus represents a progress update published to NATS during Oxide installation.
// The frontend listens on corrosion.{license_id}.oxide.status for these messages.
type OxideStatus struct {
Stage string `json:"stage"`
Progress int `json:"progress"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
Timestamp string `json:"timestamp"`
}
// Valid installation stages:
// fetching_release - Querying GitHub API for latest Oxide.Rust release
// downloading - Downloading the Oxide zip file
// installing - Extracting zip over server directory
// restarting - Restarting the game server to load Oxide
// complete - Oxide installation finished successfully
// failed - Installation failed at some stage
// CheckOxideInstalled returns true if the oxide/ directory exists in the
// server installation directory, indicating that Oxide/uMod has been installed.
func CheckOxideInstalled(installDir string) bool {
_, err := os.Stat(filepath.Join(installDir, "server", "oxide"))
return err == nil
}

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,8 @@ import {
AlertTriangle,
FileText,
FolderOpen,
Crosshair,
Navigation2,
Menu,
X,
} from 'lucide-vue-next'
@@ -44,6 +46,8 @@ 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: 'Teleport Config', path: '/teleport-config', icon: Navigation2, permission: 'teleport.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 +109,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 +207,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,195 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
const props = defineProps<{
configData: Record<string, any>
}>()
const emit = defineEmits<{
'update:configData': [configData: Record<string, any>]
}>()
const newGroupName = ref('')
// Merge all VIP maps by key name to compute the unified group list
const groups = computed(() => {
const homesLimits: Record<string, number> = props.configData?.Home?.VIPHomesLimits || {}
const cooldowns: Record<string, number> = props.configData?.TPR?.VIPCooldowns || {}
const countdowns: Record<string, number> = props.configData?.TPR?.VIPCountdowns || {}
const dailyLimits: Record<string, number> = props.configData?.TPR?.VIPDailyLimits || {}
const allKeys = new Set([
...Object.keys(homesLimits),
...Object.keys(cooldowns),
...Object.keys(countdowns),
...Object.keys(dailyLimits),
])
return Array.from(allKeys).map(name => ({
name,
homesLimit: homesLimits[name] ?? 5,
cooldown: cooldowns[name] ?? 300,
countdown: countdowns[name] ?? 5,
dailyLimit: dailyLimits[name] ?? 10,
}))
})
function ensurePaths(data: Record<string, any>) {
if (!data.Home) data.Home = {}
if (!data.Home.VIPHomesLimits) data.Home.VIPHomesLimits = {}
if (!data.TPR) data.TPR = {}
if (!data.TPR.VIPCooldowns) data.TPR.VIPCooldowns = {}
if (!data.TPR.VIPCountdowns) data.TPR.VIPCountdowns = {}
if (!data.TPR.VIPDailyLimits) data.TPR.VIPDailyLimits = {}
}
function addGroup() {
const name = newGroupName.value.trim()
if (!name) return
// Check if already exists
if (groups.value.some(g => g.name === name)) return
const updated = { ...props.configData }
ensurePaths(updated)
updated.Home.VIPHomesLimits[name] = 5
updated.TPR.VIPCooldowns[name] = 300
updated.TPR.VIPCountdowns[name] = 5
updated.TPR.VIPDailyLimits[name] = 10
emit('update:configData', updated)
newGroupName.value = ''
}
function removeGroup(name: string) {
if (!confirm(`Remove VIP group "${name}"?`)) return
const updated = { ...props.configData }
ensurePaths(updated)
delete updated.Home.VIPHomesLimits[name]
delete updated.TPR.VIPCooldowns[name]
delete updated.TPR.VIPCountdowns[name]
delete updated.TPR.VIPDailyLimits[name]
emit('update:configData', updated)
}
function updateField(groupName: string, field: string, value: number) {
const updated = { ...props.configData }
ensurePaths(updated)
switch (field) {
case 'homesLimit':
updated.Home.VIPHomesLimits[groupName] = value
break
case 'cooldown':
updated.TPR.VIPCooldowns[groupName] = value
break
case 'countdown':
updated.TPR.VIPCountdowns[groupName] = value
break
case 'dailyLimit':
updated.TPR.VIPDailyLimits[groupName] = value
break
}
emit('update:configData', updated)
}
</script>
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">VIP Permission Groups</h3>
</div>
<!-- Add Group -->
<div class="flex gap-2">
<input
v-model="newGroupName"
placeholder="New group name (e.g. vip, vip+, mvp)..."
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>
<!-- Empty State -->
<div v-if="groups.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
No VIP groups defined. Add groups to configure per-permission teleport limits, cooldowns, and countdowns.
</div>
<!-- Groups Table -->
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-neutral-800">
<th class="text-left py-3 px-4 text-neutral-500 font-medium">Group Name</th>
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Homes Limit</th>
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Cooldown (s)</th>
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Countdown (s)</th>
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Daily Limit</th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
<tr
v-for="group in groups"
:key="group.name"
class="border-b border-neutral-800/50"
>
<td class="py-3 px-4 text-neutral-200 font-medium">{{ group.name }}</td>
<td class="py-3 px-4">
<input
type="number"
:value="group.homesLimit"
@input="updateField(group.name, 'homesLimit', 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-3 px-4">
<input
type="number"
:value="group.cooldown"
@input="updateField(group.name, 'cooldown', 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-3 px-4">
<input
type="number"
:value="group.countdown"
@input="updateField(group.name, 'countdown', 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-3 px-4">
<input
type="number"
:value="group.dailyLimit"
@input="updateField(group.name, 'dailyLimit', 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-3 px-4">
<button
@click="removeGroup(group.name)"
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
>
<Trash2 class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
const props = defineProps<{
warps: Record<string, { x: number; y: number; z: number }>
}>()
const emit = defineEmits<{
'update:warps': [warps: Record<string, { x: number; y: number; z: number }>]
}>()
const newWarpName = ref('')
function addWarp() {
const name = newWarpName.value.trim()
if (!name || props.warps[name]) return
const updated = { ...props.warps, [name]: { x: 0, y: 0, z: 0 } }
emit('update:warps', updated)
newWarpName.value = ''
}
function removeWarp(name: string) {
const updated = { ...props.warps }
delete updated[name]
emit('update:warps', updated)
}
</script>
<template>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Warps</h3>
<!-- Add Warp -->
<div class="flex gap-2">
<input
v-model="newWarpName"
placeholder="Warp name..."
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
@keydown.enter="addWarp"
/>
<button
@click="addWarp"
:disabled="!newWarpName.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
</button>
</div>
<!-- Warp List -->
<div v-if="Object.keys(warps).length === 0" class="text-neutral-500 text-sm text-center py-4">
No warps defined. Add warps here and set coordinates in-game.
</div>
<div
v-for="(coords, name) in warps"
:key="name"
class="flex items-center justify-between bg-neutral-800/50 border border-neutral-700/50 rounded-lg px-4 py-3"
>
<div>
<span class="text-neutral-200 font-medium">{{ name }}</span>
<span class="text-neutral-500 text-xs ml-3">
{{ coords.x.toFixed(1) }}, {{ coords.y.toFixed(1) }}, {{ coords.z.toFixed(1) }}
</span>
</div>
<button
@click="removeWarp(name as string)"
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
>
<Trash2 class="w-4 h-4" />
</button>
</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,16 @@ const panelRoutes: RouteRecordRaw[] = [
name: 'files',
component: () => import('@/views/admin/FileManagerView.vue'),
},
{
path: 'loot-builder',
name: 'loot-builder',
component: () => import('@/views/admin/LootBuilderView.vue'),
},
{
path: 'teleport-config',
name: 'teleport-config',
component: () => import('@/views/admin/TeleportConfigView.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

@@ -64,6 +64,15 @@ export const useServerStore = defineStore('server', () => {
}
}
async function installOxide() {
try {
await api.post('/servers/install-oxide')
} catch (e) {
console.error('Failed to start Oxide installation:', e)
throw e
}
}
function updateDeploymentStatus(status: DeploymentStatus) {
deploymentStatus.value = status
if (status.stage === 'online' || status.stage === 'failed') {
@@ -94,6 +103,7 @@ export const useServerStore = defineStore('server', () => {
stopServer,
restartServer,
deployServer,
installOxide,
updateDeploymentStatus,
clearDeploymentStatus,
updateStats,

View File

@@ -0,0 +1,145 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import type { TeleportConfigSummary, TeleportConfigFull, TeleportApplyResult } from '@/types'
export const useTeleportStore = defineStore('teleport', () => {
const configs = ref<TeleportConfigSummary[]>([])
const currentConfig = ref<TeleportConfigFull | null>(null)
const isLoading = ref(false)
const isSaving = ref(false)
const isApplying = ref(false)
const isDirty = ref(false)
const api = useApi()
const toast = useToastStore()
async function fetchConfigs() {
isLoading.value = true
try {
const res = await api.get<{ configs: TeleportConfigSummary[] }>('/teleport/configs')
configs.value = res.configs
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function loadConfig(id: string) {
isLoading.value = true
try {
const res = await api.get<{ config: TeleportConfigFull }>(`/teleport/configs/${id}`)
currentConfig.value = res.config
isDirty.value = false
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function createConfig(name: string, description?: string) {
try {
const res = await api.post<{ config: TeleportConfigFull }>('/teleport/configs', {
config_name: name,
description,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config "${name}" created`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function saveCurrentConfig() {
if (!currentConfig.value) return
isSaving.value = true
try {
await api.put(`/teleport/configs/${currentConfig.value.id}`, {
config_name: currentConfig.value.config_name,
description: currentConfig.value.description,
config_data: currentConfig.value.config_data,
})
isDirty.value = false
await fetchConfigs()
toast.success('Config saved')
} catch (err) {
toast.error((err as Error).message)
} finally {
isSaving.value = false
}
}
async function deleteConfig(id: string) {
try {
await api.del(`/teleport/configs/${id}`)
if (currentConfig.value?.id === id) {
currentConfig.value = null
}
await fetchConfigs()
toast.success('Config deleted')
} catch (err) {
toast.error((err as Error).message)
}
}
async function applyToServer(id: string) {
isApplying.value = true
try {
const res = await api.post<TeleportApplyResult>(`/teleport/configs/${id}/apply`)
await fetchConfigs()
toast.success(res.message)
return res
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isApplying.value = false
}
}
async function importFromServer(configName: string) {
isLoading.value = true
try {
const res = await api.post<{ config: TeleportConfigFull }>('/teleport/import-from-server', {
config_name: configName,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config imported from server as "${configName}"`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isLoading.value = false
}
}
function markDirty() {
isDirty.value = true
}
return {
configs,
currentConfig,
isLoading,
isSaving,
isApplying,
isDirty,
fetchConfigs,
loadConfig,
createConfig,
saveCurrentConfig,
deleteConfig,
applyToServer,
importFromServer,
markDirty,
}
})

View File

@@ -442,3 +442,107 @@ 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
}
// Teleport Config types — NTeleportation integration
export interface TeleportConfigSummary {
id: string
config_name: string
description: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface TeleportConfigFull {
id: string
license_id: string
config_name: string
description: string | null
config_data: Record<string, any>
is_active: boolean
created_at: string
updated_at: string
}
export interface TeleportApplyResult {
success: boolean
message: string
config_name: string
}

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>

View File

@@ -18,6 +18,7 @@ import {
Rocket,
AlertTriangle,
Check,
Puzzle,
} from 'lucide-vue-next'
import type { DeploymentConfig, DeploymentStatus } from '@/types'
import { useWebSocket } from '@/composables/useWebSocket'
@@ -34,6 +35,8 @@ const setupTab = ref<'linux' | 'windows'>('linux')
const windowsCopied = ref(false)
const showDeployForm = ref(false)
const deployLoading = ref(false)
const oxideStatus = ref<{ stage: string; progress: number; message: string; error?: string } | null>(null)
const isInstallingOxide = ref(false)
const deployForm = ref<DeploymentConfig>({
server_name: 'My Rust Server',
@@ -141,6 +144,42 @@ function getStageState(stageKey: string): 'pending' | 'active' | 'complete' | 'f
return 'pending'
}
const oxideStages = [
{ key: 'fetching_release', label: 'Check Latest Release' },
{ key: 'downloading', label: 'Download Oxide' },
{ key: 'installing', label: 'Extract Files' },
{ key: 'restarting', label: 'Restart Server' },
{ key: 'complete', label: 'Complete' },
] as const
function getOxideStageState(stageKey: string): 'pending' | 'active' | 'complete' | 'failed' {
if (!oxideStatus.value) return 'pending'
const status = oxideStatus.value
if (status.stage === 'failed') {
const currentStages = oxideStages
const idx = currentStages.findIndex(s => s.key === stageKey)
// Find which stage was active when failure occurred — approximate from message
// For failed state, mark all stages before current as complete
return idx === 0 ? 'failed' : 'pending'
}
const currentIdx = oxideStages.findIndex(s => s.key === status.stage)
const thisIdx = oxideStages.findIndex(s => s.key === stageKey)
if (thisIdx < currentIdx) return 'complete'
if (thisIdx === currentIdx) return status.stage === 'complete' ? 'complete' : 'active'
return 'pending'
}
async function installOxide() {
isInstallingOxide.value = true
oxideStatus.value = null
try {
await server.installOxide()
} catch {
toast.error('Failed to start Oxide installation')
isInstallingOxide.value = false
}
}
const form = ref({
server_name: '',
max_players: 0,
@@ -207,6 +246,12 @@ onMounted(async () => {
if (msg.type === 'event' && msg.event === 'deploy_status') {
server.updateDeploymentStatus(msg.data as DeploymentStatus)
}
if (msg.type === 'event' && msg.event === 'oxide_status') {
oxideStatus.value = msg.data as { stage: string; progress: number; message: string; error?: string }
if (msg.data && (msg.data as any).stage === 'complete' || (msg.data as any).stage === 'failed') {
isInstallingOxide.value = false
}
}
})
})
</script>
@@ -544,6 +589,82 @@ onMounted(async () => {
</div>
</div>
<!-- Install Oxide/uMod -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-5">
<Puzzle class="w-4 h-4 text-oxide-400" />
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Install Oxide / uMod</h2>
</div>
<!-- Installation Progress Tracker -->
<div v-if="oxideStatus || isInstallingOxide" class="mb-6">
<div class="space-y-3">
<div
v-for="stage in oxideStages"
:key="stage.key"
class="flex items-center gap-3"
>
<!-- Stage indicator -->
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
:class="{
'bg-neutral-800 text-neutral-600': getOxideStageState(stage.key) === 'pending',
'bg-amber-500/20 text-amber-400': getOxideStageState(stage.key) === 'active',
'bg-green-500/20 text-green-400': getOxideStageState(stage.key) === 'complete',
'bg-red-500/20 text-red-400': getOxideStageState(stage.key) === 'failed',
}"
>
<Loader2 v-if="getOxideStageState(stage.key) === 'active'" class="w-3.5 h-3.5 animate-spin" />
<Check v-else-if="getOxideStageState(stage.key) === 'complete'" class="w-3.5 h-3.5" />
<AlertTriangle v-else-if="getOxideStageState(stage.key) === 'failed'" class="w-3.5 h-3.5" />
<span v-else class="w-1.5 h-1.5 rounded-full bg-neutral-600" />
</div>
<!-- Stage label -->
<span
class="text-sm"
:class="{
'text-neutral-600': getOxideStageState(stage.key) === 'pending',
'text-amber-300 font-medium': getOxideStageState(stage.key) === 'active',
'text-green-400': getOxideStageState(stage.key) === 'complete',
'text-red-400': getOxideStageState(stage.key) === 'failed',
}"
>{{ stage.label }}</span>
</div>
</div>
<!-- Status message -->
<div v-if="oxideStatus?.message" class="mt-4 px-3 py-2 bg-neutral-800/50 rounded-lg">
<p class="text-xs text-neutral-400">{{ oxideStatus.message }}</p>
</div>
<!-- Error display -->
<div v-if="oxideStatus?.error" class="mt-3 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg">
<p class="text-xs text-red-400">{{ oxideStatus.error }}</p>
</div>
<!-- Retry button on failure -->
<button
v-if="oxideStatus?.stage === 'failed'"
@click="oxideStatus = null; installOxide()"
class="mt-3 flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<RotateCcw class="w-4 h-4" />
Retry Installation
</button>
</div>
<!-- Install Button (shown when not installing) -->
<div v-else class="text-center py-4">
<p class="text-sm text-neutral-400 mb-4">Install or update Oxide/uMod. Required for all plugins including CorrosionCompanion.</p>
<button
@click="installOxide()"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<Puzzle class="w-4 h-4" />
Install Oxide
</button>
</div>
</div>
<!-- Configuration -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center justify-between mb-4">

View File

@@ -0,0 +1,694 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useTeleportStore } from '@/stores/teleport'
import { useToastStore } from '@/stores/toast'
import PermissionGroupEditor from '@/components/teleport/PermissionGroupEditor.vue'
import {
Save,
Play,
Download,
Plus,
Trash2,
Navigation2,
Home,
Users,
Settings as SettingsIcon,
Loader2,
} from 'lucide-vue-next'
const store = useTeleportStore()
const toast = useToastStore()
const activeTab = ref<'general' | 'homes' | 'tpr' | 'vip'>('general')
const showCreateModal = ref(false)
const showImportModal = ref(false)
const newConfigName = ref('')
const newConfigDesc = ref('')
const importConfigName = ref('')
const tabs = [
{ key: 'general', label: 'General', icon: SettingsIcon },
{ key: 'homes', label: 'Homes', icon: Home },
{ key: 'tpr', label: 'TPR', icon: Navigation2 },
{ key: 'vip', label: 'VIP Groups', icon: Users },
]
onMounted(async () => {
await store.fetchConfigs()
if (store.configs.length > 0 && store.configs[0]) {
await store.loadConfig(store.configs[0].id)
}
})
// --- Config data helpers ---
function getConfigValue(path: string, defaultValue: any = false): any {
if (!store.currentConfig?.config_data) return defaultValue
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (const part of parts) {
if (current == null || typeof current !== 'object') return defaultValue
current = current[part]
}
return current ?? defaultValue
}
function setConfigValue(path: string, value: any) {
if (!store.currentConfig) return
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!
if (current[part] == null || typeof current[part] !== 'object') {
current[part] = {}
}
current = current[part]
}
current[parts[parts.length - 1]!] = value
store.markDirty()
}
// --- Action handlers ---
async function handleConfigChange(id: string) {
if (store.isDirty) {
if (!confirm('Unsaved changes will be lost. Continue?')) return
}
await store.loadConfig(id)
}
async function handleCreateConfig() {
if (!newConfigName.value.trim()) return
const config = await store.createConfig(
newConfigName.value.trim(),
newConfigDesc.value.trim() || undefined,
)
if (config) {
showCreateModal.value = false
newConfigName.value = ''
newConfigDesc.value = ''
}
}
async function handleDeleteConfig() {
if (!store.currentConfig) return
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
await store.deleteConfig(store.currentConfig.id)
}
async function handleApply() {
if (!store.currentConfig) return
if (!confirm('Apply this teleport config to the server? This will overwrite the current NTeleportation config.')) return
if (store.isDirty) {
await store.saveCurrentConfig()
}
await store.applyToServer(store.currentConfig.id)
}
async function handleImport() {
if (!importConfigName.value.trim()) return
const config = await store.importFromServer(importConfigName.value.trim())
if (config) {
showImportModal.value = false
importConfigName.value = ''
}
}
function handlePermissionGroupUpdate(updatedData: Record<string, any>) {
if (!store.currentConfig) return
store.currentConfig.config_data = updatedData
store.markDirty()
}
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">Teleport Config</h1>
<div class="flex items-center gap-3">
<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 Config
</button>
</div>
</div>
<!-- Config Selector + Action Bar -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div class="flex items-center gap-3 flex-wrap">
<!-- Config Selector -->
<select
v-if="store.configs.length > 0"
:value="store.currentConfig?.id || ''"
@change="handleConfigChange(($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="c in store.configs" :key="c.id" :value="c.id">
{{ c.config_name }}
<template v-if="c.is_active"> (Active)</template>
</option>
</select>
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
<!-- Save -->
<button
@click="store.saveCurrentConfig()"
:disabled="!store.currentConfig || !store.isDirty || store.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" />
{{ store.isSaving ? 'Saving...' : 'Save' }}
</button>
<!-- Apply to Server -->
<button
@click="handleApply"
:disabled="!store.currentConfig || store.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" />
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
</button>
<!-- Import from Server -->
<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"
>
<Download class="w-4 h-4" />
Import from Server
</button>
<!-- Delete -->
<button
@click="handleDeleteConfig"
:disabled="!store.currentConfig"
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>
<!-- Loading State -->
<div v-if="store.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>
<!-- No Config Selected -->
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
<Navigation2 class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Teleport Config Selected</h2>
<p class="text-neutral-500 mb-4">Create a new config, import from server, 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 Config
</button>
</div>
<!-- Config Editor -->
<div v-else class="space-y-6">
<!-- Tab Bar -->
<div class="flex border-b border-neutral-800">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key as typeof activeTab"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === tab.key
? 'border-oxide-500 text-oxide-400'
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
>
<component :is="tab.icon" class="w-4 h-4" />
{{ tab.label }}
</button>
</div>
<!-- General Tab -->
<div v-if="activeTab === 'general'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">General Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- UseEconomics -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Use Economics</label>
<p class="text-xs text-neutral-500">Charge players for teleports via Economics plugin</p>
</div>
<button
@click="setConfigValue('Settings.UseEconomics', !getConfigValue('Settings.UseEconomics', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Settings.UseEconomics', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Settings.UseEconomics', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- UseServerRewards -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Use Server Rewards</label>
<p class="text-xs text-neutral-500">Charge players via ServerRewards plugin</p>
</div>
<button
@click="setConfigValue('Settings.UseServerRewards', !getConfigValue('Settings.UseServerRewards', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Settings.UseServerRewards', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Settings.UseServerRewards', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- CheckBoundaries -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Cave/Water boundary checks</label>
<p class="text-xs text-neutral-500">Prevent teleporting into caves or underwater</p>
</div>
<button
@click="setConfigValue('Settings.CheckBoundaries', !getConfigValue('Settings.CheckBoundaries', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Settings.CheckBoundaries', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Settings.CheckBoundaries', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- InterruptTPOnHostile -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Cancel TP if hostile timer</label>
<p class="text-xs text-neutral-500">Cancel pending teleport if player becomes hostile</p>
</div>
<button
@click="setConfigValue('Settings.InterruptTPOnHostile', !getConfigValue('Settings.InterruptTPOnHostile', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Settings.InterruptTPOnHostile', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Settings.InterruptTPOnHostile', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- WipeHomesOnUpgrade -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Wipe homes on map update</label>
<p class="text-xs text-neutral-500">Clear all home locations when the map changes</p>
</div>
<button
@click="setConfigValue('Settings.WipeHomesOnUpgrade', !getConfigValue('Settings.WipeHomesOnUpgrade', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Settings.WipeHomesOnUpgrade', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Settings.WipeHomesOnUpgrade', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- PlayersOnlyCannotTeleport -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Players Only Cannot Teleport</label>
<p class="text-xs text-neutral-500">Restrict teleport to specific player groups only</p>
</div>
<button
@click="setConfigValue('Settings.PlayersOnlyCannotTeleport', !getConfigValue('Settings.PlayersOnlyCannotTeleport', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Settings.PlayersOnlyCannotTeleport', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Settings.PlayersOnlyCannotTeleport', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
<!-- Global Cooldown (number) -->
<div class="max-w-sm">
<label class="block text-sm text-neutral-200 mb-1">Global cooldown (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">Minimum time between any teleport commands</p>
<input
type="number"
:value="getConfigValue('Settings.GlobalTeleportCooldown', 0)"
@input="setConfigValue('Settings.GlobalTeleportCooldown', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
</div>
<!-- Homes Tab -->
<div v-else-if="activeTab === 'homes'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Home Teleport Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- UsableOutOfBuildingBlocked -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Can use outside building privilege</label>
<p class="text-xs text-neutral-500">Allow home teleport even without building privilege</p>
</div>
<button
@click="setConfigValue('Home.UsableOutOfBuildingBlocked', !getConfigValue('Home.UsableOutOfBuildingBlocked', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Home.UsableOutOfBuildingBlocked', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Home.UsableOutOfBuildingBlocked', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- ForceOnTopOfFoundation -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Force home on foundation</label>
<p class="text-xs text-neutral-500">Homes can only be set on a foundation block</p>
</div>
<button
@click="setConfigValue('Home.ForceOnTopOfFoundation', !getConfigValue('Home.ForceOnTopOfFoundation', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Home.ForceOnTopOfFoundation', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Home.ForceOnTopOfFoundation', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- CheckFoundationForOwner -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Verify foundation ownership</label>
<p class="text-xs text-neutral-500">Only allow homes on foundations the player owns</p>
</div>
<button
@click="setConfigValue('Home.CheckFoundationForOwner', !getConfigValue('Home.CheckFoundationForOwner', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Home.CheckFoundationForOwner', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Home.CheckFoundationForOwner', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- AllowAboveFoundation -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Allow Above Foundation</label>
<p class="text-xs text-neutral-500">Allow setting homes above foundation level</p>
</div>
<button
@click="setConfigValue('Home.AllowAboveFoundation', !getConfigValue('Home.AllowAboveFoundation', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Home.AllowAboveFoundation', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Home.AllowAboveFoundation', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- CupOwnerAllowOnBuildingBlocked -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Cupboard Owner Allow on Building Blocked</label>
<p class="text-xs text-neutral-500">Allow TC owners to teleport even when building blocked</p>
</div>
<button
@click="setConfigValue('Home.CupOwnerAllowOnBuildingBlocked', !getConfigValue('Home.CupOwnerAllowOnBuildingBlocked', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Home.CupOwnerAllowOnBuildingBlocked', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Home.CupOwnerAllowOnBuildingBlocked', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
<!-- Number Inputs -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label class="block text-sm text-neutral-200 mb-1">Homes Limit</label>
<p class="text-xs text-neutral-500 mb-2">Default max homes per player</p>
<input
type="number"
:value="getConfigValue('Home.HomesLimit', 3)"
@input="setConfigValue('Home.HomesLimit', Number(($event.target as HTMLInputElement).value))"
min="0"
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-200 mb-1">Daily Limit</label>
<p class="text-xs text-neutral-500 mb-2">Max home teleports per day</p>
<input
type="number"
:value="getConfigValue('Home.DefaultDailyLimit', 5)"
@input="setConfigValue('Home.DefaultDailyLimit', Number(($event.target as HTMLInputElement).value))"
min="0"
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-200 mb-1">Cooldown (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">Time between home teleports</p>
<input
type="number"
:value="getConfigValue('Home.DefaultCooldown', 600)"
@input="setConfigValue('Home.DefaultCooldown', Number(($event.target as HTMLInputElement).value))"
min="0"
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-200 mb-1">Countdown (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">Countdown before teleport</p>
<input
type="number"
:value="getConfigValue('Home.DefaultCountdown', 5)"
@input="setConfigValue('Home.DefaultCountdown', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
</div>
</div>
<!-- TPR Tab -->
<div v-else-if="activeTab === 'tpr'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Teleport Request Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- BlockTPAOnCeiling -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Block TP accept on ceiling</label>
<p class="text-xs text-neutral-500">Prevent accepting a TP while on a ceiling tile</p>
</div>
<button
@click="setConfigValue('TPR.BlockTPAOnCeiling', !getConfigValue('TPR.BlockTPAOnCeiling', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('TPR.BlockTPAOnCeiling', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('TPR.BlockTPAOnCeiling', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- OffsetTPRTarget -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Offset teleport target position</label>
<p class="text-xs text-neutral-500">Slightly offset the teleport landing position</p>
</div>
<button
@click="setConfigValue('TPR.OffsetTPRTarget', !getConfigValue('TPR.OffsetTPRTarget', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('TPR.OffsetTPRTarget', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('TPR.OffsetTPRTarget', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- AutoAcceptEnabled -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Auto Accept Enabled</label>
<p class="text-xs text-neutral-500">Automatically accept incoming TP requests</p>
</div>
<button
@click="setConfigValue('TPR.AutoAcceptEnabled', !getConfigValue('TPR.AutoAcceptEnabled', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('TPR.AutoAcceptEnabled', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('TPR.AutoAcceptEnabled', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
<!-- Number Inputs -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label class="block text-sm text-neutral-200 mb-1">Cooldown (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">Cooldown between TPR requests</p>
<input
type="number"
:value="getConfigValue('TPR.Cooldown', 600)"
@input="setConfigValue('TPR.Cooldown', Number(($event.target as HTMLInputElement).value))"
min="0"
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-200 mb-1">Countdown (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">Countdown before teleport</p>
<input
type="number"
:value="getConfigValue('TPR.Countdown', 5)"
@input="setConfigValue('TPR.Countdown', Number(($event.target as HTMLInputElement).value))"
min="0"
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-200 mb-1">Daily Limit</label>
<p class="text-xs text-neutral-500 mb-2">Max TPR per day</p>
<input
type="number"
:value="getConfigValue('TPR.DailyLimit', 5)"
@input="setConfigValue('TPR.DailyLimit', Number(($event.target as HTMLInputElement).value))"
min="0"
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-200 mb-1">Request Duration (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">How long a TPR request lasts</p>
<input
type="number"
:value="getConfigValue('TPR.RequestDuration', 30)"
@input="setConfigValue('TPR.RequestDuration', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
</div>
</div>
<!-- VIP Groups Tab -->
<div v-else-if="activeTab === 'vip'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6">
<PermissionGroupEditor
:config-data="store.currentConfig.config_data"
@update:config-data="handlePermissionGroupUpdate"
/>
</div>
</div>
<!-- Create Config 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 Teleport Config</h2>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="newConfigName"
placeholder="e.g. Default TP Settings"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleCreateConfig"
/>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
<textarea
v-model="newConfigDesc"
rows="2"
placeholder="What is this config 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="handleCreateConfig"
:disabled="!newConfigName.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 from Server 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-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
<p class="text-sm text-neutral-400 mb-4">
Import the current NTeleportation config from your live server. This will create a new config profile.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="importConfigName"
placeholder="e.g. Imported Server Config"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleImport"
/>
</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="!importConfigName.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>
</div>
</template>

File diff suppressed because it is too large Load Diff