namespace Aeshnidae.DevKit;
///
/// /submit: turns what a developer changed on staging into the two files the change
/// ledger wants - a change .sql in the _template.sql shape and a .patchnote.md
/// skeleton - and hands them to the person who applies changes, via Discord.
///
/// The change file replaces each weenie whole (DELETE + INSERT, exactly what
/// /export-sql writes and what the ledger's before/ snapshots are), with the
/// difference against live written into its header as comments so a reviewer can see
/// what the replacement actually changes without diffing 400 lines of INSERT.
///
/// Nothing here touches the live server. The files are saved beside the content
/// folder and posted to #content; applying them is a person's decision, made with
/// push-content.sh, which snapshots, applies, refreshes the cache and tells players.
///
internal static class Submit
{
public sealed record Request(string Slug, List Wcids, List Landblocks, string Why, string Author);
public sealed record Output(string BaseName, byte[] ChangeSql, byte[] PatchNote, List Summary, List Warnings);
private static readonly Regex SlugRx = new("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.Compiled);
public static bool ValidSlug(string slug) => slug.Length is >= 3 and <= 60 && SlugRx.IsMatch(slug);
public static Output Build(Request req)
{
var settings = Mod.Settings;
var today = DateTime.Now.ToString("yyyy-MM-dd");
var title = Title(req.Slug);
var summary = new List();
var warnings = new List();
var header = new StringBuilder();
var body = new StringBuilder();
var noteValues = new StringBuilder();
header.AppendLine($"-- Title: {title}");
header.AppendLine($"-- Wcids: {string.Join(", ", req.Wcids)}");
if (req.Landblocks.Count > 0)
header.AppendLine($"-- Landblocks: {string.Join(", ", req.Landblocks.Select(l => l.ToString("X4")))}");
header.AppendLine($"-- Why: {(string.IsNullOrWhiteSpace(req.Why) ? "" : Indent(req.Why))}");
header.AppendLine($"-- Author: {req.Author} (via /submit on {settings.Label})");
header.AppendLine($"-- Date: {today}");
header.AppendLine("--");
header.AppendLine("-- HOW. Submitted from staging with /submit. Each weenie below is a full replacement");
header.AppendLine("-- (DELETE + INSERT, as /export-sql writes it), which is also the shape of the");
header.AppendLine("-- before/ snapshot the apply script takes. What that replacement changes against");
header.AppendLine($"-- live, as of this submission{(settings.HasBase ? "" : " (NO BASE SCHEMA - diff unavailable)")}:");
foreach (var wcid in req.Wcids)
{
var export = Exporter.Export(wcid.ToString());
if (export is null)
{
warnings.Add($"{wcid}: no such weenie on {settings.Label} - skipped");
continue;
}
var name = Names.Weenie(wcid);
header.AppendLine($"-- {wcid} {name}:");
if (settings.HasBase)
{
var diff = Diff.Weenie(wcid);
if (!diff.Changed)
{
warnings.Add($"{wcid} {name} is identical to live - included anyway, but the change file will replace it with itself");
header.AppendLine("-- (identical to live)");
}
foreach (var line in diff.Lines)
{
header.AppendLine($"-- {line}");
noteValues.AppendLine(line);
}
summary.Add($"{wcid} {name}: {diff.Lines.Count} change line(s)");
}
else
{
summary.Add($"{wcid} {name}: exported ({export.Lines} lines)");
}
body.AppendLine();
body.AppendLine($"-- ---- {wcid} {name} ------------------------------------------------------------");
body.AppendLine(Exporter.Utf8NoBom.GetString(export.Sql).TrimEnd());
}
foreach (var lb in req.Landblocks)
{
var export = Exporter.ExportLandblock(lb);
header.AppendLine($"-- landblock {lb:X4}:");
if (settings.HasBase)
{
var diff = Diff.Landblock(lb);
if (!diff.Changed)
{
warnings.Add($"landblock {lb:X4} is identical to live - included anyway");
header.AppendLine("-- (identical to live)");
}
foreach (var line in diff.Lines)
{
header.AppendLine($"-- {line}");
noteValues.AppendLine(line);
}
summary.Add($"landblock {lb:X4}: {diff.Lines.Count} change line(s), {export?.Instances ?? 0} instance(s) in total");
}
else
{
summary.Add($"landblock {lb:X4}: {export?.Instances ?? 0} instance(s)");
}
body.AppendLine();
body.AppendLine($"-- ---- landblock {lb:X4} ({export?.Instances ?? 0} instances) -------------------------------------");
if (export is null)
{
// The landblock has no instances at all now: the change is "remove them all".
body.AppendLine($"DELETE FROM `landblock_instance` WHERE `landblock` = 0x{lb:X4};");
}
else
{
body.AppendLine(Exporter.Utf8NoBom.GetString(export.Sql).TrimEnd());
}
}
header.AppendLine("--");
header.AppendLine("-- Everything below runs in one transaction. Afterwards: /clearweenie ;");
header.AppendLine("-- landblock placements need /reload-landblock while standing in the landblock, or");
header.AppendLine("-- wait for it to unload.");
header.AppendLine();
header.AppendLine("USE aeshnidae_world;");
var sql = header.ToString() + body.ToString().TrimEnd() + "\n";
var note = new StringBuilder();
note.AppendLine($"**{title}**");
note.AppendLine();
note.AppendLine("");
note.AppendLine();
note.AppendLine($"**Why:** {(string.IsNullOrWhiteSpace(req.Why) ? "" : req.Why.Trim())}");
note.AppendLine();
note.AppendLine("**New values** (previous in brackets):");
note.AppendLine("```");
note.Append(noteValues.Length > 0 ? noteValues.ToString() : "\n");
note.AppendLine("```");
note.AppendLine();
note.AppendLine("**Unchanged:** ");
var baseName = $"{today}_NNN_{req.Slug}";
return new Output(baseName,
Exporter.Utf8NoBom.GetBytes(sql),
Exporter.Utf8NoBom.GetBytes(note.ToString()),
summary, warnings);
}
/// Where submissions are kept on disk: Settings.SubmitDirectory, else <content_folder>/submitted.
public static string Directory()
{
var dir = Mod.Settings.SubmitDirectory;
if (string.IsNullOrWhiteSpace(dir))
{
var content = PropertyManager.GetString("content_folder").Item ?? "Content";
if (content.StartsWith('.'))
content = Path.Combine(Environment.CurrentDirectory, content);
dir = Path.Combine(content, "submitted");
}
System.IO.Directory.CreateDirectory(dir);
return dir;
}
/// Saves both files; returns the paths. A name clash gets a numeric suffix rather than an overwrite.
public static (string sql, string note) Save(Output output)
{
var dir = Directory();
var name = output.BaseName;
var n = 1;
while (File.Exists(Path.Combine(dir, name + ".sql")))
name = $"{output.BaseName}-{++n}";
var sqlPath = Path.Combine(dir, name + ".sql");
var notePath = Path.Combine(dir, name + ".patchnote.md");
File.WriteAllBytes(sqlPath, output.ChangeSql);
File.WriteAllBytes(notePath, output.PatchNote);
return (sqlPath, notePath);
}
private static string Title(string slug)
{
var words = slug.Split('-', StringSplitOptions.RemoveEmptyEntries)
.Select(w => char.ToUpperInvariant(w[0]) + w[1..]);
return string.Join(' ', words);
}
private static string Indent(string text) =>
string.Join("\n-- ", text.Trim().Split('\n').Select(l => l.TrimEnd()));
}