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 | 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(); 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 { return this.status; } async getServerResources(): Promise { 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 { if (this.status === 'online') return; this.transition('starting', START_DELAY_MS, 'online'); } async stopServer(): Promise { if (this.status === 'offline') return; this.transition('stopping', STOP_DELAY_MS, 'offline'); } async restartServer(): Promise { 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 { 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 { throw ApiError.notConfigured('Direct downloads are not available in mock mode.'); } async writeTextFile(_serverId: string, path: string, content: string): Promise { 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 { return this.schedules.map((schedule) => ({ ...schedule, tasks: schedule.tasks.map((task) => ({ ...task })), })); } async createRestartSchedule( _serverId: string, input: RestartScheduleInput, ): Promise { 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 { 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 { this.schedules = this.schedules.filter((schedule) => schedule.id !== scheduleId); } async downloadTextFile( _serverId: string, path: string, maxBytes = 2 * 1024 * 1024, ): Promise { 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, }; } }