mirror of
https://github.com/gwdevhub/Daybreak.git
synced 2026-07-15 15:19:57 +00:00
089a10a056
* Reorganize skills * Minor fixes
240 lines
7.9 KiB
C#
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<Skill></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();
|
|
}
|
|
}
|