Files
Daybreak/Tools/SkillUpdater/SkillFileWriter.cs
T
amacocian 089a10a056 Reorganize skills (#1541)
* Reorganize skills

* Minor fixes
2026-06-03 22:09:58 +02:00

240 lines
7.9 KiB
C#

using System.Globalization;
using System.Text;
namespace Daybreak.Tools.SkillUpdater;
/// <summary>
/// Renders the wiki-derived skill set into <c>Skill.g.cs</c>: one
/// <c>public static readonly Skill X = new() {…};</c> per skill, then
/// per-campaign <c>IReadOnlyList&lt;Skill&gt;</c> collections, then
/// <c>AllSkills</c>. Strictly emit-only — no model objects required.
/// </summary>
internal static class SkillFileWriter
{
private const string Header = """
// <auto-generated>
// Generated by Tools/SkillUpdater. Do not edit manually — re-run
// `dotnet run --project Tools/SkillUpdater` to refresh from the wiki.
// </auto-generated>
#nullable enable
using Daybreak.Shared.Models.Guildwars;
using Attribute = Daybreak.Shared.Models.Guildwars.Attribute;
namespace Daybreak.Shared.Models.Guildwars;
public sealed partial class Skill
{
""";
private const string Footer = "}\n";
public static (string Content, IReadOnlyList<string> Warnings) Render(
IEnumerable<ParsedSkill> skills,
IReadOnlyDictionary<string, string> iconUrls)
{
// Expand skills carrying multiple ids (e.g. "Save Yourselves!" with
// 1954 Luxon + 2097 Kurzick) into one emitted entry per id.
var expanded = new List<ParsedSkill>();
foreach (var skill in skills)
{
expanded.Add(skill);
for (var i = 0; i < skill.AdditionalIds.Count; i++)
{
expanded.Add(skill with
{
Id = skill.AdditionalIds[i],
AdditionalIds = [],
});
}
}
// Stable ordering: by id ascending so identifier collisions get resolved
// deterministically (lower id keeps the bare identifier).
var ordered = expanded
.OrderBy(s => s.Id)
.ThenBy(s => s.Name, StringComparer.Ordinal)
.ToList();
var seenIdentifier = new Dictionary<string, int>(StringComparer.Ordinal);
var rendered = new List<(string Identifier, ParsedSkill Skill)>(ordered.Count);
var warnings = new List<string>();
foreach (var entry in ordered)
{
var ident = ToIdentifier(entry.Name);
if (seenIdentifier.TryGetValue(ident, out var count))
{
seenIdentifier[ident] = count + 1;
var unique = $"{ident}_{count + 1}";
warnings.Add($"identifier collision: {entry.Name} (id {entry.Id}) → {unique}");
rendered.Add((unique, entry));
}
else
{
seenIdentifier[ident] = 1;
rendered.Add((ident, entry));
}
}
var sb = new StringBuilder(capacity: 1 << 20);
sb.Append(Header);
foreach (var (ident, skill) in rendered.OrderBy(r => r.Identifier, StringComparer.Ordinal))
{
sb.Append(" ").Append(RenderField(ident, skill, iconUrls)).Append('\n');
}
sb.Append('\n');
foreach (var (campaignIdentifier, listName) in CampaignListNames)
{
var members = rendered
.Where(r => r.Skill.CampaignIdentifier == campaignIdentifier)
.OrderBy(r => r.Identifier, StringComparer.Ordinal)
.Select(r => r.Identifier)
.ToList();
EmitList(sb, listName, members);
}
sb.Append('\n');
EmitList(sb, "AllSkills",
rendered.Select(r => r.Identifier).OrderBy(i => i, StringComparer.Ordinal).Prepend("None").ToList());
sb.Append(Footer);
return (sb.ToString(), warnings);
}
private static readonly (string CampaignIdentifier, string ListName)[] CampaignListNames =
[
("Core", "CoreSkills"),
("Prophecies", "PropheciesSkills"),
("Factions", "FactionsSkills"),
("Nightfall", "NightfallSkills"),
("EyeOfTheNorth", "EyeOfTheNorthSkills"),
("BonusMissionPack", "BonusMissionPackSkills"),
];
private static void EmitList(StringBuilder sb, string name, IReadOnlyList<string> identifiers)
{
if (identifiers.Count == 0)
{
sb.Append(" public static readonly IReadOnlyList<Skill> ").Append(name).Append(" = [];\n");
return;
}
sb.Append(" public static readonly IReadOnlyList<Skill> ").Append(name).Append(" =\n");
sb.Append(" [\n");
foreach (var ident in identifiers)
{
sb.Append(" ").Append(ident).Append(",\n");
}
sb.Append(" ];\n");
}
private static string RenderField(string identifier, ParsedSkill s, IReadOnlyDictionary<string, string> iconUrls)
{
var icon = iconUrls.GetValueOrDefault(s.Name, string.Empty);
return $"public static readonly Skill {identifier} = new() {{ "
+ $"Id = {s.Id.ToString(CultureInfo.InvariantCulture)}, "
+ $"Name = {Quote(s.Name)}, "
+ $"Elite = {Bool(s.Elite)}, "
+ $"PvEOnly = {Bool(s.PvEOnly)}, "
+ $"PvP = {Bool(s.PvP)}, "
+ $"Campaign = Campaign.{s.CampaignIdentifier}, "
+ $"Profession = Profession.{s.ProfessionIdentifier}, "
+ $"Attribute = Attribute.{s.AttributeIdentifier}, "
+ $"Type = {s.TypeExpression}, "
+ $"Energy = {Number(s.Energy)}, "
+ $"Activation = {Number(s.Activation)}, "
+ $"Recharge = {Number(s.Recharge)}, "
+ $"Overcast = {Number(s.Overcast)}, "
+ $"Adrenaline = {Number(s.Adrenaline)}, "
+ $"Sacrifice = {Number(s.Sacrifice)}, "
+ $"Upkeep = {Number(s.Upkeep)}, "
+ $"Description = {Quote(s.Description)}, "
+ $"ConciseDescription = {Quote(s.ConciseDescription)}, "
+ $"IconUrl = {Quote(icon)}"
+ " };";
}
private static string Bool(bool value) => value ? "true" : "false";
private static string Number(double? value)
{
if (value is not double d)
{
return "null";
}
return d == Math.Floor(d) && !double.IsInfinity(d)
? ((long)d).ToString(CultureInfo.InvariantCulture)
: d.ToString("G17", CultureInfo.InvariantCulture);
}
private static string Quote(string? value)
{
if (value is null)
{
return "\"\"";
}
var sb = new StringBuilder(value.Length + 2);
sb.Append('"');
foreach (var c in value)
{
switch (c)
{
case '\\': sb.Append("\\\\"); break;
case '"': sb.Append("\\\""); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default:
if (c < 0x20)
{
sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));
}
else
{
sb.Append(c);
}
break;
}
}
sb.Append('"');
return sb.ToString();
}
/// <summary>
/// Sanitises a skill display name into a C# identifier matching the
/// existing Daybreak convention: strip every non-alphanumeric character,
/// preserve original casing. PvP variants therefore become <c>FooPvP</c>.
/// </summary>
public static string ToIdentifier(string name)
{
var sb = new StringBuilder(name.Length);
foreach (var c in name)
{
if (char.IsLetterOrDigit(c))
{
sb.Append(c);
}
}
if (sb.Length == 0)
{
return "_";
}
if (char.IsDigit(sb[0]))
{
sb.Insert(0, '_');
}
return sb.ToString();
}
}