import path from 'node:path'; import { fileURLToPath } from 'node:url'; import dotenv from 'dotenv'; const here = path.dirname(fileURLToPath(import.meta.url)); export const SERVER_ROOT = path.resolve(here, '..'); export const REPO_ROOT = path.resolve(SERVER_ROOT, '..'); dotenv.config({ path: path.join(REPO_ROOT, '.env') }); function int(name, fallback) { const raw = process.env[name]; if (raw === undefined || raw === '') return fallback; const value = Number.parseInt(raw, 10); if (Number.isNaN(value)) throw new Error(`Umgebungsvariable ${name} muss eine Zahl sein (ist: "${raw}")`); return value; } function bool(name, fallback) { const raw = process.env[name]; if (raw === undefined || raw === '') return fallback; return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase()); } function str(name, fallback = '') { const raw = process.env[name]; return raw === undefined || raw === '' ? fallback : raw; } export function loadConfig(overrides = {}) { const config = { port: int('PORT', 3000), publicUrl: str('PUBLIC_URL', 'http://localhost:5173').replace(/\/$/, ''), databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')), mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')), createMatchPassword: str('CREATE_MATCH_PASSWORD'), requireFullTeams: bool('REQUIRE_FULL_TEAMS', false), trustProxy: bool('TRUST_PROXY', false), serverAdapter: str('SERVER_ADAPTER', 'demo'), idleTimeoutMinutes: int('SERVER_IDLE_TIMEOUT_MINUTES', 30), maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8), statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5), reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60), match: { tvDelaySeconds: int('CS2_TV_DELAY', 90), tvEnabled: bool('CS2_TV_ENABLE', true), maxRoundsCompetitive: int('CS2_MAXROUNDS_COMPETITIVE', 24), maxRoundsWingman: int('CS2_MAXROUNDS_WINGMAN', 16), overtime: bool('CS2_OVERTIME', true), extraCommands: str('CS2_EXTRA_COMMANDS') .split(';') .map((c) => c.trim()) .filter(Boolean), }, demo: { startSeconds: int('DEMO_START_SECONDS', 20), failFirstConfigure: bool('DEMO_FAIL_FIRST_CONFIGURE', false), }, docker: { bin: str('DOCKER_BIN', 'docker'), image: str('CS2_DOCKER_IMAGE', 'cm2network/cs2:latest'), host: str('CS2_PUBLIC_HOST'), rconHost: str('CS2_RCON_HOST', '127.0.0.1'), portRangeStart: int('CS2_PORT_RANGE_START', 27015), portRangeEnd: int('CS2_PORT_RANGE_END', 27115), gslt: str('CS2_GSLT'), volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'), extraArgs: str('CS2_DOCKER_EXTRA_ARGS'), startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20), }, staticPool: { // Format: host:port:rconPasswort[:tvPort], mehrere durch Komma getrennt servers: str('CS2_STATIC_SERVERS') .split(',') .map((s) => s.trim()) .filter(Boolean) .map(parseStaticServer), }, }; return deepMerge(config, overrides); } function parseStaticServer(entry) { const parts = entry.split(':'); if (parts.length < 3) { throw new Error(`CS2_STATIC_SERVERS-Eintrag "${entry}" hat nicht das Format host:port:rconPasswort[:tvPort]`); } const [host, port, ...rest] = parts; let tvPort = null; // Das Passwort darf Doppelpunkte enthalten; ein abschließender rein numerischer Teil ist der TV-Port. if (rest.length > 1 && /^\d+$/.test(rest[rest.length - 1])) tvPort = Number(rest.pop()); return { host, port: Number(port), rconPassword: rest.join(':'), tvPort: tvPort ?? Number(port) + 5 }; } function deepMerge(target, source) { for (const [key, value] of Object.entries(source)) { if (value && typeof value === 'object' && !Array.isArray(value) && typeof target[key] === 'object') { deepMerge(target[key], value); } else { target[key] = value; } } return target; }