Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6461417b50 | ||
|
|
380ab2700c | ||
|
|
585e8aa3f7 | ||
|
|
4d087132db | ||
|
|
16f378eada | ||
|
|
3e1af29b38 | ||
|
|
759bd0be2e | ||
|
|
9d28fdfb65 | ||
|
|
eb57c51a24 | ||
|
|
f67b175d39 | ||
|
|
7acdd3654f | ||
|
|
57efc6a5d2 | ||
|
|
854f56a178 |
@@ -35,6 +35,8 @@ import { SetupModule } from './modules/setup/setup.module';
|
|||||||
import { MigrationModule } from './modules/migration/migration.module';
|
import { MigrationModule } from './modules/migration/migration.module';
|
||||||
import { ChangelogModule } from './modules/changelog/changelog.module';
|
import { ChangelogModule } from './modules/changelog/changelog.module';
|
||||||
import { FilesModule } from './modules/files/files.module';
|
import { FilesModule } from './modules/files/files.module';
|
||||||
|
import { LootModule } from './modules/loot/loot.module';
|
||||||
|
import { TeleportModule } from './modules/teleport/teleport.module';
|
||||||
|
|
||||||
// Shared Services
|
// Shared Services
|
||||||
import { NatsService } from './services/nats.service';
|
import { NatsService } from './services/nats.service';
|
||||||
@@ -105,6 +107,8 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
MigrationModule,
|
MigrationModule,
|
||||||
ChangelogModule,
|
ChangelogModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
|
LootModule,
|
||||||
|
TeleportModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global guards (order matters: auth first, then license, then permissions)
|
// Global guards (order matters: auth first, then license, then permissions)
|
||||||
|
|||||||
36
backend-nest/src/entities/loot-profile.entity.ts
Normal file
36
backend-nest/src/entities/loot-profile.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
33
backend-nest/src/entities/teleport-config.entity.ts
Normal file
33
backend-nest/src/entities/teleport-config.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
39
backend-nest/src/modules/loot/data/rust-containers.ts
Normal file
39
backend-nest/src/modules/loot/data/rust-containers.ts
Normal 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' },
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
24
backend-nest/src/modules/loot/dto/create-loot-profile.dto.ts
Normal file
24
backend-nest/src/modules/loot/dto/create-loot-profile.dto.ts
Normal 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>;
|
||||||
|
}
|
||||||
23
backend-nest/src/modules/loot/dto/import-loot-profile.dto.ts
Normal file
23
backend-nest/src/modules/loot/dto/import-loot-profile.dto.ts
Normal 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>;
|
||||||
|
}
|
||||||
30
backend-nest/src/modules/loot/dto/update-loot-profile.dto.ts
Normal file
30
backend-nest/src/modules/loot/dto/update-loot-profile.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
112
backend-nest/src/modules/loot/loot.controller.ts
Normal file
112
backend-nest/src/modules/loot/loot.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/loot/loot.module.ts
Normal file
14
backend-nest/src/modules/loot/loot.module.ts
Normal 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 {}
|
||||||
258
backend-nest/src/modules/loot/loot.service.ts
Normal file
258
backend-nest/src/modules/loot/loot.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,4 +73,11 @@ export class ServersController {
|
|||||||
) {
|
) {
|
||||||
return await this.serversService.deployServer(licenseId, dto);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,4 +103,12 @@ export class ServersService {
|
|||||||
await this.natsService.sendDeployCommand(licenseId, { ...dto });
|
await this.natsService.sendDeployCommand(licenseId, { ...dto });
|
||||||
return { message: 'Deployment started' };
|
return { message: 'Deployment started' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install Oxide/uMod via companion agent
|
||||||
|
*/
|
||||||
|
async installOxide(licenseId: string) {
|
||||||
|
await this.natsService.sendOxideInstallCommand(licenseId);
|
||||||
|
return { message: 'Oxide installation started' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
80
backend-nest/src/modules/teleport/teleport.controller.ts
Normal file
80
backend-nest/src/modules/teleport/teleport.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/teleport/teleport.module.ts
Normal file
14
backend-nest/src/modules/teleport/teleport.module.ts
Normal 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 {}
|
||||||
180
backend-nest/src/modules/teleport/teleport.service.ts
Normal file
180
backend-nest/src/modules/teleport/teleport.service.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,11 @@ export class NatsBridgeService implements OnModuleInit {
|
|||||||
this.emit(licenseId, 'deploy_status', data);
|
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');
|
this.logger.log('NATS bridge subscriptions initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,4 +79,12 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
timestamp: new Date().toISOString(),
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
backend/migrations/013_loot_profiles.sql
Normal file
13
backend/migrations/013_loot_profiles.sql
Normal 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);
|
||||||
12
backend/migrations/014_teleport_configs.sql
Normal file
12
backend/migrations/014_teleport_configs.sql
Normal 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);
|
||||||
@@ -31,6 +31,10 @@ type Config struct {
|
|||||||
// Install directory for deployment
|
// Install directory for deployment
|
||||||
InstallDir string `envconfig:"INSTALL_DIR" default:""`
|
InstallDir string `envconfig:"INSTALL_DIR" default:""`
|
||||||
|
|
||||||
|
// RCON configuration
|
||||||
|
RconPort int `envconfig:"RCON_PORT" default:"28016"`
|
||||||
|
RconPassword string `envconfig:"RCON_PASSWORD" default:""`
|
||||||
|
|
||||||
// Optional settings
|
// Optional settings
|
||||||
HeartbeatInterval int `envconfig:"HEARTBEAT_INTERVAL" default:"60"`
|
HeartbeatInterval int `envconfig:"HEARTBEAT_INTERVAL" default:"60"`
|
||||||
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
|
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
|
||||||
@@ -63,6 +67,7 @@ func main() {
|
|||||||
log.Printf(" Game Server Path: %s", cfg.GameServerPath)
|
log.Printf(" Game Server Path: %s", cfg.GameServerPath)
|
||||||
log.Printf(" SteamCMD Path: %s", cfg.SteamCMDPath)
|
log.Printf(" SteamCMD Path: %s", cfg.SteamCMDPath)
|
||||||
log.Printf(" Install Dir: %s", cfg.InstallDir)
|
log.Printf(" Install Dir: %s", cfg.InstallDir)
|
||||||
|
log.Printf(" RCON Port: %d", cfg.RconPort)
|
||||||
log.Printf(" Heartbeat Interval: %ds", cfg.HeartbeatInterval)
|
log.Printf(" Heartbeat Interval: %ds", cfg.HeartbeatInterval)
|
||||||
|
|
||||||
// Create context with signal handling for graceful shutdown
|
// Create context with signal handling for graceful shutdown
|
||||||
@@ -88,6 +93,8 @@ func main() {
|
|||||||
GameServerArgs: cfg.GameServerArgs,
|
GameServerArgs: cfg.GameServerArgs,
|
||||||
Version: version,
|
Version: version,
|
||||||
InstallDir: cfg.InstallDir,
|
InstallDir: cfg.InstallDir,
|
||||||
|
RconPort: cfg.RconPort,
|
||||||
|
RconPassword: cfg.RconPassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start daemon
|
// Start daemon
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/klauspost/compress v1.17.0 // indirect
|
github.com/klauspost/compress v1.17.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.5 // indirect
|
github.com/nats-io/nkeys v0.4.5 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
|
|||||||
@@ -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 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||||
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
|
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import (
|
|||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/deploy"
|
"github.com/vigilcyber/corrosion-companion/internal/deploy"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/filemanager"
|
"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/files"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/process"
|
"github.com/vigilcyber/corrosion-companion/internal/process"
|
||||||
|
"github.com/vigilcyber/corrosion-companion/internal/rcon"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/update"
|
"github.com/vigilcyber/corrosion-companion/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,18 +27,21 @@ type DaemonConfig struct {
|
|||||||
GameServerArgs string
|
GameServerArgs string
|
||||||
Version string
|
Version string
|
||||||
InstallDir string
|
InstallDir string
|
||||||
|
RconPort int
|
||||||
|
RconPassword string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Daemon manages the companion agent's main operations
|
// Daemon manages the companion agent's main operations
|
||||||
type Daemon struct {
|
type Daemon struct {
|
||||||
nc *nats.Conn
|
nc *nats.Conn
|
||||||
cfg *DaemonConfig
|
cfg *DaemonConfig
|
||||||
gameServer *process.GameServer
|
gameServer *process.GameServer
|
||||||
fileOps *files.Operations
|
fileOps *files.Operations
|
||||||
fm *filemanager.FileManager
|
fm *filemanager.FileManager
|
||||||
updater *update.Updater
|
updater *update.Updater
|
||||||
deployer *deploy.Deployer
|
deployer *deploy.Deployer
|
||||||
subscriptions []*nats.Subscription
|
oxideInstaller *oxide.OxideInstaller
|
||||||
|
subscriptions []*nats.Subscription
|
||||||
}
|
}
|
||||||
|
|
||||||
// HeartbeatPayload represents the data sent in heartbeat messages
|
// HeartbeatPayload represents the data sent in heartbeat messages
|
||||||
@@ -53,6 +58,7 @@ type HeartbeatPayload struct {
|
|||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
ServerInstalled bool `json:"server_installed"`
|
ServerInstalled bool `json:"server_installed"`
|
||||||
|
OxideInstalled bool `json:"oxide_installed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// gameServerAdapter wraps process.GameServer to satisfy deploy.GameServerStarter
|
// 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)
|
*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
|
// NewDaemon creates a new daemon instance
|
||||||
func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
||||||
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
|
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
|
||||||
@@ -79,15 +94,18 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
|||||||
updater := update.NewUpdater(cfg.Version)
|
updater := update.NewUpdater(cfg.Version)
|
||||||
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
|
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
|
||||||
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
|
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
|
||||||
|
restarter := &restartAdapter{gs: gameServer}
|
||||||
|
oxideInst := oxide.NewOxideInstaller(nc, cfg.LicenseID, cfg.InstallDir, restarter)
|
||||||
|
|
||||||
d := &Daemon{
|
d := &Daemon{
|
||||||
nc: nc,
|
nc: nc,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
gameServer: gameServer,
|
gameServer: gameServer,
|
||||||
fileOps: fileOps,
|
fileOps: fileOps,
|
||||||
fm: fm,
|
fm: fm,
|
||||||
updater: updater,
|
updater: updater,
|
||||||
deployer: deployer,
|
deployer: deployer,
|
||||||
|
oxideInstaller: oxideInst,
|
||||||
}
|
}
|
||||||
|
|
||||||
return d, nil
|
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)
|
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)
|
// Subscribe to file manager commands (VueFinder-compatible request-reply)
|
||||||
if err := d.subscribeFileManager(); err != nil {
|
if err := d.subscribeFileManager(); err != nil {
|
||||||
return fmt.Errorf("failed to subscribe to file manager commands: %w", err)
|
return fmt.Errorf("failed to subscribe to file manager commands: %w", err)
|
||||||
@@ -155,7 +178,8 @@ func (d *Daemon) subscribeServerCommands() error {
|
|||||||
|
|
||||||
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
|
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||||
var cmd struct {
|
var cmd struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
|
Command string `json:"command"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(msg.Data, &cmd); err != nil {
|
if err := json.Unmarshal(msg.Data, &cmd); err != nil {
|
||||||
@@ -174,6 +198,24 @@ func (d *Daemon) subscribeServerCommands() error {
|
|||||||
err = d.gameServer.Stop()
|
err = d.gameServer.Stop()
|
||||||
case "restart":
|
case "restart":
|
||||||
err = d.gameServer.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:
|
default:
|
||||||
err = fmt.Errorf("unknown action: %s", cmd.Action)
|
err = fmt.Errorf("unknown action: %s", cmd.Action)
|
||||||
}
|
}
|
||||||
@@ -367,6 +409,38 @@ func (d *Daemon) subscribeFileManager() error {
|
|||||||
return nil
|
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
|
// handleFileOperation processes file operation requests
|
||||||
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
|
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
|
||||||
// Parse common fields
|
// Parse common fields
|
||||||
@@ -437,6 +511,7 @@ func (d *Daemon) publishHeartbeat() {
|
|||||||
OS: runtime.GOOS,
|
OS: runtime.GOOS,
|
||||||
Arch: runtime.GOARCH,
|
Arch: runtime.GOARCH,
|
||||||
ServerInstalled: deploy.CheckServerInstalled(d.cfg.InstallDir),
|
ServerInstalled: deploy.CheckServerInstalled(d.cfg.InstallDir),
|
||||||
|
OxideInstalled: oxide.CheckOxideInstalled(d.cfg.InstallDir),
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.Marshal(payload)
|
data, err := json.Marshal(payload)
|
||||||
|
|||||||
250
companion-agent/internal/oxide/installer.go
Normal file
250
companion-agent/internal/oxide/installer.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
companion-agent/internal/oxide/status.go
Normal file
31
companion-agent/internal/oxide/status.go
Normal 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
|
||||||
|
}
|
||||||
80
companion-agent/internal/rcon/client.go
Normal file
80
companion-agent/internal/rcon/client.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Crosshair,
|
||||||
|
Navigation2,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
@@ -44,6 +46,8 @@ const navItems = [
|
|||||||
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
|
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
|
||||||
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
|
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
|
||||||
{ name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.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: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' },
|
||||||
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
|
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
|
||||||
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
|
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
|
||||||
@@ -105,7 +109,7 @@ function canShowNavItem(item: typeof navItems[0]): boolean {
|
|||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside
|
<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'"
|
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
|
||||||
>
|
>
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
@@ -203,8 +207,8 @@ function canShowNavItem(item: typeof navItems[0]): boolean {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content (offset by sidebar width on desktop) -->
|
||||||
<main class="flex-1 overflow-y-auto md:ml-0">
|
<main class="flex-1 overflow-y-auto md:pl-64">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
103
frontend/src/components/loot/LootContainerSidebar.vue
Normal file
103
frontend/src/components/loot/LootContainerSidebar.vue
Normal 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>
|
||||||
184
frontend/src/components/loot/LootGroupEditor.vue
Normal file
184
frontend/src/components/loot/LootGroupEditor.vue
Normal 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>
|
||||||
232
frontend/src/components/loot/LootItemEditor.vue
Normal file
232
frontend/src/components/loot/LootItemEditor.vue
Normal 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>
|
||||||
88
frontend/src/components/loot/LootItemPicker.vue
Normal file
88
frontend/src/components/loot/LootItemPicker.vue
Normal 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>
|
||||||
195
frontend/src/components/teleport/PermissionGroupEditor.vue
Normal file
195
frontend/src/components/teleport/PermissionGroupEditor.vue
Normal 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>
|
||||||
76
frontend/src/components/teleport/WarpEditor.vue
Normal file
76
frontend/src/components/teleport/WarpEditor.vue
Normal 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>
|
||||||
72
frontend/src/data/rust-containers.ts
Normal file
72
frontend/src/data/rust-containers.ts
Normal 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]
|
||||||
259
frontend/src/data/rust-items.ts
Normal file
259
frontend/src/data/rust-items.ts
Normal 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]
|
||||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||||
|
|
||||||
|
import { VueFinderPlugin } from 'vuefinder'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
@@ -14,5 +15,6 @@ pinia.use(piniaPluginPersistedstate)
|
|||||||
|
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.use(VueFinderPlugin)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ const panelRoutes: RouteRecordRaw[] = [
|
|||||||
name: 'files',
|
name: 'files',
|
||||||
component: () => import('@/views/admin/FileManagerView.vue'),
|
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',
|
path: 'wipes',
|
||||||
name: 'wipes',
|
name: 'wipes',
|
||||||
|
|||||||
179
frontend/src/stores/loot.ts
Normal file
179
frontend/src/stores/loot.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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) {
|
function updateDeploymentStatus(status: DeploymentStatus) {
|
||||||
deploymentStatus.value = status
|
deploymentStatus.value = status
|
||||||
if (status.stage === 'online' || status.stage === 'failed') {
|
if (status.stage === 'online' || status.stage === 'failed') {
|
||||||
@@ -94,6 +103,7 @@ export const useServerStore = defineStore('server', () => {
|
|||||||
stopServer,
|
stopServer,
|
||||||
restartServer,
|
restartServer,
|
||||||
deployServer,
|
deployServer,
|
||||||
|
installOxide,
|
||||||
updateDeploymentStatus,
|
updateDeploymentStatus,
|
||||||
clearDeploymentStatus,
|
clearDeploymentStatus,
|
||||||
updateStats,
|
updateStats,
|
||||||
|
|||||||
145
frontend/src/stores/teleport.ts
Normal file
145
frontend/src/stores/teleport.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -442,3 +442,107 @@ export interface DeploymentStatus {
|
|||||||
message: string
|
message: string
|
||||||
error?: 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
|
||||||
|
}
|
||||||
|
|||||||
393
frontend/src/views/admin/LootBuilderView.vue
Normal file
393
frontend/src/views/admin/LootBuilderView.vue
Normal 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>
|
||||||
@@ -12,6 +12,7 @@ const toast = useToastStore()
|
|||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const tab = ref<'installed' | 'browse' | 'upload'>('installed')
|
const tab = ref<'installed' | 'browse' | 'upload'>('installed')
|
||||||
const browseQuery = ref('')
|
const browseQuery = ref('')
|
||||||
|
const browsePage = ref(1)
|
||||||
const browseDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
|
const browseDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const installing = ref<string | 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
|
if (!browseQuery.value.trim()) return
|
||||||
|
browsePage.value = page
|
||||||
try {
|
try {
|
||||||
await pluginStore.browseUmod(browseQuery.value.trim())
|
await pluginStore.browseUmod(browseQuery.value.trim(), page)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to search uMod plugins')
|
toast.error('Failed to search uMod plugins')
|
||||||
}
|
}
|
||||||
@@ -82,7 +84,17 @@ async function handleBrowseSearch() {
|
|||||||
|
|
||||||
function scheduleBrowseSearch() {
|
function scheduleBrowseSearch() {
|
||||||
if (browseDebounce.value) clearTimeout(browseDebounce.value)
|
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) {
|
async function installFromBrowse(result: UmodPlugin) {
|
||||||
@@ -219,7 +231,7 @@ onMounted(() => {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Search uMod plugins..."
|
placeholder="Search uMod plugins..."
|
||||||
@input="scheduleBrowseSearch"
|
@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"
|
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>
|
</div>
|
||||||
@@ -325,6 +337,22 @@ onMounted(() => {
|
|||||||
{{ pluginStore.browseResults.total.toLocaleString() }} plugins found
|
{{ pluginStore.browseResults.total.toLocaleString() }} plugins found
|
||||||
• Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
|
• Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
|
||||||
</p>
|
</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"
|
||||||
|
>
|
||||||
|
← 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 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -366,6 +394,28 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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"
|
||||||
|
>
|
||||||
|
← 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 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Rocket,
|
Rocket,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
Check,
|
||||||
|
Puzzle,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
||||||
import { useWebSocket } from '@/composables/useWebSocket'
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
@@ -34,6 +35,8 @@ const setupTab = ref<'linux' | 'windows'>('linux')
|
|||||||
const windowsCopied = ref(false)
|
const windowsCopied = ref(false)
|
||||||
const showDeployForm = ref(false)
|
const showDeployForm = ref(false)
|
||||||
const deployLoading = 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>({
|
const deployForm = ref<DeploymentConfig>({
|
||||||
server_name: 'My Rust Server',
|
server_name: 'My Rust Server',
|
||||||
@@ -141,6 +144,42 @@ function getStageState(stageKey: string): 'pending' | 'active' | 'complete' | 'f
|
|||||||
return 'pending'
|
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({
|
const form = ref({
|
||||||
server_name: '',
|
server_name: '',
|
||||||
max_players: 0,
|
max_players: 0,
|
||||||
@@ -207,6 +246,12 @@ onMounted(async () => {
|
|||||||
if (msg.type === 'event' && msg.event === 'deploy_status') {
|
if (msg.type === 'event' && msg.event === 'deploy_status') {
|
||||||
server.updateDeploymentStatus(msg.data as DeploymentStatus)
|
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>
|
</script>
|
||||||
@@ -544,6 +589,82 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Configuration -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
|||||||
694
frontend/src/views/admin/TeleportConfigView.vue
Normal file
694
frontend/src/views/admin/TeleportConfigView.vue
Normal 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>
|
||||||
1555
plugin/CorrosionTeleportGUI.cs
Normal file
1555
plugin/CorrosionTeleportGUI.cs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user