343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
import type {
|
|
RestartScheduleInput,
|
|
ServerScheduleSummary,
|
|
ServerStatus,
|
|
} from '@reforger-panel/shared';
|
|
import { ApiError } from '../../lib/errors.js';
|
|
import type {
|
|
DownloadableFile,
|
|
GameServerProvider,
|
|
ProviderServerResources,
|
|
ServerFileEntry,
|
|
} from './types.js';
|
|
|
|
const START_DELAY_MS = 4_000;
|
|
const STOP_DELAY_MS = 2_500;
|
|
|
|
function pad(n: number, width = 2): string {
|
|
return String(n).padStart(width, '0');
|
|
}
|
|
|
|
function timeOfDay(date: Date): string {
|
|
return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(
|
|
date.getUTCMilliseconds(),
|
|
3,
|
|
)}`;
|
|
}
|
|
|
|
function dateStamp(date: Date): string {
|
|
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
|
|
}
|
|
|
|
/**
|
|
* Builds a plausible Reforger console.log covering the last ~50 minutes:
|
|
* server start, four connects, one disconnect. Line shapes mirror the real
|
|
* Enfusion/BattlEye output the parser targets (see parser/patterns.ts).
|
|
*/
|
|
export function buildMockConsoleLog(now: Date = new Date()): string {
|
|
const at = (minutesAgo: number, driftSeconds = 0) =>
|
|
new Date(now.getTime() - minutesAgo * 60_000 + driftSeconds * 1000);
|
|
|
|
const started = at(50);
|
|
const lines = [
|
|
`------------------------------------------------------------------------------------------------`,
|
|
`Log started ${dateStamp(started)} ${timeOfDay(started).slice(0, 8)}`,
|
|
`${timeOfDay(started)} ENGINE : Enfusion engine build: 1.3.0.42 (mock)`,
|
|
`${timeOfDay(at(50, 4))} DEFAULT : Loading world.`,
|
|
`${timeOfDay(at(49))} DEFAULT : Game successfully created.`,
|
|
`${timeOfDay(at(48))} NETWORK : Server is ready to accept connections`,
|
|
`${timeOfDay(at(44))} DEFAULT : BattlEye Server: 'Player #1 Braeden (10.66.4.21:50241) connected'`,
|
|
`${timeOfDay(at(44, 2))} DEFAULT : BattlEye Server: 'Player #1 Braeden - GUID: 9f2ab04c11d9e0aa'`,
|
|
`${timeOfDay(at(38))} DEFAULT : BattlEye Server: 'Player #2 Sable (10.66.4.30:61022) connected'`,
|
|
`${timeOfDay(at(38, 1))} DEFAULT : BattlEye Server: 'Player #2 Sable - GUID: 41c7de9a5b02f311'`,
|
|
`${timeOfDay(at(31))} DEFAULT : BattlEye Server: 'Player #3 Kestrel (10.66.4.87:49155) connected'`,
|
|
`${timeOfDay(at(27))} SCRIPT : SCR_BaseGameMode: match state changed`,
|
|
`${timeOfDay(at(22))} DEFAULT : BattlEye Server: 'Player #4 Moss (10.66.4.44:51811) connected'`,
|
|
`${timeOfDay(at(22, 1))} DEFAULT : BattlEye Server: 'Player #4 Moss - GUID: c31009e2ab77d514'`,
|
|
`${timeOfDay(at(9))} DEFAULT : BattlEye Server: 'Player #3 Kestrel disconnected'`,
|
|
`${timeOfDay(at(2))} NETWORK : ### Connection stats`,
|
|
'',
|
|
];
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* In-process stand-in for Pterodactyl so the whole panel runs without
|
|
* credentials. Power actions transition through starting/stopping states, and
|
|
* the mock file system serves a generated console.log fixture.
|
|
*/
|
|
export class MockGameServerProvider implements GameServerProvider {
|
|
private status: ServerStatus = 'online';
|
|
private startedAt = Date.now() - 50 * 60_000;
|
|
private transitionTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private readonly logContent: string;
|
|
private readonly logPath: string;
|
|
|
|
private readonly configPath: string;
|
|
private configContent: string;
|
|
private nextScheduleId = 2;
|
|
private schedules: ServerScheduleSummary[] = [
|
|
{
|
|
id: '1',
|
|
name: 'Daily restart',
|
|
isActive: true,
|
|
onlyWhenOnline: true,
|
|
minute: '0',
|
|
hour: '9',
|
|
dayOfMonth: '*',
|
|
month: '*',
|
|
dayOfWeek: '*',
|
|
nextRunAt: null,
|
|
lastRunAt: null,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
tasks: [
|
|
{
|
|
id: '1',
|
|
action: 'power',
|
|
payload: 'restart',
|
|
timeOffsetSeconds: 0,
|
|
continueOnFailure: false,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
/** Files written via writeTextFile (e.g. config.json backups). */
|
|
readonly writtenFiles = new Map<string, string>();
|
|
|
|
constructor(options: { logPath?: string; configPath?: string; now?: Date } = {}) {
|
|
this.logPath = options.logPath ?? '/profile/logs/console.log';
|
|
this.logContent = buildMockConsoleLog(options.now ?? new Date());
|
|
this.configPath = options.configPath ?? '/config.json';
|
|
// Shape mirrors a real Reforger dedicated-server config.json.
|
|
this.configContent = JSON.stringify(
|
|
{
|
|
bindAddress: '0.0.0.0',
|
|
bindPort: 2001,
|
|
game: {
|
|
name: 'Mock Reforger Server',
|
|
scenarioId: '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf',
|
|
maxPlayers: 16,
|
|
crossPlatform: true,
|
|
gameProperties: {
|
|
serverMaxViewDistance: 2500,
|
|
networkViewDistance: 1500,
|
|
disableThirdPerson: false,
|
|
},
|
|
mods: [{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }],
|
|
},
|
|
operating: { disableAI: false, aiLimit: 40 },
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
|
|
dispose() {
|
|
if (this.transitionTimer) clearTimeout(this.transitionTimer);
|
|
}
|
|
|
|
private transition(to: ServerStatus, after: number, thenTo: ServerStatus) {
|
|
this.status = to;
|
|
if (this.transitionTimer) clearTimeout(this.transitionTimer);
|
|
this.transitionTimer = setTimeout(() => {
|
|
this.status = thenTo;
|
|
if (thenTo === 'online') this.startedAt = Date.now();
|
|
this.transitionTimer = null;
|
|
}, after);
|
|
this.transitionTimer.unref?.();
|
|
}
|
|
|
|
async getServerStatus(): Promise<ServerStatus> {
|
|
return this.status;
|
|
}
|
|
|
|
async getServerResources(): Promise<ProviderServerResources> {
|
|
const online = this.status === 'online';
|
|
const wobble = (base: number, spread: number) => base + (Math.random() - 0.5) * spread;
|
|
return {
|
|
status: this.status,
|
|
cpuPercent: online ? Math.max(2, wobble(38, 14)) : 0,
|
|
cpuLimitPercent: 400,
|
|
memoryBytes: online ? Math.round(wobble(5.1, 0.6) * 1024 ** 3) : 0,
|
|
memoryLimitBytes: 8 * 1024 ** 3,
|
|
diskBytes: Math.round(22.4 * 1024 ** 3),
|
|
diskLimitBytes: 40 * 1024 ** 3,
|
|
networkRxBytes: online ? Math.round(wobble(9.2, 1.5) * 1024 ** 2) : 0,
|
|
networkTxBytes: online ? Math.round(wobble(26.8, 4) * 1024 ** 2) : 0,
|
|
uptimeMs: online ? Date.now() - this.startedAt : 0,
|
|
};
|
|
}
|
|
|
|
async startServer(): Promise<void> {
|
|
if (this.status === 'online') return;
|
|
this.transition('starting', START_DELAY_MS, 'online');
|
|
}
|
|
|
|
async stopServer(): Promise<void> {
|
|
if (this.status === 'offline') return;
|
|
this.transition('stopping', STOP_DELAY_MS, 'offline');
|
|
}
|
|
|
|
async restartServer(): Promise<void> {
|
|
this.transition('stopping', STOP_DELAY_MS, 'starting');
|
|
setTimeout(() => {
|
|
if (this.status === 'starting') {
|
|
this.status = 'online';
|
|
this.startedAt = Date.now();
|
|
}
|
|
}, STOP_DELAY_MS + START_DELAY_MS).unref?.();
|
|
}
|
|
|
|
async listFiles(_serverId: string, directory: string): Promise<ServerFileEntry[]> {
|
|
const dir = directory.replace(/\/$/, '') || '/';
|
|
const logDir = this.logPath.slice(0, this.logPath.lastIndexOf('/')) || '/';
|
|
if (dir !== logDir) return [];
|
|
return [
|
|
{
|
|
name: this.logPath.slice(this.logPath.lastIndexOf('/') + 1),
|
|
isFile: true,
|
|
sizeBytes: Buffer.byteLength(this.logContent),
|
|
modifiedAt: new Date(),
|
|
},
|
|
];
|
|
}
|
|
|
|
async getFileDownloadUrl(): Promise<string> {
|
|
throw ApiError.notConfigured('Direct downloads are not available in mock mode.');
|
|
}
|
|
|
|
async writeTextFile(_serverId: string, path: string, content: string): Promise<void> {
|
|
this.writtenFiles.set(path, content);
|
|
if (path === this.configPath) {
|
|
this.configContent = content;
|
|
}
|
|
}
|
|
|
|
private startupVariables = [
|
|
{
|
|
name: 'Server Password',
|
|
description: 'Password required to join the server.',
|
|
envVariable: 'SERVER_PASSWORD',
|
|
serverValue: '',
|
|
defaultValue: '',
|
|
isEditable: true,
|
|
},
|
|
{
|
|
name: 'Admin Password',
|
|
description: 'Password for in-game admin access.',
|
|
envVariable: 'ADMIN_PASSWORD',
|
|
serverValue: 'mock-admin-pass',
|
|
defaultValue: '',
|
|
isEditable: true,
|
|
},
|
|
{
|
|
name: 'App ID',
|
|
description: 'Steam application id (managed by the egg).',
|
|
envVariable: 'SRCDS_APPID',
|
|
serverValue: '1874900',
|
|
defaultValue: '1874900',
|
|
isEditable: false,
|
|
},
|
|
];
|
|
|
|
async listStartupVariables() {
|
|
return this.startupVariables.map((v) => ({ ...v }));
|
|
}
|
|
|
|
async updateStartupVariable(_serverId: string, envVariable: string, value: string) {
|
|
const variable = this.startupVariables.find((v) => v.envVariable === envVariable);
|
|
if (!variable || !variable.isEditable) {
|
|
throw ApiError.validation('This startup variable cannot be edited.');
|
|
}
|
|
variable.serverValue = value;
|
|
}
|
|
|
|
async listSchedules(): Promise<ServerScheduleSummary[]> {
|
|
return this.schedules.map((schedule) => ({
|
|
...schedule,
|
|
tasks: schedule.tasks.map((task) => ({ ...task })),
|
|
}));
|
|
}
|
|
|
|
async createRestartSchedule(
|
|
_serverId: string,
|
|
input: RestartScheduleInput,
|
|
): Promise<ServerScheduleSummary> {
|
|
const now = new Date().toISOString();
|
|
const schedule: ServerScheduleSummary = {
|
|
id: String(this.nextScheduleId++),
|
|
name: input.name,
|
|
isActive: input.isActive,
|
|
onlyWhenOnline: input.onlyWhenOnline,
|
|
minute: String(input.minute),
|
|
hour: String(input.hour),
|
|
dayOfMonth: '*',
|
|
month: '*',
|
|
dayOfWeek: input.dayOfWeek,
|
|
nextRunAt: null,
|
|
lastRunAt: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
tasks: [
|
|
{
|
|
id: String(this.nextScheduleId++),
|
|
action: 'power',
|
|
payload: 'restart',
|
|
timeOffsetSeconds: 0,
|
|
continueOnFailure: false,
|
|
},
|
|
],
|
|
};
|
|
this.schedules.unshift(schedule);
|
|
return { ...schedule, tasks: schedule.tasks.map((task) => ({ ...task })) };
|
|
}
|
|
|
|
async updateRestartSchedule(
|
|
_serverId: string,
|
|
scheduleId: string,
|
|
input: RestartScheduleInput,
|
|
): Promise<ServerScheduleSummary> {
|
|
const schedule = this.schedules.find((s) => s.id === scheduleId);
|
|
if (!schedule) throw ApiError.notFound('Schedule not found.');
|
|
schedule.name = input.name;
|
|
schedule.isActive = input.isActive;
|
|
schedule.onlyWhenOnline = input.onlyWhenOnline;
|
|
schedule.minute = String(input.minute);
|
|
schedule.hour = String(input.hour);
|
|
schedule.dayOfWeek = input.dayOfWeek;
|
|
schedule.updatedAt = new Date().toISOString();
|
|
return { ...schedule, tasks: schedule.tasks.map((task) => ({ ...task })) };
|
|
}
|
|
|
|
async deleteSchedule(_serverId: string, scheduleId: string): Promise<void> {
|
|
this.schedules = this.schedules.filter((schedule) => schedule.id !== scheduleId);
|
|
}
|
|
|
|
async downloadTextFile(
|
|
_serverId: string,
|
|
path: string,
|
|
maxBytes = 2 * 1024 * 1024,
|
|
): Promise<DownloadableFile> {
|
|
const content =
|
|
path === this.logPath
|
|
? this.logContent
|
|
: path === this.configPath
|
|
? this.configContent
|
|
: null;
|
|
if (content === null) {
|
|
throw ApiError.notFound(`Mock file not found: ${path}`);
|
|
}
|
|
const buffer = Buffer.from(content, 'utf8');
|
|
const trimmed =
|
|
buffer.byteLength > maxBytes ? buffer.subarray(buffer.byteLength - maxBytes) : buffer;
|
|
return {
|
|
path,
|
|
content: trimmed.toString('utf8'),
|
|
totalSizeBytes: buffer.byteLength,
|
|
contentStartOffset: buffer.byteLength - trimmed.byteLength,
|
|
truncated: trimmed.byteLength < buffer.byteLength,
|
|
};
|
|
}
|
|
}
|