using System.Text.RegularExpressions; using BrassAndSigil.Server.Models; namespace BrassAndSigil.Server.Services; /// /// Destructive world operations -- wipe and replace. Always single-flight, always /// stops the server first, and offers a rename-as-backup default so an accidental /// click doesn't lose data permanently. /// public sealed class WorldService { private readonly ServerConfig _config; private readonly ServerProcess _proc; private readonly BackupService _backup; private readonly Broadcaster _broadcast; private readonly RconManager _rcon; private readonly BlueMapService? _bluemap; private readonly Action _log; private readonly SemaphoreSlim _lock = new(1, 1); public WorldService(ServerConfig config, ServerProcess proc, BackupService backup, Broadcaster broadcast, RconManager rcon, Action log, BlueMapService? bluemap = null) { _config = config; _proc = proc; _backup = backup; _broadcast = broadcast; _rcon = rcon; _bluemap = bluemap; _log = log; } public sealed record WipeResult(bool Ok, string? BackupName, string? SeedUsed, string? Error); /// /// What seed strategy a wipe should use: /// /// Keep -- capture the live seed via RCON before wipe and reuse it. /// Random -- clear level-seed so MC picks a fresh random one. /// Custom -- set level-seed to . /// /// public enum SeedMode { Keep, Random, Custom } public sealed record WipeOptions(bool Backup, SeedMode Mode, string? CustomSeed); /// /// Best-effort lookup of the current world seed. Prefers RCON's seed /// command (always returns the actual generated seed even when /// server.properties has level-seed empty); falls back to the configured /// level-seed value if RCON is unavailable (server stopped, no MC, etc.). /// Returns null when neither is available. /// public async Task GetCurrentSeedAsync(CancellationToken ct = default) { if (_proc.IsRunning) { try { var resp = await _rcon.SendCommandAsync("seed", ct); // Format: "Seed: []" var m = Regex.Match(resp, @"Seed:\s*\[(-?\d+)\]"); if (m.Success) return m.Groups[1].Value; } catch { /* fall through to properties */ } } return new ServerPropertiesService(_config).GetLevelSeed(); } // Caching world size: scanning a large world dir is O(file count). 30 s cache // dampens that to roughly once per status poll cycle on busy panels. private long _cachedSize; private DateTime _cachedAt = DateTime.MinValue; public long GetWorldSizeBytes() { if ((DateTime.UtcNow - _cachedAt) < TimeSpan.FromSeconds(30)) return _cachedSize; var levelName = ReadLevelName(_config.ServerDir) ?? "world"; var worldDir = Path.Combine(Path.GetFullPath(_config.ServerDir), levelName); if (!Directory.Exists(worldDir)) { _cachedSize = 0; _cachedAt = DateTime.UtcNow; return 0; } long total = 0; try { foreach (var f in Directory.EnumerateFiles(worldDir, "*", SearchOption.AllDirectories)) { try { total += new FileInfo(f).Length; } catch { } } } catch { } _cachedSize = total; _cachedAt = DateTime.UtcNow; return total; } /// /// Stop the server, optionally archive the world via BackupService, delete the /// world folder(s), apply the chosen seed strategy, then restart. Backups go /// to the configured backup dir (typically a slower-but-bigger drive) rather /// than next to the world dir. /// public async Task WipeWorldAsync(WipeOptions options, int warningSeconds = 30, CancellationToken ct = default) { if (!await _lock.WaitAsync(0, ct)) return new WipeResult(false, null, null, "Another wipe is already in progress."); try { var serverDir = Path.GetFullPath(_config.ServerDir); var levelName = ReadLevelName(serverDir) ?? "world"; var primaryWorld = Path.Combine(serverDir, levelName); var props = new ServerPropertiesService(_config); // ── Decide what seed the new world will use, BEFORE we stop the server ── // Keep mode needs RCON, which only works while MC is alive. string seedToWrite; // value for level-seed= line; "" means random string? capturedSeed = null; switch (options.Mode) { case SeedMode.Keep: capturedSeed = await GetCurrentSeedAsync(ct); if (string.IsNullOrEmpty(capturedSeed)) { return new WipeResult(false, null, null, "Couldn't read current seed (RCON unreachable + level-seed empty). Stop the server, set level-seed manually, or pick Random/Custom."); } seedToWrite = capturedSeed; _log($"[wipe] Keep-seed mode: captured current seed {capturedSeed} for reuse."); break; case SeedMode.Custom: var custom = options.CustomSeed?.Trim() ?? ""; if (string.IsNullOrEmpty(custom)) return new WipeResult(false, null, null, "Custom seed mode selected but no seed provided."); seedToWrite = custom; _log($"[wipe] Custom-seed mode: new world will use seed {custom}."); break; default: // Random seedToWrite = ""; _log("[wipe] Random-seed mode: clearing level-seed so MC generates a fresh seed."); break; } // Loud, urgent player warning before any irreversible action -- wipe is // destructive even with a backup (admins still need to restore manually). if (warningSeconds > 0 && _proc.IsRunning) { _log($"[wipe] Announcing {warningSeconds}s wipe warning to players..."); try { await _broadcast.SayAsync( $"WORLD WIPE in {warningSeconds} seconds. Disconnect now if you want to keep your current world!", ct); await _broadcast.ActionBarCountdownAsync("WORLD WIPING", warningSeconds, ct); await _broadcast.SayAsync("Wiping world now."); } catch (OperationCanceledException) { throw; } catch { /* don't abort wipe over a broadcast error */ } } string? backupName = null; if (options.Backup && Directory.Exists(primaryWorld)) { _log("[wipe] Creating backup before wipe..."); // flush:true here -- we're about to delete the world. Capture every block // and every move up to right now, even at the cost of a tick spike. var br = await _backup.CreateAsync("pre-wipe", flush: true, ct: ct); if (!br.Ok) return new WipeResult(false, null, null, $"Backup failed: {br.Error}. Wipe aborted to preserve data."); backupName = br.Name; } if (_proc.IsRunning) { _log("[wipe] Stopping server..."); await _proc.StopAsync(TimeSpan.FromSeconds(30), ct); } if (Directory.Exists(primaryWorld)) { _log($"[wipe] Deleting {primaryWorld}"); Directory.Delete(primaryWorld, recursive: true); } // Legacy sibling dirs (rare on modern NeoForge but cheap to handle) foreach (var altSuffix in new[] { "_nether", "_the_end" }) { var altDir = Path.Combine(serverDir, levelName + altSuffix); if (!Directory.Exists(altDir)) continue; _log($"[wipe] Deleting {altDir}"); Directory.Delete(altDir, recursive: true); } // Apply the seed AFTER deletion but BEFORE restart -- MC reads // server.properties at startup to determine the new world's seed. props.SetLevelSeed(seedToWrite); // Map output for the now-deleted world is stale -- clear it so the next // render starts fresh against the new terrain. _bluemap?.ClearRenderOutput(); _log("[wipe] World wiped. Restarting server -- Minecraft will generate a fresh world."); _proc.Start(); // For Random mode we don't know the exact new seed yet (MC picks at startup); // return the level-seed value we wrote, which is "" for random. return new WipeResult(true, backupName, string.IsNullOrEmpty(seedToWrite) ? null : seedToWrite, null); } catch (Exception ex) { _log($"[wipe] Failed: {ex.Message}"); try { if (!_proc.IsRunning) _proc.Start(); } catch { } return new WipeResult(false, null, null, ex.Message); } finally { _lock.Release(); } } private static string? ReadLevelName(string serverDir) { var path = Path.Combine(serverDir, "server.properties"); if (!File.Exists(path)) return null; foreach (var line in File.ReadAllLines(path)) { if (line.StartsWith("level-name=", StringComparison.Ordinal)) return line.Substring("level-name=".Length).Trim(); } return null; } }