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;
}
}