feat: Add RaidableBases plugin config module — DB migration, NestJS CRUD, Vue editor
All checks were successful
Test Asgard Runner / test (push) Successful in 3s

- Migration 021: raidablebases_configs table with JSONB config_data
- Entity, module, controller (7 endpoints), service with NATS deploy/import
- Frontend: 4-tab editor (General, Difficulty, NPC, Loot & Rewards)
- Pinia store, types, router route, sidebar nav with Swords icon
- Top 30 most common settings with actual RaidableBases.json key paths
- Difficulty sub-tabs for Easy/Medium/Hard/Expert/Nightmare with spawn day toggles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-22 02:20:21 -05:00
parent bb381569e3
commit 2668014068
14 changed files with 1700 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
@Injectable()
export class RaidableBasesService {
private readonly logger = new Logger(RaidableBasesService.name);
constructor(
@InjectRepository(RaidableBasesConfig)
private readonly raidableBasesRepo: Repository<RaidableBasesConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.raidableBasesRepo.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.raidableBasesRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('RaidableBases config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateRaidableBasesConfigDto) {
const config = this.raidableBasesRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.raidableBasesRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateRaidableBasesConfigDto) {
const config = await this.raidableBasesRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('RaidableBases 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.raidableBasesRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.raidableBasesRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('RaidableBases config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.raidableBasesRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('RaidableBases config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write RaidableBases.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/RaidableBases.json',
content: jsonString,
},
30000,
);
// Reload RaidableBases plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload RaidableBases',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.raidableBasesRepo.update({ license_id: licenseId }, { is_active: false });
await this.raidableBasesRepo.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 RaidableBases config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy RaidableBases config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import RaidableBases.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read RaidableBases.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/RaidableBases.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 RaidableBases config row
const config = this.raidableBasesRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.raidableBasesRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import RaidableBases config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import RaidableBases config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}