a1331212cb
Self-hosted Minecraft modpack distribution + administration system.
- launcher/ Avalonia 12 desktop client; single-file win-x64 publish.
Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
portable install path, sidecar settings.
- server/ brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
MC subprocess, embedded Kestrel admin panel with cookie
auth + rate limiting, RCON bridge, scheduled backups,
BlueMap CLI integration with player markers + skin proxy,
friend-side whitelist request flow, world wipe with seed
selection (keep current / random / custom).
- pack/ pack.lock.json (Modrinth + manual CurseForge entries),
data-only tweak source under tweaks/, build outputs in
overrides/ (gitignored).
- scripts/ Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
plus Deploy-Brass.ps1 unified one-shot deploy with
version-bump pre-flight and daemon-state detection.
230 lines
9.9 KiB
C#
230 lines
9.9 KiB
C#
using System.Text.RegularExpressions;
|
|
using BrassAndSigil.Server.Models;
|
|
|
|
namespace BrassAndSigil.Server.Services;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string> _log;
|
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
|
|
|
public WorldService(ServerConfig config, ServerProcess proc, BackupService backup,
|
|
Broadcaster broadcast, RconManager rcon, Action<string> 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);
|
|
|
|
/// <summary>
|
|
/// What seed strategy a wipe should use:
|
|
/// <list type="bullet">
|
|
/// <item><c>Keep</c> -- capture the live seed via RCON before wipe and reuse it.</item>
|
|
/// <item><c>Random</c> -- clear <c>level-seed</c> so MC picks a fresh random one.</item>
|
|
/// <item><c>Custom</c> -- set <c>level-seed</c> to <see cref="WipeOptions.CustomSeed"/>.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
public enum SeedMode { Keep, Random, Custom }
|
|
|
|
public sealed record WipeOptions(bool Backup, SeedMode Mode, string? CustomSeed);
|
|
|
|
/// <summary>
|
|
/// Best-effort lookup of the current world seed. Prefers RCON's <c>seed</c>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<string?> GetCurrentSeedAsync(CancellationToken ct = default)
|
|
{
|
|
if (_proc.IsRunning)
|
|
{
|
|
try
|
|
{
|
|
var resp = await _rcon.SendCommandAsync("seed", ct);
|
|
// Format: "Seed: [<number>]"
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<WipeResult> 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;
|
|
}
|
|
}
|