import { mkdir, readFile, writeFile } from "node:fs/promises"; import { serverDataPath } from "../docker"; import type { Server } from "../db/schema"; import { grantToServer } from "./ownership"; /** * Defaults for a Build 42 server .ini. Anything the panel does not expose as a * form field lives here so a generated config is complete rather than relying * on the server filling in blanks on first boot. */ const INI_DEFAULTS: Record = { nightlengthmodifier: "1.0", PVP: "true", PauseEmpty: "true", GlobalChat: "true", ChatStreams: "s,r,a,w,y,sh,f,all", Open: "true", AutoCreateUserInWhiteList: "false", DisplayUserName: "true", ShowFirstAndLastName: "false", SpawnPoint: "0,0,0", SafetySystem: "true", ShowSafety: "true", SafetyToggleTimer: "2", SafetyCooldownTimer: "3", SpawnItems: "", DoLuaChecksum: "true", DenyLoginOnOverloadedServer: "true", Public: "false", PingLimit: "400", HoursForLootRespawn: "0", MaxItemsForLootRespawn: "4", ConstructionPreventsLootRespawn: "true", DropOffWhiteListAfterDeath: "false", NoFire: "false", AnnounceDeath: "false", MinutesPerPage: "1.0", SaveWorldEveryMinutes: "0", PlayerSafehouse: "false", AdminSafehouse: "false", SafehouseAllowTrepass: "true", SafehouseAllowFire: "true", SafehouseAllowLoot: "true", SafehouseAllowRespawn: "false", SafehouseDaySurvivedToClaim: "0", SafeHouseRemovalTime: "144", SafehouseAllowNonResidential: "false", AllowDestructionBySledgehammer: "true", KickFastPlayers: "false", MaxAccountsPerUser: "0", AllowCoop: "true", SleepAllowed: "false", SleepNeeded: "false", KnockedDownAllowed: "true", SneakModeHideFromOtherPlayers: "true", SteamScoreboard: "true", SteamVAC: "true", // Explicit Docker port mappings already publish the ports; UPnP inside a // container would try (and fail) to punch holes for the container's own IP. UPnP: "false", VoiceEnable: "true", VoiceMinDistance: "10.0", VoiceMaxDistance: "100.0", Voice3D: "true", BackupsCount: "5", BackupsOnStart: "true", BackupsOnVersionChange: "true", BackupsPeriod: "0", AntiCheatProtectionType1: "true", DisableRadioStaff: "false", DisableRadioAdmin: "true", DisableRadioGM: "true", DisableRadioOverseer: "false", DisableRadioModerator: "false", DisableRadioInvisible: "true", ClientCommandFilter: "-vehicle.*;+vehicle.damageWindow;+vehicle.fixPart;+vehicle.installPart;+vehicle.uninstallPart", ClientActionLogs: "ISEnterVehicle;ISExitVehicle;ISTakeEngineParts;", PerkLogs: "true", ItemNumbersLimitPerContainer: "0", BloodSplatLifespanDays: "0", AllowNonAsciiUsername: "false", BanKickGlobalSound: "true", RemovePlayerCorpsesOnCorpseRemoval: "false", TrashDeleteAll: "false", PVPMeleeWhileHitReaction: "false", MouseOverToSeeDisplayName: "true", HidePlayersBehindYou: "true", PVPMeleeDamageModifier: "30.0", PVPFirearmDamageModifier: "50.0", CarEngineAttractionModifier: "0.5", PlayerBumpPlayer: "false", MapRemotePlayerVisibility: "1", // Map is not defaulted here — it is always computed from the load order. }; /** PZ .ini values are newline-delimited key=value with no quoting or escapes. */ function sanitize(value: string): string { return value.replace(/[\r\n]+/g, " ").trim(); } /** The vanilla map. Always loaded last — the server reads Map= left to right. */ export const BASE_MAP = "Muldraugh, KY"; /** * Full map load order: the server's mod maps first, vanilla last. * * Map mods need three separate entries to work — the Workshop ID in * WorkshopItems, the mod ID in Mods, and the map FOLDER name here. They are * frequently all different strings for the same mod. */ export function mapLoadOrder(server: Server): string[] { const custom = (server.maps ?? "") .split(";") .map((m) => m.trim()) .filter(Boolean) // Guard against the vanilla map being listed manually — if it landed // anywhere but last it would shadow every mod map after it. .filter((m) => m.toLowerCase() !== BASE_MAP.toLowerCase()); return [...new Set(custom), BASE_MAP]; } /** * Options the panel always owns. Everything else is left to whatever is * already on disk, so changes made with `changeoption` or the in-game admin * panel survive a restart instead of being silently reverted. */ const PANEL_OWNED = new Set([ "DefaultPort", "UDPPort", "RCONPort", "RCONPassword", "SteamPort1", "SteamPort2", "Password", "MaxPlayers", "PVP", "PauseEmpty", "Public", "PublicName", "PublicDescription", "Mods", "WorkshopItems", "Map", "ServerWelcomeMessage", "UPnP", ]); /** Parses an existing server.ini into a key/value map. */ export function parseIni(text: string): Record { const out: Record = {}; for (const line of text.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eq = trimmed.indexOf("="); if (eq <= 0) continue; out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1); } return out; } export function renderServerIni(server: Server, existing: Record = {}): string { // Precedence, lowest first: shipped defaults, whatever is already on disk, // explicit panel overrides, then the fields the panel always owns. const preserved: Record = {}; for (const [key, value] of Object.entries(existing)) { if (!PANEL_OWNED.has(key)) preserved[key] = value; } const values: Record = { ...INI_DEFAULTS, ...preserved, ...(server.options ?? {}), // Panel-managed fields override the defaults. DefaultPort: String(server.gamePort), UDPPort: String(server.directPort), RCONPort: String(server.rconPort), // Without these the server cannot register with Steam and never shows up in // the in-game browser, even with Public=true. SteamPort1: String(server.steamPort1), SteamPort2: String(server.steamPort2), RCONPassword: sanitize(server.rconPassword), Password: sanitize(server.serverPassword), MaxPlayers: String(server.maxPlayers), PVP: String(server.pvp), PauseEmpty: String(server.pauseEmpty), Public: String(server.isPublic), PublicName: sanitize(server.publicName || server.name), PublicDescription: sanitize(server.publicDescription), Mods: sanitize(server.mods), WorkshopItems: sanitize(server.workshopItems), Map: mapLoadOrder(server).join(";"), ServerWelcomeMessage: sanitize( ` Welcome to ${server.publicName || server.name}! Use /help for commands.`, ), }; const lines = Object.entries(values) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => `${key}=${value}`); return `${lines.join("\n")}\n`; } /** * Sandbox settings live in a Lua table, not the .ini. Only keys present in the * server's stored sandbox object override these baseline values. */ const SANDBOX_DEFAULTS: Record = { VERSION: 5, Zombies: 3, Distribution: 1, DayLength: 3, StartYear: 1, StartMonth: 7, StartDay: 9, StartTime: 2, WaterShut: 2, ElecShut: 2, WaterShutModifier: 14, ElecShutModifier: 14, FoodLoot: 2, WeaponLoot: 2, OtherLoot: 2, Temperature: 3, Rain: 3, ErosionSpeed: 3, ErosionDays: 0, XpMultiplier: 1.0, ZombieAttractionMultiplier: 1.0, VehicleEasyUse: false, Farming: 3, CompostTime: 2, StatsDecrease: 3, NatureAbundance: 3, Alarm: 4, LockedHouses: 6, StarterKit: false, Nutrition: true, FoodRotSpeed: 3, FridgeFactor: 3, LootRespawn: 1, SeenHoursPreventLootRespawn: 0, TimeSinceApo: 1, PlantResilience: 3, PlantAbundance: 3, EndRegen: 3, Helicopter: 2, MetaEvent: 2, SleepingEvent: 1, GeneratorSpawning: 3, GeneratorFuelConsumption: 1.0, SurvivorHouseChance: 3, VehicleStoryChance: 3, ZoneStoryChance: 3, AnnotatedMapChance: 4, CharacterFreePoints: 0, ConstructionBonusPoints: 3, NightDarkness: 3, InjurySeverity: 2, BoneFracture: true, HoursForCorpseRemoval: 216, DecayingCorpseHealthImpact: 3, BloodLevel: 3, ClothingDegradation: 3, FireSpread: true, DaysForRottenFoodRemoval: -1, AllowExteriorGenerator: true, MaxFogIntensity: 1, MaxRainFxIntensity: 1, EnableSnowOnGround: true, MultiHitZombies: false, RearVulnerability: 3, AttackBlockMovements: true, AllClothesUnlocked: false, CarSpawnRate: 3, ChanceHasGas: 1, InitialGas: 2, FuelStationGas: 5, CarGasConsumption: 1.0, LockedCar: 3, CarGeneralCondition: 2, CarDamageOnImpact: 3, DamageToPlayerFromHitByACar: 1, TrafficJam: true, CarAlarm: 4, PlayerDamageFromCrash: true, LoseAccessoriesOnZombieDeath: false, SirenShutoffHours: 0.0, RecentlySurvivorVehicles: 1, EnableVehicles: true, ZombieLore: { Speed: 2, Strength: 2, Toughness: 2, Transmission: 1, Mortality: 5, Reanimate: 3, Cognition: 3, CrawlUnderVehicle: 3, Memory: 2, Sight: 2, Hearing: 2, ThumpNoChasing: false, ThumpOnConstruction: true, ActiveOnly: 1, TriggerHouseAlarm: false, ZombiesDragDown: true, ZombiesFenceLunge: true, DisableFakeDead: 1, }, } as unknown as Record; function luaValue(value: unknown, indent: string): string { if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "number") return String(value); if (value && typeof value === "object") { const inner = Object.entries(value as Record) .map(([k, v]) => `${indent} ${k} = ${luaValue(v, `${indent} `)},`) .join("\n"); return `{\n${inner}\n${indent}}`; } return `"${String(value).replace(/"/g, '\\"')}"`; } type LuaTable = Record; const isTable = (value: unknown): value is LuaTable => typeof value === "object" && value !== null && !Array.isArray(value); /** * Merges sandbox tables recursively. A shallow merge would be wrong: overriding * one key of `ZombieLore` would otherwise drop the seventeen it sits beside. */ function mergeTables(base: LuaTable, over: LuaTable): LuaTable { const out: LuaTable = { ...base }; for (const [key, value] of Object.entries(over)) { const prev = out[key]; out[key] = isTable(prev) && isTable(value) ? mergeTables(prev, value) : value; } return out; } /** * Reads an existing SandboxVars.lua into a nested object. * * This is not a Lua interpreter — it walks the one literal table PZ writes, * which holds only numbers, booleans, quoted strings and nested tables. Keys * the panel has never heard of are preserved along with the rest, so a game * update that adds a sandbox option does not lose it on the next restart. */ export function parseSandboxLua(text: string): LuaTable { const anchor = text.indexOf("SandboxVars"); const open = anchor < 0 ? -1 : text.indexOf("{", anchor); if (open < 0) return {}; let i = open; /** Advances past whitespace and `--` comments. */ function skip(): void { for (;;) { while (i < text.length && /\s/.test(text[i])) i++; if (text[i] === "-" && text[i + 1] === "-") { while (i < text.length && text[i] !== "\n") i++; continue; } return; } } function parseValue(): unknown { skip(); const ch = text[i]; if (ch === "{") return parseTable(); if (ch === '"' || ch === "'") { i++; let out = ""; while (i < text.length && text[i] !== ch) { if (text[i] === "\\") { out += text[i + 1] ?? ""; i += 2; continue; } out += text[i++]; } i++; // closing quote return out; } const start = i; while (i < text.length && !/[,;}\s]/.test(text[i])) i++; const raw = text.slice(start, i); if (raw === "true") return true; if (raw === "false") return false; if (raw === "nil") return undefined; const num = Number(raw); return raw !== "" && !Number.isNaN(num) ? num : raw; } function parseTable(): LuaTable { const out: LuaTable = {}; i++; // consume { for (;;) { skip(); if (i >= text.length) break; if (text[i] === "}") { i++; break; } if (text[i] === "," || text[i] === ";") { i++; continue; } const keyStart = i; while (i < text.length && /[A-Za-z0-9_]/.test(text[i])) i++; const key = text.slice(keyStart, i); skip(); // Anything that is not `key = value` is malformed. Step over one // character rather than spinning forever on it. if (key === "" || text[i] !== "=") { i++; continue; } i++; // consume = const value = parseValue(); if (value !== undefined) out[key] = value; } return out; } return parseTable(); } export function renderSandboxLua(server: Server, existing: LuaTable = {}): string { // Precedence, lowest first: shipped defaults, whatever is already on disk, // then explicit panel overrides — the same order the .ini uses. Reading the // file back matters because the in-game admin sandbox editor writes here, // and regenerating from scratch would revert every change on restart. const merged = mergeTables( mergeTables(SANDBOX_DEFAULTS as LuaTable, existing), (server.sandbox ?? {}) as LuaTable, ); const body = Object.entries(merged) .map(([key, value]) => ` ${key} = ${luaValue(value, " ")},`) .join("\n"); return `SandboxVars = {\n${body}\n}\n`; } /** * Writes the config files into the server's data directory. Must run before the * container starts, otherwise PZ generates its own defaults and the panel's * settings are silently ignored until the next restart. */ export async function writeServerConfig(server: Server): Promise { const root = serverDataPath(server.slug); const dir = `${root}/Server`; await mkdir(dir, { recursive: true }); // Read the current files first. `changeoption` and the in-game admin panel // both write here, and regenerating from scratch would discard their work. let existing: Record = {}; try { existing = parseIni(await readFile(`${dir}/${server.slug}.ini`, "utf8")); } catch { // First run — nothing to preserve. } let existingSandbox: Record = {}; try { existingSandbox = parseSandboxLua( await readFile(`${dir}/${server.slug}_SandboxVars.lua`, "utf8"), ); } catch { // First run — nothing to preserve. } // The server writes saves and logs into the root, not just Server/. await grantToServer(root); await grantToServer(dir); const files: Array<[string, string]> = [ [`${dir}/${server.slug}.ini`, renderServerIni(server, existing)], [`${dir}/${server.slug}_SandboxVars.lua`, renderSandboxLua(server, existingSandbox)], ]; for (const [path, contents] of files) { await writeFile(path, contents, "utf8"); await grantToServer(path); } const spawnRegions = `${dir}/${server.slug}_spawnregions.lua`; await writeFile(spawnRegions, renderSpawnRegions(server), "utf8"); await grantToServer(spawnRegions); } /** * Spawn regions decide which maps new players may spawn on. Every map in the * load order gets an entry, so adding a map mod actually lets people start * there rather than only visit. * * Caveat: a map mod that ships no spawnpoints.lua will log a missing-file * warning. Remove it from the Map folders field if that happens. */ export function renderSpawnRegions(server: Server): string { const entries = mapLoadOrder(server) .map((name) => { const escaped = name.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); return ` { name = "${escaped}", file = "media/maps/${escaped}/spawnpoints.lua" },`; }) .join("\n"); return `function SpawnRegions()\n return {\n${entries}\n }\nend\n`; }