mirror of
https://github.com/gwdevhub/Daybreak.git
synced 2026-07-15 15:19:57 +00:00
@@ -50,7 +50,7 @@ public static unsafe partial class GWCA
|
||||
// [NAMESPACE] GWCA.GW.Packet.StoC.GenericValueID popped at line 70
|
||||
// [NAMESPACE] GWCA.GW.Packet.StoC.JumboMessageType popped at line 82
|
||||
// [NAMESPACE] GWCA.GW.Packet.StoC.JumboMessageValue popped at line 99
|
||||
// [NAMESPACE] GWCA.GW.PartyMgr popped at line 107
|
||||
// [NAMESPACE] GWCA.GW.PartyMgr popped at line 108
|
||||
// [NAMESPACE] GWCA.GW.PlayerMgr popped at line 56
|
||||
// [NAMESPACE] GWCA.GW.QuestMgr popped at line 43
|
||||
// [NAMESPACE] GWCA.GW.Render popped at line 122
|
||||
@@ -118,7 +118,7 @@ public static unsafe partial class GWCA
|
||||
// GWCA.GW.Inventory: 39 fields [OK]
|
||||
// GWCA.GW.InventoryTableEntry: 3 fields [OK]
|
||||
// GWCA.GW.Item: 26 fields [OK]
|
||||
// GWCA.GW.ItemContext: 43 fields [OK]
|
||||
// GWCA.GW.ItemContext: 38 fields [OK]
|
||||
// GWCA.GW.ItemData: 4 fields [OK]
|
||||
// GWCA.GW.ItemFormula: 5 fields [OK]
|
||||
// GWCA.GW.ItemModifier: 1 fields [OK]
|
||||
@@ -3873,7 +3873,7 @@ public static unsafe partial class GWCA
|
||||
Test_Map_solo_areas, // "This is a debug map to allow multiplayer testing of current solo areas."
|
||||
Nightfallen_Garden,
|
||||
Churrhir_Fields,
|
||||
Beknur_Harbor_outpost,
|
||||
Beknur_Harbor,
|
||||
Kodonur_Crossroads_cinematic,
|
||||
Rilohn_Refuge_cinematic,
|
||||
Pogahn_Passage_cinematic,
|
||||
@@ -3903,7 +3903,7 @@ public static unsafe partial class GWCA
|
||||
Lahtenda_Bog,
|
||||
Arbor_Bay,
|
||||
Issnur_Isles,
|
||||
Beknur_Harbor,
|
||||
Beknur_Harbor_outpost,
|
||||
Mehtani_Keys,
|
||||
Kodlonu_Hamlet_outpost,
|
||||
Island_of_Shehkah,
|
||||
@@ -6825,9 +6825,9 @@ public static unsafe partial class GWCA
|
||||
Aura_of_the_Bloodstone1,
|
||||
Aura_of_the_Bloodstone2,
|
||||
Haunted_Ground,
|
||||
Asuran_Bodyguard,
|
||||
Asuran_Bodyguard1,
|
||||
Asuran_Bodyguard2,
|
||||
Asuran_Bodyguard_Rank2,
|
||||
Asuran_Bodyguard_Rank3,
|
||||
Asuran_Bodyguard_Rank4,
|
||||
Energy_Channel,
|
||||
Hunt_Rampage,
|
||||
Boss_Bounty = 2440,
|
||||
@@ -6836,9 +6836,9 @@ public static unsafe partial class GWCA
|
||||
Hunt_Point_Bonus2,
|
||||
Time_Attack,
|
||||
Dwarven_Raider,
|
||||
Dwarven_Raider1,
|
||||
Dwarven_Raider2,
|
||||
Dwarven_Raider3,
|
||||
Dwarven_Raider_Rank2,
|
||||
Dwarven_Raider_Rank3,
|
||||
Dwarven_Raider_Rank4,
|
||||
Great_Dwarfs_Blessing,
|
||||
Hunt_Rampage1,
|
||||
Boss_Bounty1 = 2452,
|
||||
@@ -6846,19 +6846,19 @@ public static unsafe partial class GWCA
|
||||
Hunt_Point_Bonus4,
|
||||
Time_Attack1 = 2456,
|
||||
Vanguard_Patrol,
|
||||
Vanguard_Patrol1,
|
||||
Vanguard_Patrol2,
|
||||
Vanguard_Patrol3,
|
||||
Vanguard_Patrol_Rank2,
|
||||
Vanguard_Patrol_Rank3,
|
||||
Vanguard_Patrol_Rank4,
|
||||
Vanguard_Commendation,
|
||||
Hunt_Rampage2,
|
||||
Boss_Bounty2 = 2464,
|
||||
Norn_Hunting_Party = 2469,
|
||||
Norn_Hunting_Party1,
|
||||
Norn_Hunting_Party2,
|
||||
Norn_Hunting_Party3,
|
||||
Norn_Hunting_Party_Rank2,
|
||||
Norn_Hunting_Party_Rank3,
|
||||
Norn_Hunting_Party_Rank4,
|
||||
Strength_of_the_Norn,
|
||||
Hunt_Rampage3,
|
||||
Asuran_Bodyguard3 = 2481,
|
||||
Asuran_Bodyguard = 2481,
|
||||
Desperate_Howl,
|
||||
Gloat,
|
||||
Metamorphosis,
|
||||
@@ -9302,45 +9302,47 @@ public static unsafe partial class GWCA
|
||||
kShowHeroPanel, // 0x100001a3, wparam = hero_id
|
||||
kMessage_0x1000019e, // 0x100001a4
|
||||
kMessage_0x1000019f, // 0x100001a5
|
||||
kMessage_0x100001a0, // 0x100001a6
|
||||
kQuerySuppressedKeyAction, // 0x100001a6, wparam = 0, lparam = bool*. Used to check if the suppress action is pressed (usually shift)
|
||||
kGetInventoryAgentId, // 0x100001a7, wparam = 0, lparam = uint32_t* agent_id_out. Used to fetch which agent is selected
|
||||
kInventoryRelated1, // 0x100001a8, added to GW 2026-02-26
|
||||
kInventoryRelated2, // 0x100001a9, added to GW 2026-02-26
|
||||
kInventoryRelated3, // 0x100001aa, added to GW 2026-02-26
|
||||
kEquipItem, // 0x100001ab, wparam = { item_id, agent_id }
|
||||
kMoveItem, // 0x100001ac, wparam = { item_id, to_bag, to_slot, bool prompt }
|
||||
kItemRelated_1, // 0x100001ad
|
||||
kItemTooltip, // 0x100001ae
|
||||
kItemRelated_3, // 0x100001af, added to GW 2026-02-26
|
||||
kItemRelated_4, // 0x100001b0, added to GW 2026-02-26
|
||||
kInitiateTrade, // 0x100001b1
|
||||
kMessage_0x100001a7, // 0x100001b2
|
||||
kMessage_0x100001a8, // 0x100001b3
|
||||
kMessage_0x100001a9, // 0x100001b4
|
||||
kMessage_0x100001aa, // 0x100001b5
|
||||
kMessage_0x100001ab, // 0x100001b6
|
||||
kMessage_0x100001ac, // 0x100001b7
|
||||
kMessage_0x100001ad, // 0x100001b8
|
||||
kMessage_0x100001ae, // 0x100001b9
|
||||
kMessage_0x100001af, // 0x100001ba
|
||||
kMessage_0x100001b0, // 0x100001bb
|
||||
kMessage_0x100001b1, // 0x100001bc
|
||||
kMessage_0x100001b2, // 0x100001bd
|
||||
kMessage_0x100001b3, // 0x100001be
|
||||
kMessage_0x100001b4, // 0x100001bf
|
||||
kMessage_0x100001b5, // 0x100001c0
|
||||
kInventoryAgentChanged, // 0x100001c1, Triggered when inventory needs updating due to agent change; no args
|
||||
kInventoryRelated_1, // 0x100001c2
|
||||
kInventoryRelated_2, // 0x100001c3
|
||||
kMissionStatusRelated, // 0x100001c4
|
||||
kUnused_1c2, // 0x100001c5
|
||||
kCollapseExpandSkillListSection, // 0x100001c6
|
||||
kTemplateRelated_1, // 0x100001c7
|
||||
kTemplateRelated_2, // 0x100001c8
|
||||
kPromptSaveTemplate, // 0x100001c9
|
||||
kOpenTemplate, // 0x100001ca, wparam = GW::UI::ChatTemplate*
|
||||
kTemplateRelated_3, // 0x100001cb
|
||||
kTemplateRelated_4, // 0x100001cc
|
||||
kInventoryRelated4, // 0x100001ab, added to GW 2026-04-28
|
||||
kEquipItem, // 0x100001ac, wparam = { item_id, agent_id }
|
||||
kMoveItem, // 0x100001ad, wparam = { item_id, to_bag, to_slot, bool prompt }
|
||||
kItemRelated_1, // 0x100001ae
|
||||
kItemTooltip, // 0x100001af
|
||||
kItemRelated_3, // 0x100001b0, added to GW 2026-02-26
|
||||
kItemRelated_4, // 0x100001b1, added to GW 2026-02-26
|
||||
kItemRelated_5, // 0x100001b2, added to GW 2026-04-28
|
||||
kInitiateTrade, // 0x100001b3
|
||||
kMessage_0x100001a7, // 0x100001b4
|
||||
kMessage_0x100001a8, // 0x100001b5
|
||||
kMessage_0x100001a9, // 0x100001b6
|
||||
kMessage_0x100001aa, // 0x100001b7
|
||||
kMessage_0x100001ab, // 0x100001b8
|
||||
kMessage_0x100001ac, // 0x100001b9
|
||||
kMessage_0x100001ad, // 0x100001ba
|
||||
kMessage_0x100001ae, // 0x100001bb
|
||||
kMessage_0x100001af, // 0x100001bc
|
||||
kMessage_0x100001b0, // 0x100001bd
|
||||
kMessage_0x100001b1, // 0x100001be
|
||||
kMessage_0x100001b2, // 0x100001bf
|
||||
kMessage_0x100001b3, // 0x100001c0
|
||||
kMessage_0x100001b4, // 0x100001c1
|
||||
kMessage_0x100001b5, // 0x100001c2
|
||||
kInventoryAgentChanged, // 0x100001c3, Triggered when inventory needs updating due to agent change; no args
|
||||
kInventoryRelated_1, // 0x100001c4
|
||||
kInventoryRelated_2, // 0x100001c5
|
||||
kMissionStatusRelated, // 0x100001c6
|
||||
kUnused_1c2, // 0x100001c7
|
||||
kCollapseExpandSkillListSection, // 0x100001c8
|
||||
kTemplateRelated_1, // 0x100001c9
|
||||
kTemplateRelated_2, // 0x100001ca
|
||||
kPromptSaveTemplate, // 0x100001cb
|
||||
kOpenTemplate, // 0x100001cc, wparam = GW::UI::ChatTemplate*
|
||||
kTemplateRelated_3, // 0x100001cd
|
||||
kTemplateRelated_4, // 0x100001ce
|
||||
kSendLoadSkillTemplate = 0x30000000 | 0x3, // 0x30000003, wparam = SkillbarMgr::SkillTemplate*
|
||||
kSendPingWeaponSet = 0x30000000 | 0x4, // 0x30000004, wparam = UIPacket::kSendPingWeaponSet*
|
||||
kSendMoveItem = 0x30000000 | 0x5, // 0x30000005, wparam = UIPacket::kSendMoveItem*
|
||||
@@ -9448,6 +9450,14 @@ namespace Daybreak.API.Interop.GuildWars
|
||||
public nint NextNode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// THash<T> - hash table (36 bytes, opaque to managed code).
|
||||
/// </summary>
|
||||
[global::System.Runtime.InteropServices.StructLayout(global::System.Runtime.InteropServices.LayoutKind.Sequential, Pack = 1, Size = 0x24)]
|
||||
public struct THash
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline array of 54 AttributeStruct elements.
|
||||
/// </summary>
|
||||
@@ -10533,7 +10543,7 @@ namespace Daybreak.API.Interop.GuildWars
|
||||
Test_Map_solo_areas,
|
||||
Nightfallen_Garden,
|
||||
Churrhir_Fields,
|
||||
Beknur_Harbor_outpost,
|
||||
Beknur_Harbor,
|
||||
Kodonur_Crossroads_cinematic,
|
||||
Rilohn_Refuge_cinematic,
|
||||
Pogahn_Passage_cinematic,
|
||||
@@ -10563,7 +10573,7 @@ namespace Daybreak.API.Interop.GuildWars
|
||||
Lahtenda_Bog,
|
||||
Arbor_Bay,
|
||||
Issnur_Isles,
|
||||
Beknur_Harbor,
|
||||
Beknur_Harbor_outpost,
|
||||
Mehtani_Keys,
|
||||
Kodlonu_Hamlet_outpost,
|
||||
Island_of_Shehkah,
|
||||
@@ -13485,9 +13495,9 @@ namespace Daybreak.API.Interop.GuildWars
|
||||
Aura_of_the_Bloodstone1,
|
||||
Aura_of_the_Bloodstone2,
|
||||
Haunted_Ground,
|
||||
Asuran_Bodyguard,
|
||||
Asuran_Bodyguard1,
|
||||
Asuran_Bodyguard2,
|
||||
Asuran_Bodyguard_Rank2,
|
||||
Asuran_Bodyguard_Rank3,
|
||||
Asuran_Bodyguard_Rank4,
|
||||
Energy_Channel,
|
||||
Hunt_Rampage,
|
||||
Boss_Bounty = 2440,
|
||||
@@ -13496,9 +13506,9 @@ namespace Daybreak.API.Interop.GuildWars
|
||||
Hunt_Point_Bonus2,
|
||||
Time_Attack,
|
||||
Dwarven_Raider,
|
||||
Dwarven_Raider1,
|
||||
Dwarven_Raider2,
|
||||
Dwarven_Raider3,
|
||||
Dwarven_Raider_Rank2,
|
||||
Dwarven_Raider_Rank3,
|
||||
Dwarven_Raider_Rank4,
|
||||
Great_Dwarfs_Blessing,
|
||||
Hunt_Rampage1,
|
||||
Boss_Bounty1 = 2452,
|
||||
@@ -13506,19 +13516,19 @@ namespace Daybreak.API.Interop.GuildWars
|
||||
Hunt_Point_Bonus4,
|
||||
Time_Attack1 = 2456,
|
||||
Vanguard_Patrol,
|
||||
Vanguard_Patrol1,
|
||||
Vanguard_Patrol2,
|
||||
Vanguard_Patrol3,
|
||||
Vanguard_Patrol_Rank2,
|
||||
Vanguard_Patrol_Rank3,
|
||||
Vanguard_Patrol_Rank4,
|
||||
Vanguard_Commendation,
|
||||
Hunt_Rampage2,
|
||||
Boss_Bounty2 = 2464,
|
||||
Norn_Hunting_Party = 2469,
|
||||
Norn_Hunting_Party1,
|
||||
Norn_Hunting_Party2,
|
||||
Norn_Hunting_Party3,
|
||||
Norn_Hunting_Party_Rank2,
|
||||
Norn_Hunting_Party_Rank3,
|
||||
Norn_Hunting_Party_Rank4,
|
||||
Strength_of_the_Norn,
|
||||
Hunt_Rampage3,
|
||||
Asuran_Bodyguard3 = 2481,
|
||||
Asuran_Bodyguard = 2481,
|
||||
Desperate_Howl,
|
||||
Gloat,
|
||||
Metamorphosis,
|
||||
@@ -15348,17 +15358,19 @@ namespace Daybreak.API.Interop.GuildWars
|
||||
kShowHeroPanel,
|
||||
kMessage_0x1000019e,
|
||||
kMessage_0x1000019f,
|
||||
kMessage_0x100001a0,
|
||||
kQuerySuppressedKeyAction,
|
||||
kGetInventoryAgentId,
|
||||
kInventoryRelated1,
|
||||
kInventoryRelated2,
|
||||
kInventoryRelated3,
|
||||
kInventoryRelated4,
|
||||
kEquipItem,
|
||||
kMoveItem,
|
||||
kItemRelated_1,
|
||||
kItemTooltip,
|
||||
kItemRelated_3,
|
||||
kItemRelated_4,
|
||||
kItemRelated_5,
|
||||
kInitiateTrade,
|
||||
kMessage_0x100001a7,
|
||||
kMessage_0x100001a8,
|
||||
@@ -16718,7 +16730,7 @@ namespace Daybreak.API.Interop.GuildWars
|
||||
public uint Level;
|
||||
}
|
||||
|
||||
[global::System.Runtime.InteropServices.StructLayout(global::System.Runtime.InteropServices.LayoutKind.Explicit, Pack = 1, Size = 0x98)]
|
||||
[global::System.Runtime.InteropServices.StructLayout(global::System.Runtime.InteropServices.LayoutKind.Explicit, Pack = 1, Size = 0xA4)]
|
||||
public unsafe struct Inventory
|
||||
{
|
||||
[global::System.Runtime.InteropServices.FieldOffset(0x0000)]
|
||||
@@ -16940,19 +16952,9 @@ namespace Daybreak.API.Interop.GuildWars
|
||||
[global::System.Runtime.InteropServices.FieldOffset(0x00D0)]
|
||||
public uint H00D0;
|
||||
[global::System.Runtime.InteropServices.FieldOffset(0x00D4)]
|
||||
public uint H00D4;
|
||||
[global::System.Runtime.InteropServices.FieldOffset(0x00D8)]
|
||||
public uint H00D8;
|
||||
[global::System.Runtime.InteropServices.FieldOffset(0x00DC)]
|
||||
public uint H00DC;
|
||||
[global::System.Runtime.InteropServices.FieldOffset(0x00E0)]
|
||||
public uint H00E0;
|
||||
[global::System.Runtime.InteropServices.FieldOffset(0x00E4)]
|
||||
public global::Daybreak.API.Interop.GuildWars.GuildWarsArray<global::Daybreak.API.Interop.GuildWars.InventoryTableEntry> InventoryTable;
|
||||
[global::System.Runtime.InteropServices.FieldOffset(0x00F4)]
|
||||
public uint H00F4;
|
||||
public global::Daybreak.API.Interop.GuildWars.THash InventoryTable; // 0x24 bytes, keyed by agent_id
|
||||
[global::System.Runtime.InteropServices.FieldOffset(0x00F8)]
|
||||
public global::Daybreak.API.Interop.GuildWars.Inventory* Inventory;
|
||||
public global::Daybreak.API.Interop.GuildWars.Inventory* Inventory; // shortcut to player's own inventory
|
||||
[global::System.Runtime.InteropServices.FieldOffset(0x00FC)]
|
||||
public global::Daybreak.API.Interop.GuildWars.GuildWarsArray<nint> H00FC;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ using Daybreak.Services.ApplicationArguments.ArgumentHandling;
|
||||
using Daybreak.Services.ApplicationLauncher;
|
||||
using Daybreak.Services.BuildTemplates;
|
||||
using Daybreak.Services.Credentials;
|
||||
using Daybreak.Services.Formatting;
|
||||
using Daybreak.Services.DirectSong;
|
||||
using Daybreak.Services.Downloads;
|
||||
using Daybreak.Services.Events;
|
||||
@@ -48,12 +49,12 @@ using Daybreak.Services.TradeChat.Models;
|
||||
using Daybreak.Services.TradeChat.Notifications;
|
||||
using Daybreak.Services.UMod;
|
||||
using Daybreak.Services.Updater;
|
||||
using Daybreak.Services.Wiki;
|
||||
using Daybreak.Shared.Models.Plugins;
|
||||
using Daybreak.Shared.Services.Api;
|
||||
using Daybreak.Shared.Services.ApplicationArguments;
|
||||
using Daybreak.Shared.Services.ApplicationLauncher;
|
||||
using Daybreak.Shared.Services.BuildTemplates;
|
||||
using Daybreak.Shared.Services.Formatting;
|
||||
using Daybreak.Shared.Services.BuildTemplates.Parsers;
|
||||
using Daybreak.Shared.Services.Credentials;
|
||||
using Daybreak.Shared.Services.DirectSong;
|
||||
@@ -82,7 +83,6 @@ using Daybreak.Shared.Services.Toolbox;
|
||||
using Daybreak.Shared.Services.TradeChat;
|
||||
using Daybreak.Shared.Services.UMod;
|
||||
using Daybreak.Shared.Services.Updater;
|
||||
using Daybreak.Shared.Services.Wiki;
|
||||
using Daybreak.Services.Graph;
|
||||
using Daybreak.Themes;
|
||||
using Daybreak.Views;
|
||||
@@ -225,6 +225,7 @@ public class ProjectConfiguration : PluginConfigurationBase
|
||||
services.AddScoped<IOnboardingService, OnboardingService>();
|
||||
services.AddScoped<IExperienceCalculator, ExperienceCalculator>();
|
||||
services.AddScoped<IAttributePointCalculator, AttributePointCalculator>();
|
||||
services.AddScoped<ISkillNumberFormatter, SkillNumberFormatter>();
|
||||
services.AddScoped<IDownloadService, DownloadService>();
|
||||
services.AddScoped<IGuildWarsInstaller, IntegratedGuildwarsInstaller>();
|
||||
services.AddScoped<IExceptionHandler, ExceptionHandler>();
|
||||
@@ -248,7 +249,6 @@ public class ProjectConfiguration : PluginConfigurationBase
|
||||
services.AddScoped<ILaunchConfigurationService, LaunchConfigurationService>();
|
||||
services.AddScoped<IEventService, EventService>();
|
||||
services.AddScoped<IApplicationArgumentService, ApplicationArgumentService>();
|
||||
services.AddScoped<IWikiService, WikiService>();
|
||||
services.AddScoped<IScreenshotService, ScreenshotService>();
|
||||
|
||||
services.AddHostedSingleton<IApplicationUpdater, ApplicationUpdater>();
|
||||
@@ -568,10 +568,6 @@ public class ProjectConfiguration : PluginConfigurationBase
|
||||
.WithMessageHandler(SetupLoggingAndMetrics<ScopedApiContext>)
|
||||
.WithDefaultRequestHeadersSetup(SetupDaybreakUserAgent)
|
||||
.Build()
|
||||
.RegisterHttpClient<WikiService>()
|
||||
.WithMessageHandler(SetupLoggingAndMetrics<WikiService>)
|
||||
.WithDefaultRequestHeadersSetup(SetupDaybreakUserAgent)
|
||||
.Build()
|
||||
.RegisterHttpClient<ReShadeService>()
|
||||
.WithMessageHandler(SetupLoggingAndMetrics<ReShadeService>)
|
||||
.WithDefaultRequestHeadersSetup(SetupDaybreakUserAgent)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Globalization;
|
||||
using Daybreak.Shared.Services.Formatting;
|
||||
|
||||
namespace Daybreak.Services.Formatting;
|
||||
|
||||
internal sealed class SkillNumberFormatter : ISkillNumberFormatter
|
||||
{
|
||||
public string Format(double? value)
|
||||
{
|
||||
if (value is not double d)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (d < 0)
|
||||
{
|
||||
return "-" + this.Format(-d);
|
||||
}
|
||||
|
||||
var whole = (long)Math.Floor(d);
|
||||
var fractional = d - whole;
|
||||
var fractionGlyph = TryGetFractionGlyph(fractional);
|
||||
|
||||
if (fractionGlyph is null)
|
||||
{
|
||||
return d == whole
|
||||
? whole.ToString(CultureInfo.InvariantCulture)
|
||||
: d.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return whole == 0
|
||||
? fractionGlyph
|
||||
: whole.ToString(CultureInfo.InvariantCulture) + fractionGlyph;
|
||||
}
|
||||
|
||||
public string FormatPercent(double? value)
|
||||
{
|
||||
if (value is not double d)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return Math.Round(d * 100).ToString("0", CultureInfo.InvariantCulture) + "%";
|
||||
}
|
||||
|
||||
private static string? TryGetFractionGlyph(double fractional) => fractional switch
|
||||
{
|
||||
< 0.001 => null,
|
||||
< 0.26 and > 0.24 => "¹⁄₄",
|
||||
< 0.51 and > 0.49 => "¹⁄₂",
|
||||
< 0.76 and > 0.74 => "³⁄₄",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
using Daybreak.Shared.Models.Guildwars;
|
||||
using Daybreak.Shared.Services.Wiki;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Attribute = Daybreak.Shared.Models.Guildwars.Attribute;
|
||||
|
||||
namespace Daybreak.Services.Wiki;
|
||||
public sealed partial class WikiService(
|
||||
IHttpClient<WikiService> httpClient,
|
||||
ILogger<WikiService> logger)
|
||||
: IWikiService
|
||||
{
|
||||
private const string WikiQueryTitlePlaceholder = "[TITLE]";
|
||||
private const string WikiQueryUrl = $"https://wiki.guildwars.com/api.php?action=query&format=json&titles={WikiQueryTitlePlaceholder}&prop=revisions&rvprop=content&rvslots=main";
|
||||
|
||||
private static readonly ConcurrentDictionary<int, SkillDescription> DescriptionCache = [];
|
||||
|
||||
private readonly IHttpClient<WikiService> httpClient = httpClient;
|
||||
private readonly ILogger<WikiService> logger = logger;
|
||||
|
||||
public async Task<SkillDescription?> GetSkillDescription(Skill skill, CancellationToken cancellationToken)
|
||||
{
|
||||
if (DescriptionCache.TryGetValue(skill.Id, out var cachedDescription))
|
||||
{
|
||||
return cachedDescription;
|
||||
}
|
||||
|
||||
var wikiText = await this.GetWikiText(skill.Name, cancellationToken);
|
||||
if (wikiText is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var description = ParseWikiText(wikiText);
|
||||
if (description is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
DescriptionCache[skill.Id] = description;
|
||||
return description;
|
||||
}
|
||||
|
||||
private async ValueTask<string?> GetWikiText(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var urlEncodedName = UrlEncoder.Default.Encode(name);
|
||||
var wikiUrl = WikiQueryUrl.Replace(WikiQueryTitlePlaceholder, urlEncodedName);
|
||||
var response = await this.httpClient.GetAsync(wikiUrl, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var wikiText = ExtractWikiTextFromJson(content);
|
||||
if (wikiText is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var redirectTarget = ExtractRedirectTarget(wikiText);
|
||||
if (redirectTarget is null)
|
||||
{
|
||||
return wikiText;
|
||||
}
|
||||
|
||||
name = redirectTarget;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractRedirectTarget(string wikiText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(wikiText))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var match = RedirectRegex().Match(wikiText.Trim());
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
private static string? ExtractWikiTextFromJson(string jsonContent)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var json = JsonDocument.Parse(jsonContent);
|
||||
var root = json.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("query", out var query) ||
|
||||
!query.TryGetProperty("pages", out var pages))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the first property (page) from pages object
|
||||
using var pagesEnumerator = pages.EnumerateObject();
|
||||
if (!pagesEnumerator.MoveNext())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var page = pagesEnumerator.Current.Value;
|
||||
|
||||
if (!page.TryGetProperty("revisions", out var revisions) ||
|
||||
revisions.GetArrayLength() == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var firstRevision = revisions[0];
|
||||
if (firstRevision.TryGetProperty("slots", out var slots) &&
|
||||
slots.TryGetProperty("main", out var main) &&
|
||||
main.TryGetProperty("*", out var content))
|
||||
{
|
||||
return content.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
private static SkillDescription? ParseWikiText(string wikiText)
|
||||
{
|
||||
var infoboxContent = ExtractSkillInfobox(CleanWikiText(wikiText));
|
||||
if (infoboxContent is not null)
|
||||
{
|
||||
return new SkillDescription(
|
||||
Id: ExtractField(infoboxContent, "id") is string idStr ? int.TryParse(idStr, out var id) ? id : -1 : -1,
|
||||
Campaign: ExtractField(infoboxContent, "campaign") is string campaignStr ? Campaign.TryParse(campaignStr, out var campaign) ? campaign : Campaign.None : Campaign.None,
|
||||
Name: ExtractField(infoboxContent, "name") ?? string.Empty,
|
||||
Profession: ExtractField(infoboxContent, "profession") is string professionStr ? Profession.TryParse(professionStr, out var profession) ? profession : Profession.None : Profession.None,
|
||||
Attribute: ExtractField(infoboxContent, "attribute") is string attrStr ? Attribute.TryParse(attrStr, out var attribute) ? attribute : Attribute.None : Attribute.None,
|
||||
PveOnly: ExtractField(infoboxContent, "pve-only") is string pveOnlyStr && pveOnlyStr.StartsWith('y'), // Some fields contain yes, others y
|
||||
Pvp: ExtractField(infoboxContent, "is-pvp") is string pvpStr && pvpStr.StartsWith('y'),
|
||||
Elite: ExtractField(infoboxContent, "elite") is string eliteStr && eliteStr.StartsWith('y'), // Some fields contain yes, others y
|
||||
Type: ExtractField(infoboxContent, "type") ?? string.Empty,
|
||||
Energy: ExtractField(infoboxContent, "energy") ?? string.Empty,
|
||||
Activation: ExtractField(infoboxContent, "activation") ?? string.Empty,
|
||||
Recharge: ExtractField(infoboxContent, "recharge") ?? string.Empty,
|
||||
Overcast: ExtractField(infoboxContent, "overcast") ?? string.Empty,
|
||||
Adrenaline: ExtractField(infoboxContent, "adrenaline") ?? string.Empty,
|
||||
Sacrifice: ExtractField(infoboxContent, "sacrifice") ?? string.Empty,
|
||||
Upkeep: ExtractField(infoboxContent, "upkeep") ?? string.Empty,
|
||||
Description: ExtractField(infoboxContent, "description") ?? string.Empty,
|
||||
ConciseDescription: ExtractField(infoboxContent, "concise description") ?? string.Empty);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
private static string CleanWikiText(string wikiText)
|
||||
{
|
||||
if (string.IsNullOrEmpty(wikiText))
|
||||
{
|
||||
return wikiText;
|
||||
}
|
||||
|
||||
var cleaned = wikiText;
|
||||
|
||||
// Handle common fraction templates
|
||||
cleaned = HalfFractionRegex().Replace(cleaned, "¹⁄₂");
|
||||
cleaned = QuarterFractionRegex().Replace(cleaned, "¹⁄₄");
|
||||
cleaned = ThreeQuarterFractionRegex().Replace(cleaned, "³⁄₄");
|
||||
cleaned = ThreeOverTwoFractionRegex().Replace(cleaned, "³⁄₂");
|
||||
|
||||
// Handle decimal number templates
|
||||
cleaned = DecimalRegex().Replace(cleaned, "$1");
|
||||
|
||||
// Handle {{gr|min|max}} templates (green range values)
|
||||
cleaned = GreenRangeRegex().Replace(cleaned, "$1…$2");
|
||||
cleaned = GreenRange2Regex().Replace(cleaned, "$1…$2");
|
||||
cleaned = GrayRangeRegex().Replace(cleaned, "$1…$2");
|
||||
|
||||
// Handle simple links [[Link Text]]
|
||||
cleaned = SimpleLinkRegex().Replace(cleaned, "$1");
|
||||
|
||||
// Handle {{gray|text}} templates
|
||||
cleaned = GrayTextRegex().Replace(cleaned, "");
|
||||
cleaned = GreyTextRegex().Replace(cleaned, "");
|
||||
|
||||
// Handle {{sic|text}} templates (remove the sic notation but keep the content)
|
||||
cleaned = SicTextRegex().Replace(cleaned, "");
|
||||
|
||||
// Handle links with display text [[Link|Display Text]]
|
||||
cleaned = LinkWithDisplayTextRegex().Replace(cleaned, "$2");
|
||||
|
||||
// Handle skill type formatting
|
||||
cleaned = SkillTypeLinkRegex().Replace(cleaned, "$1");
|
||||
|
||||
// Remove any remaining wiki markup
|
||||
cleaned = BoldTextRegex().Replace(cleaned, "$1"); // Bold
|
||||
cleaned = ItalicTextRegex().Replace(cleaned, "$1"); // Italic
|
||||
cleaned = HtmlTagRegex().Replace(cleaned, ""); // HTML tags
|
||||
|
||||
// Clean up extra whitespace
|
||||
cleaned = WhitespaceRegex().Replace(cleaned, " ").Trim();
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private static string? ExtractSkillInfobox(string wikiText)
|
||||
{
|
||||
var startPattern = "{{Skill infobox";
|
||||
var startIndex = wikiText.IndexOf(startPattern, StringComparison.OrdinalIgnoreCase);
|
||||
if (startIndex == -1)
|
||||
return null;
|
||||
|
||||
// Find the pipe after "{{Skill infobox"
|
||||
var pipeIndex = wikiText.IndexOf('|', startIndex);
|
||||
if (pipeIndex == -1)
|
||||
return null;
|
||||
|
||||
// Count braces to find the matching closing }}
|
||||
var braceCount = 2; // Start with 2 for the opening {{
|
||||
var index = startIndex + startPattern.Length;
|
||||
|
||||
while (index < wikiText.Length && braceCount > 0)
|
||||
{
|
||||
if (wikiText[index] == '{' && index + 1 < wikiText.Length && wikiText[index + 1] == '{')
|
||||
{
|
||||
braceCount += 2;
|
||||
index += 2;
|
||||
}
|
||||
else if (wikiText[index] == '}' && index + 1 < wikiText.Length && wikiText[index + 1] == '}')
|
||||
{
|
||||
braceCount -= 2;
|
||||
index += 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
if (braceCount == 0)
|
||||
{
|
||||
// Extract content between the pipe and the closing }}
|
||||
return wikiText.Substring(pipeIndex + 1, index - pipeIndex - 3).Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractField(string infoboxContent, string fieldName)
|
||||
{
|
||||
var pattern = $@"(?:^|\|\s*){Regex.Escape(fieldName)}\s*=\s*(.*?)(?=\s*\||$)";
|
||||
var match = Regex.Match(infoboxContent, pattern, RegexOptions.IgnoreCase | RegexOptions.Multiline);
|
||||
|
||||
return match.Success ? match.Groups[1].Value.Trim() : null;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^#REDIRECT\s*\[\[([^\]]+)\]\]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex RedirectRegex();
|
||||
[GeneratedRegex(@"\{\{1/2\}\}", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex HalfFractionRegex();
|
||||
[GeneratedRegex(@"\{\{1/4\}\}", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex QuarterFractionRegex();
|
||||
[GeneratedRegex(@"\{\{3/4\}\}", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ThreeQuarterFractionRegex();
|
||||
[GeneratedRegex(@"\{\{3/2\}\}", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ThreeOverTwoFractionRegex();
|
||||
[GeneratedRegex(@"\{\{(\d+(?:\.\d+)?)\}\}", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex DecimalRegex();
|
||||
[GeneratedRegex(@"\{\{gr\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex GreenRangeRegex();
|
||||
[GeneratedRegex(@"\{\{gr2\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex GreenRange2Regex();
|
||||
[GeneratedRegex(@"\{\{gray\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex GrayRangeRegex();
|
||||
[GeneratedRegex(@"\{\{sic(?:\|([^}]*))?\}\}", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SicTextRegex();
|
||||
[GeneratedRegex(@"\[\[([^\]|]+)\]\]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SimpleLinkRegex();
|
||||
[GeneratedRegex(@"\[\[([^|]+)\|([^\]]+)\]\]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LinkWithDisplayTextRegex();
|
||||
[GeneratedRegex(@"\[\[([^|]+)\]\]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SkillTypeLinkRegex();
|
||||
[GeneratedRegex(@"'''([^']+)'''", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BoldTextRegex();
|
||||
[GeneratedRegex(@"''([^']+)''", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ItalicTextRegex();
|
||||
[GeneratedRegex(@"<[^>]*>", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex HtmlTagRegex();
|
||||
[GeneratedRegex(@"\s+", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex WhitespaceRegex();
|
||||
[GeneratedRegex(@"\{\{gray\|([^}]+)\}\}", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex GrayTextRegex();
|
||||
[GeneratedRegex(@"\{\{grey\|([^}]+)\}\}", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex GreyTextRegex();
|
||||
}
|
||||
@@ -232,7 +232,7 @@ public abstract class BuildTemplateViewModelBase<TViewModel, TView>(
|
||||
|
||||
protected void FilterSkillsByProfessionsAndString(string? searchFilter = default)
|
||||
{
|
||||
var filteredSkills = Skill.Skills.Where(skill =>
|
||||
var filteredSkills = Skill.AllSkills.Where(skill =>
|
||||
{
|
||||
if (skill.PvP)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<div class="backdrop-panel container skill-snippet" style="--mouse-x: @(this.Context.MousePosition.PosX)px; --mouse-y: @(this.Context.MousePosition.PosY)px;">
|
||||
@inject ISkillNumberFormatter SkillNumberFormatter
|
||||
|
||||
<div class="backdrop-panel container skill-snippet" style="--mouse-x: @(this.Context.MousePosition.PosX)px; --mouse-y: @(this.Context.MousePosition.PosY)px;">
|
||||
<div class="left-side">
|
||||
<div class="skill-icon">
|
||||
<SkillIcon Skill="this.Context.Skill" />
|
||||
@@ -23,67 +25,67 @@
|
||||
<div class="skill-concise-description">@this.Context.Skill.ConciseDescription</div>
|
||||
</div>
|
||||
<div class="right-side">
|
||||
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Energy))
|
||||
@if (this.Context.Skill.Energy.HasValue)
|
||||
{
|
||||
<div class="skill-resource">
|
||||
<div class="skill-resource-img">
|
||||
<Daybreak.Views.Components.Icons.Misc.Energy />
|
||||
</div>
|
||||
<div class="skill-resource-text">@this.Context.Skill.Energy</div>
|
||||
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Energy)</div>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Activation))
|
||||
@if (this.Context.Skill.Activation.HasValue)
|
||||
{
|
||||
<div class="skill-resource">
|
||||
<div class="skill-resource-img">
|
||||
<Daybreak.Views.Components.Icons.Misc.Activation />
|
||||
</div>
|
||||
<div class="skill-resource-text">@this.Context.Skill.Activation</div>
|
||||
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Activation)</div>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Recharge))
|
||||
@if (this.Context.Skill.Recharge.HasValue)
|
||||
{
|
||||
<div class="skill-resource">
|
||||
<div class="skill-resource-img">
|
||||
<Daybreak.Views.Components.Icons.Misc.Recharge />
|
||||
</div>
|
||||
<div class="skill-resource-text">@this.Context.Skill.Recharge</div>
|
||||
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Recharge)</div>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Overcast))
|
||||
@if (this.Context.Skill.Overcast.HasValue)
|
||||
{
|
||||
<div class="skill-resource">
|
||||
<div class="skill-resource-img">
|
||||
<Daybreak.Views.Components.Icons.Misc.Overcast />
|
||||
</div>
|
||||
<div class="skill-resource-text">@this.Context.Skill.Overcast</div>
|
||||
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Overcast)</div>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Adrenaline))
|
||||
@if (this.Context.Skill.Adrenaline.HasValue)
|
||||
{
|
||||
<div class="skill-resource">
|
||||
<div class="skill-resource-img">
|
||||
<Daybreak.Views.Components.Icons.Misc.Adrenaline />
|
||||
</div>
|
||||
<div class="skill-resource-text">@this.Context.Skill.Adrenaline</div>
|
||||
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Adrenaline)</div>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Sacrifice))
|
||||
@if (this.Context.Skill.Sacrifice.HasValue)
|
||||
{
|
||||
<div class="skill-resource">
|
||||
<div class="skill-resource-img">
|
||||
<Daybreak.Views.Components.Icons.Misc.Sacrifice />
|
||||
</div>
|
||||
<div class="skill-resource-text">@this.Context.Skill.Sacrifice</div>
|
||||
<div class="skill-resource-text">@this.SkillNumberFormatter.FormatPercent(this.Context.Skill.Sacrifice)</div>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Upkeep))
|
||||
@if (this.Context.Skill.Upkeep.HasValue)
|
||||
{
|
||||
<div class="skill-resource">
|
||||
<div class="skill-resource-img">
|
||||
<Daybreak.Views.Components.Icons.Misc.Upkeep />
|
||||
</div>
|
||||
<div class="skill-resource-text">@this.Context.Skill.Upkeep</div>
|
||||
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Upkeep)</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
@using Daybreak.Shared.Models.Notifications
|
||||
@using Daybreak.Shared.Models.Options
|
||||
@using Daybreak.Shared.Models.FocusView
|
||||
@using Daybreak.Shared.Models.Guildwars
|
||||
@using Daybreak.Shared.Services.Formatting
|
||||
@using Daybreak.Shared.Utils
|
||||
@using Daybreak.Shared.Validators
|
||||
@using Daybreak.Shared
|
||||
|
||||
@@ -974,6 +974,21 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
sb.AppendLine($"{pad}}}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// THash<T> - GWCA hash table, fixed 0x24 (36) bytes regardless of T.
|
||||
// Treated as an opaque blob; consumers use GWCA helpers for traversal.
|
||||
// See Dependencies/GWCA/Include/GWCA/GameContainers/Hash.h.
|
||||
if (emittedNames.Add("THash"))
|
||||
{
|
||||
sb.AppendLine($"{pad}/// <summary>");
|
||||
sb.AppendLine($"{pad}/// THash<T> - hash table (36 bytes, opaque to managed code).");
|
||||
sb.AppendLine($"{pad}/// </summary>");
|
||||
sb.AppendLine($"{pad}[global::System.Runtime.InteropServices.StructLayout(global::System.Runtime.InteropServices.LayoutKind.Sequential, Pack = 1, Size = 0x24)]");
|
||||
sb.AppendLine($"{pad}public struct THash");
|
||||
sb.AppendLine($"{pad}{{");
|
||||
sb.AppendLine($"{pad}}}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1250,6 +1265,12 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
return "global::Daybreak.API.Interop.GuildWars.TLink";
|
||||
}
|
||||
|
||||
// Handle THash<T> - hash table, always 0x24 bytes; opaque to managed code.
|
||||
if (cppType.StartsWith("THash<"))
|
||||
{
|
||||
return "global::Daybreak.API.Interop.GuildWars.THash";
|
||||
}
|
||||
|
||||
// Handle pointers
|
||||
bool isPointer = cppType.EndsWith("*");
|
||||
if (isPointer)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
namespace Daybreak.Shared.Models.Guildwars;
|
||||
public sealed record SkillDescription(
|
||||
int Id,
|
||||
string Name,
|
||||
Campaign Campaign,
|
||||
Profession Profession,
|
||||
Attribute Attribute,
|
||||
bool PveOnly,
|
||||
bool Pvp,
|
||||
bool Elite,
|
||||
string Type,
|
||||
string Energy,
|
||||
string Activation,
|
||||
string Recharge,
|
||||
string Overcast,
|
||||
string Adrenaline,
|
||||
string Sacrifice,
|
||||
string Upkeep,
|
||||
string Description,
|
||||
string ConciseDescription)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Daybreak.Shared.Services.Formatting;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a numeric skill resource cost (energy, recharge, activation, etc.)
|
||||
/// using the wiki's fraction glyphs (¼/½/¾) where applicable, mirroring how
|
||||
/// the source wiki presents these numbers so the UI matches what players
|
||||
/// already recognise.
|
||||
/// </summary>
|
||||
public interface ISkillNumberFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders an integer/decimal/quarter-fraction cost. Returns
|
||||
/// <see cref="string.Empty"/> for <c>null</c>.
|
||||
/// </summary>
|
||||
string Format(double? value);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a value stored as a unit fraction (e.g. <c>0.15</c>) back as
|
||||
/// a rounded percentage (<c>"15%"</c>). Used by the few skill fields
|
||||
/// that the wiki originally expressed as percentages — currently just
|
||||
/// the blood-magic Sacrifice cost.
|
||||
/// </summary>
|
||||
string FormatPercent(double? value);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
using Daybreak.Shared.Models.Guildwars;
|
||||
|
||||
namespace Daybreak.Shared.Services.Wiki;
|
||||
public interface IWikiService
|
||||
{
|
||||
Task<SkillDescription?> GetSkillDescription(Skill skill, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -50,4 +50,7 @@
|
||||
<Project Path="Daybreak.Generators/Daybreak.Generators.csproj">
|
||||
<Platform Project="Any CPU" />
|
||||
</Project>
|
||||
<Project Path="Tools/SkillUpdater/SkillUpdater.csproj">
|
||||
<Platform Project="Any CPU" />
|
||||
</Project>
|
||||
</Solution>
|
||||
|
||||
+2
-2
@@ -463,7 +463,7 @@ namespace GW {
|
||||
Test_Map_solo_areas, // "This is a debug map to allow multiplayer testing of current solo areas."
|
||||
Nightfallen_Garden,
|
||||
Churrhir_Fields,
|
||||
Beknur_Harbor_outpost,
|
||||
Beknur_Harbor,
|
||||
Kodonur_Crossroads_cinematic,
|
||||
Rilohn_Refuge_cinematic,
|
||||
Pogahn_Passage_cinematic,
|
||||
@@ -493,7 +493,7 @@ namespace GW {
|
||||
Lahtenda_Bog,
|
||||
Arbor_Bay,
|
||||
Issnur_Isles,
|
||||
Beknur_Harbor,
|
||||
Beknur_Harbor_outpost,
|
||||
Mehtani_Keys,
|
||||
Kodlonu_Hamlet_outpost,
|
||||
Island_of_Shehkah,
|
||||
|
||||
+13
-13
@@ -2213,9 +2213,9 @@ namespace GW {
|
||||
Aura_of_the_Bloodstone1,
|
||||
Aura_of_the_Bloodstone2,
|
||||
Haunted_Ground,
|
||||
Asuran_Bodyguard,
|
||||
Asuran_Bodyguard1,
|
||||
Asuran_Bodyguard2,
|
||||
Asuran_Bodyguard_Rank2,
|
||||
Asuran_Bodyguard_Rank3,
|
||||
Asuran_Bodyguard_Rank4,
|
||||
Energy_Channel,
|
||||
Hunt_Rampage,
|
||||
Boss_Bounty = 2440,
|
||||
@@ -2224,9 +2224,9 @@ namespace GW {
|
||||
Hunt_Point_Bonus2,
|
||||
Time_Attack,
|
||||
Dwarven_Raider,
|
||||
Dwarven_Raider1,
|
||||
Dwarven_Raider2,
|
||||
Dwarven_Raider3,
|
||||
Dwarven_Raider_Rank2,
|
||||
Dwarven_Raider_Rank3,
|
||||
Dwarven_Raider_Rank4,
|
||||
Great_Dwarfs_Blessing,
|
||||
Hunt_Rampage1,
|
||||
Boss_Bounty1 = 2452,
|
||||
@@ -2234,19 +2234,19 @@ namespace GW {
|
||||
Hunt_Point_Bonus4,
|
||||
Time_Attack1 = 2456,
|
||||
Vanguard_Patrol,
|
||||
Vanguard_Patrol1,
|
||||
Vanguard_Patrol2,
|
||||
Vanguard_Patrol3,
|
||||
Vanguard_Patrol_Rank2,
|
||||
Vanguard_Patrol_Rank3,
|
||||
Vanguard_Patrol_Rank4,
|
||||
Vanguard_Commendation,
|
||||
Hunt_Rampage2,
|
||||
Boss_Bounty2 = 2464,
|
||||
Norn_Hunting_Party = 2469,
|
||||
Norn_Hunting_Party1,
|
||||
Norn_Hunting_Party2,
|
||||
Norn_Hunting_Party3,
|
||||
Norn_Hunting_Party_Rank2,
|
||||
Norn_Hunting_Party_Rank3,
|
||||
Norn_Hunting_Party_Rank4,
|
||||
Strength_of_the_Norn,
|
||||
Hunt_Rampage3,
|
||||
Asuran_Bodyguard3 = 2481,
|
||||
Asuran_Bodyguard = 2481,
|
||||
Desperate_Howl,
|
||||
Gloat,
|
||||
Metamorphosis,
|
||||
|
||||
+37
-35
@@ -548,45 +548,47 @@ namespace GW {
|
||||
kShowHeroPanel, // 0x100001a3, wparam = hero_id
|
||||
kMessage_0x1000019e, // 0x100001a4
|
||||
kMessage_0x1000019f, // 0x100001a5
|
||||
kMessage_0x100001a0, // 0x100001a6
|
||||
kQuerySuppressedKeyAction, // 0x100001a6, wparam = 0, lparam = bool*. Used to check if the suppress action is pressed (usually shift)
|
||||
kGetInventoryAgentId, // 0x100001a7, wparam = 0, lparam = uint32_t* agent_id_out. Used to fetch which agent is selected
|
||||
kInventoryRelated1, // 0x100001a8, added to GW 2026-02-26
|
||||
kInventoryRelated2, // 0x100001a9, added to GW 2026-02-26
|
||||
kInventoryRelated3, // 0x100001aa, added to GW 2026-02-26
|
||||
kEquipItem, // 0x100001ab, wparam = { item_id, agent_id }
|
||||
kMoveItem, // 0x100001ac, wparam = { item_id, to_bag, to_slot, bool prompt }
|
||||
kItemRelated_1, // 0x100001ad
|
||||
kItemTooltip, // 0x100001ae
|
||||
kItemRelated_3, // 0x100001af, added to GW 2026-02-26
|
||||
kItemRelated_4, // 0x100001b0, added to GW 2026-02-26
|
||||
kInitiateTrade, // 0x100001b1
|
||||
kMessage_0x100001a7, // 0x100001b2
|
||||
kMessage_0x100001a8, // 0x100001b3
|
||||
kMessage_0x100001a9, // 0x100001b4
|
||||
kMessage_0x100001aa, // 0x100001b5
|
||||
kMessage_0x100001ab, // 0x100001b6
|
||||
kMessage_0x100001ac, // 0x100001b7
|
||||
kMessage_0x100001ad, // 0x100001b8
|
||||
kMessage_0x100001ae, // 0x100001b9
|
||||
kMessage_0x100001af, // 0x100001ba
|
||||
kMessage_0x100001b0, // 0x100001bb
|
||||
kMessage_0x100001b1, // 0x100001bc
|
||||
kMessage_0x100001b2, // 0x100001bd
|
||||
kMessage_0x100001b3, // 0x100001be
|
||||
kMessage_0x100001b4, // 0x100001bf
|
||||
kMessage_0x100001b5, // 0x100001c0
|
||||
kInventoryAgentChanged, // 0x100001c1, Triggered when inventory needs updating due to agent change; no args
|
||||
kInventoryRelated_1, // 0x100001c2
|
||||
kInventoryRelated_2, // 0x100001c3
|
||||
kMissionStatusRelated, // 0x100001c4
|
||||
kUnused_1c2, // 0x100001c5
|
||||
kCollapseExpandSkillListSection, // 0x100001c6
|
||||
kTemplateRelated_1, // 0x100001c7
|
||||
kTemplateRelated_2, // 0x100001c8
|
||||
kPromptSaveTemplate, // 0x100001c9
|
||||
kOpenTemplate, // 0x100001ca, wparam = GW::UI::ChatTemplate*
|
||||
kTemplateRelated_3, // 0x100001cb
|
||||
kTemplateRelated_4, // 0x100001cc
|
||||
kInventoryRelated4, // 0x100001ab, added to GW 2026-04-28
|
||||
kEquipItem, // 0x100001ac, wparam = { item_id, agent_id }
|
||||
kMoveItem, // 0x100001ad, wparam = { item_id, to_bag, to_slot, bool prompt }
|
||||
kItemRelated_1, // 0x100001ae
|
||||
kItemTooltip, // 0x100001af
|
||||
kItemRelated_3, // 0x100001b0, added to GW 2026-02-26
|
||||
kItemRelated_4, // 0x100001b1, added to GW 2026-02-26
|
||||
kItemRelated_5, // 0x100001b2, added to GW 2026-04-28
|
||||
kInitiateTrade, // 0x100001b3
|
||||
kMessage_0x100001a7, // 0x100001b4
|
||||
kMessage_0x100001a8, // 0x100001b5
|
||||
kMessage_0x100001a9, // 0x100001b6
|
||||
kMessage_0x100001aa, // 0x100001b7
|
||||
kMessage_0x100001ab, // 0x100001b8
|
||||
kMessage_0x100001ac, // 0x100001b9
|
||||
kMessage_0x100001ad, // 0x100001ba
|
||||
kMessage_0x100001ae, // 0x100001bb
|
||||
kMessage_0x100001af, // 0x100001bc
|
||||
kMessage_0x100001b0, // 0x100001bd
|
||||
kMessage_0x100001b1, // 0x100001be
|
||||
kMessage_0x100001b2, // 0x100001bf
|
||||
kMessage_0x100001b3, // 0x100001c0
|
||||
kMessage_0x100001b4, // 0x100001c1
|
||||
kMessage_0x100001b5, // 0x100001c2
|
||||
kInventoryAgentChanged, // 0x100001c3, Triggered when inventory needs updating due to agent change; no args
|
||||
kInventoryRelated_1, // 0x100001c4
|
||||
kInventoryRelated_2, // 0x100001c5
|
||||
kMissionStatusRelated, // 0x100001c6
|
||||
kUnused_1c2, // 0x100001c7
|
||||
kCollapseExpandSkillListSection, // 0x100001c8
|
||||
kTemplateRelated_1, // 0x100001c9
|
||||
kTemplateRelated_2, // 0x100001ca
|
||||
kPromptSaveTemplate, // 0x100001cb
|
||||
kOpenTemplate, // 0x100001cc, wparam = GW::UI::ChatTemplate*
|
||||
kTemplateRelated_3, // 0x100001cd
|
||||
kTemplateRelated_4, // 0x100001ce
|
||||
|
||||
// GWCA Client to Server commands. Only added the ones that are used for hooks, everything else goes straight into GW
|
||||
|
||||
|
||||
+37
-35
@@ -548,45 +548,47 @@ namespace GW {
|
||||
kShowHeroPanel, // 0x100001a3, wparam = hero_id
|
||||
kMessage_0x1000019e, // 0x100001a4
|
||||
kMessage_0x1000019f, // 0x100001a5
|
||||
kMessage_0x100001a0, // 0x100001a6
|
||||
kQuerySuppressedKeyAction, // 0x100001a6, wparam = 0, lparam = bool*. Used to check if the suppress action is pressed (usually shift)
|
||||
kGetInventoryAgentId, // 0x100001a7, wparam = 0, lparam = uint32_t* agent_id_out. Used to fetch which agent is selected
|
||||
kInventoryRelated1, // 0x100001a8, added to GW 2026-02-26
|
||||
kInventoryRelated2, // 0x100001a9, added to GW 2026-02-26
|
||||
kInventoryRelated3, // 0x100001aa, added to GW 2026-02-26
|
||||
kEquipItem, // 0x100001ab, wparam = { item_id, agent_id }
|
||||
kMoveItem, // 0x100001ac, wparam = { item_id, to_bag, to_slot, bool prompt }
|
||||
kItemRelated_1, // 0x100001ad
|
||||
kItemTooltip, // 0x100001ae
|
||||
kItemRelated_3, // 0x100001af, added to GW 2026-02-26
|
||||
kItemRelated_4, // 0x100001b0, added to GW 2026-02-26
|
||||
kInitiateTrade, // 0x100001b1
|
||||
kMessage_0x100001a7, // 0x100001b2
|
||||
kMessage_0x100001a8, // 0x100001b3
|
||||
kMessage_0x100001a9, // 0x100001b4
|
||||
kMessage_0x100001aa, // 0x100001b5
|
||||
kMessage_0x100001ab, // 0x100001b6
|
||||
kMessage_0x100001ac, // 0x100001b7
|
||||
kMessage_0x100001ad, // 0x100001b8
|
||||
kMessage_0x100001ae, // 0x100001b9
|
||||
kMessage_0x100001af, // 0x100001ba
|
||||
kMessage_0x100001b0, // 0x100001bb
|
||||
kMessage_0x100001b1, // 0x100001bc
|
||||
kMessage_0x100001b2, // 0x100001bd
|
||||
kMessage_0x100001b3, // 0x100001be
|
||||
kMessage_0x100001b4, // 0x100001bf
|
||||
kMessage_0x100001b5, // 0x100001c0
|
||||
kInventoryAgentChanged, // 0x100001c1, Triggered when inventory needs updating due to agent change; no args
|
||||
kInventoryRelated_1, // 0x100001c2
|
||||
kInventoryRelated_2, // 0x100001c3
|
||||
kMissionStatusRelated, // 0x100001c4
|
||||
kUnused_1c2, // 0x100001c5
|
||||
kCollapseExpandSkillListSection, // 0x100001c6
|
||||
kTemplateRelated_1, // 0x100001c7
|
||||
kTemplateRelated_2, // 0x100001c8
|
||||
kPromptSaveTemplate, // 0x100001c9
|
||||
kOpenTemplate, // 0x100001ca, wparam = GW::UI::ChatTemplate*
|
||||
kTemplateRelated_3, // 0x100001cb
|
||||
kTemplateRelated_4, // 0x100001cc
|
||||
kInventoryRelated4, // 0x100001ab, added to GW 2026-04-28
|
||||
kEquipItem, // 0x100001ac, wparam = { item_id, agent_id }
|
||||
kMoveItem, // 0x100001ad, wparam = { item_id, to_bag, to_slot, bool prompt }
|
||||
kItemRelated_1, // 0x100001ae
|
||||
kItemTooltip, // 0x100001af
|
||||
kItemRelated_3, // 0x100001b0, added to GW 2026-02-26
|
||||
kItemRelated_4, // 0x100001b1, added to GW 2026-02-26
|
||||
kItemRelated_5, // 0x100001b2, added to GW 2026-04-28
|
||||
kInitiateTrade, // 0x100001b3
|
||||
kMessage_0x100001a7, // 0x100001b4
|
||||
kMessage_0x100001a8, // 0x100001b5
|
||||
kMessage_0x100001a9, // 0x100001b6
|
||||
kMessage_0x100001aa, // 0x100001b7
|
||||
kMessage_0x100001ab, // 0x100001b8
|
||||
kMessage_0x100001ac, // 0x100001b9
|
||||
kMessage_0x100001ad, // 0x100001ba
|
||||
kMessage_0x100001ae, // 0x100001bb
|
||||
kMessage_0x100001af, // 0x100001bc
|
||||
kMessage_0x100001b0, // 0x100001bd
|
||||
kMessage_0x100001b1, // 0x100001be
|
||||
kMessage_0x100001b2, // 0x100001bf
|
||||
kMessage_0x100001b3, // 0x100001c0
|
||||
kMessage_0x100001b4, // 0x100001c1
|
||||
kMessage_0x100001b5, // 0x100001c2
|
||||
kInventoryAgentChanged, // 0x100001c3, Triggered when inventory needs updating due to agent change; no args
|
||||
kInventoryRelated_1, // 0x100001c4
|
||||
kInventoryRelated_2, // 0x100001c5
|
||||
kMissionStatusRelated, // 0x100001c6
|
||||
kUnused_1c2, // 0x100001c7
|
||||
kCollapseExpandSkillListSection, // 0x100001c8
|
||||
kTemplateRelated_1, // 0x100001c9
|
||||
kTemplateRelated_2, // 0x100001ca
|
||||
kPromptSaveTemplate, // 0x100001cb
|
||||
kOpenTemplate, // 0x100001cc, wparam = GW::UI::ChatTemplate*
|
||||
kTemplateRelated_3, // 0x100001cd
|
||||
kTemplateRelated_4, // 0x100001ce
|
||||
|
||||
// GWCA Client to Server commands. Only added the ones that are used for hooks, everything else goes straight into GW
|
||||
|
||||
|
||||
+4
-8
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <GWCA/GameContainers/Array.h>
|
||||
#include <GWCA/Utilities/Export.h>
|
||||
#include <GWCA/GameContainers/Hash.h>
|
||||
|
||||
namespace GW {
|
||||
struct ItemContext;
|
||||
@@ -53,14 +54,9 @@ namespace GW {
|
||||
/* +h00C8 */ uint32_t h00C8;
|
||||
/* +h00CC */ uint32_t h00CC;
|
||||
/* +h00D0 */ uint32_t h00D0;
|
||||
/* +h00D4 */ uint32_t h00D4;
|
||||
/* +h00D8 */ uint32_t h00D8;
|
||||
/* +h00DC */ uint32_t h00DC;
|
||||
/* +h00E0 */ uint32_t h00E0;
|
||||
/* +h00E4 */ Array<InventoryTableEntry> inventory_table;
|
||||
/* +h00F4 */ uint32_t h00F4;
|
||||
/* +h00F8 */ Inventory* inventory;
|
||||
/* +h00D4 */ THash<Inventory> inventory_table; // 0x24 bytes, keyed by agent_id
|
||||
/* +h00F8 */ Inventory* inventory; // shortcut to player's own inventory
|
||||
/* +h00FC */ Array<void*> h00FC;
|
||||
};
|
||||
static_assert(sizeof(ItemContext) == 0x10c);
|
||||
static_assert(sizeof(ItemContext) == 0x10C, "struct ItemContext has incorrect size");
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,9 +2,9 @@
|
||||
|
||||
#define GWCA_VERSION_MAJOR 4
|
||||
#define GWCA_VERSION_MINOR 1
|
||||
#define GWCA_VERSION_PATCH 1
|
||||
#define GWCA_VERSION_PATCH 4
|
||||
#define GWCA_VERSION_BUILD 0
|
||||
#define GWCA_VERSION "4.1.1.0"
|
||||
#define GWCA_VERSION "4.1.4.0"
|
||||
|
||||
namespace GWCA {
|
||||
constexpr int VersionMajor = GWCA_VERSION_MAJOR;
|
||||
|
||||
+55
-17
@@ -1,34 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#pragma once
|
||||
#include <GWCA/Utilities/Export.h>
|
||||
|
||||
#include <GWCA/GameContainers/List.h>
|
||||
#include <GWCA/GameContainers/Array.h>
|
||||
|
||||
namespace GW {
|
||||
GWCA_API uint32_t Hash(const void *data, size_t size);
|
||||
GWCA_API uint32_t Hash(const void* data, size_t size);
|
||||
GWCA_API uint32_t Hash8(uint8_t data);
|
||||
GWCA_API uint32_t Hash16(uint16_t data);
|
||||
GWCA_API uint32_t Hash32(uint32_t data);
|
||||
GWCA_API uint32_t HashWString(const wchar_t* str, uint32_t maxLength = -1);
|
||||
|
||||
template <typename T>
|
||||
struct THash {
|
||||
/* +h0000 */ TList<T> m_fullList;
|
||||
/* +h0008 */ uint32_t m_slotSize;
|
||||
/* +h000C */ Array<TList<T>> m_slotListArray;
|
||||
/* +h001C */ uint32_t m_mask;
|
||||
/* +h0020 */ uint32_t m_maxCount;
|
||||
};
|
||||
struct THashLink;
|
||||
|
||||
template <typename T>
|
||||
struct THashLink
|
||||
{
|
||||
struct THash {
|
||||
/* +h0000 */ TList<T> m_fullList; // 0x0C bytes
|
||||
/* +h000C */ uint32_t m_slotSize;
|
||||
/* +h0010 */ BaseArray<TList<T>> m_slotListArray; // 0x0C bytes
|
||||
/* +h001C */ uint32_t m_mask;
|
||||
/* +h0020 */ uint32_t m_maxCount;
|
||||
|
||||
class iterator {
|
||||
public:
|
||||
explicit iterator(TLink<T>* link) : current(link) {}
|
||||
|
||||
THashLink<T>& operator*() {
|
||||
return *reinterpret_cast<THashLink<T>*>(
|
||||
reinterpret_cast<uintptr_t>(current)
|
||||
- offsetof(THashLink<T>, m_fullListLink)
|
||||
);
|
||||
}
|
||||
|
||||
THashLink<T>* operator->() { return &operator*(); }
|
||||
|
||||
iterator& operator++() {
|
||||
current = current->NextLink();
|
||||
return *this;
|
||||
}
|
||||
|
||||
bool operator==(const iterator& other) const { return current == other.current; }
|
||||
bool operator!=(const iterator& other) const { return current != other.current; }
|
||||
|
||||
private:
|
||||
TLink<T>* current;
|
||||
};
|
||||
|
||||
iterator begin() const {
|
||||
return iterator(m_fullList.Get()->NextLink());
|
||||
}
|
||||
|
||||
iterator end() const {
|
||||
return iterator(m_fullList.Get());
|
||||
}
|
||||
|
||||
bool valid() const { return m_slotListArray.valid(); }
|
||||
uint32_t size() const { return m_slotListArray.m_size; }
|
||||
};
|
||||
static_assert(sizeof(THash<void*>) == 0x24, "THash has incorrect size");
|
||||
|
||||
template <typename T>
|
||||
struct THashLink {
|
||||
/* +h0000 */ uint32_t hash;
|
||||
/* +h0004 */ T *elem;
|
||||
/* +h0004 */ T* elem;
|
||||
/* +h0008 */ uint32_t h0008;
|
||||
/* +h000C */ uint32_t h000C;
|
||||
/* +h0010 */ TLink<T> m_fullListLink;
|
||||
/* +h0018 */ TLink<T> m_slotListLink;
|
||||
/* +h0010 */ TLink<T> m_fullListLink; // 0x08 bytes
|
||||
/* +h0018 */ TLink<T> m_slotListLink; // 0x08 bytes
|
||||
};
|
||||
}
|
||||
static_assert(sizeof(THashLink<void*>) == 0x20, "THashLink has incorrect size");
|
||||
}
|
||||
+4
-1
@@ -208,13 +208,16 @@ namespace GW {
|
||||
/* +h0088 */ uint32_t h0088[2];
|
||||
/* +h0090 */ uint32_t gold_character;
|
||||
/* +h0094 */ uint32_t gold_storage;
|
||||
/* +0x098 */ uint32_t cached_hash; // at m_slotSize offset
|
||||
/* +0x09C */ uint32_t next_offset; // = 0x9c
|
||||
/* +0x0A0 */ Inventory* next; // chain pointer
|
||||
|
||||
[[nodiscard]] uint32_t inventory_id() const
|
||||
{
|
||||
return (uint32_t)unused_bag; // Fucked ourselves with this one; bag ids depend on the array starting at the inventory id...
|
||||
}
|
||||
};
|
||||
static_assert(sizeof(Inventory) == 152, "struct Inventory has incorrect size");
|
||||
static_assert(sizeof(Inventory) == 0xa4, "struct Inventory has incorrect size");
|
||||
|
||||
// Static struct for info about available item upgrade info, used for PvP Equipment window
|
||||
struct PvPItemUpgradeInfo {
|
||||
|
||||
@@ -89,6 +89,7 @@ namespace GW {
|
||||
GWCA_API bool SetHeroTarget(uint32_t hero_agent_id, uint32_t target_agent_id = 0);
|
||||
GWCA_API bool SetHeroBehavior(uint32_t hero_agent_id, HeroBehavior behavior);
|
||||
GWCA_API bool SetPetBehavior(uint32_t owner_agent_id, HeroBehavior behavior);
|
||||
GWCA_API bool SetHeroSkillDisabled(uint32_t hero_agent_id, uint32_t slot_index, bool disabled = true);
|
||||
|
||||
GWCA_API PetInfo* GetPetInfo(uint32_t owner_agent_id = 0);
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Daybreak.Tools.SkillUpdater;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves skill names to their canonical wiki image CDN URL via the
|
||||
/// MediaWiki API (<c>prop=imageinfo</c>). Avoids the runtime
|
||||
/// <c>Special:FilePath/...</c> redirect that has proven flaky.
|
||||
///
|
||||
/// Each skill carries one or more candidate base names (e.g. shouts ship with
|
||||
/// both the unquoted and quoted form). For each base name we try the
|
||||
/// high-resolution <c>"<Base> (large).jpg"</c> first, then plain
|
||||
/// <c>"<Base>.jpg"</c>. Skills with no matching file get an empty URL,
|
||||
/// matching the historical convention for PvP variants.
|
||||
/// </summary>
|
||||
internal sealed class IconResolver(WikiHttpClient client)
|
||||
{
|
||||
private const string ApiBase = "https://wiki.guildwars.com/api.php";
|
||||
|
||||
/// <summary>Maximum titles per <c>prop=imageinfo</c> query.</summary>
|
||||
/// <remarks>
|
||||
/// MediaWiki itself accepts 50, but the wiki's WAF/Cloudflare returns 403
|
||||
/// when the encoded URL grows past ~2 KB (which 50 skill titles routinely
|
||||
/// hit, especially with parentheses and apostrophes). 25 keeps every URL
|
||||
/// under the limit; <see cref="ResolveSliceAsync"/> additionally splits
|
||||
/// on the fly if a single batch still gets rejected.
|
||||
/// </remarks>
|
||||
private const int BatchSize = 25;
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, string>> ResolveAsync(
|
||||
IReadOnlyList<ParsedSkill> skills,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Track candidate (skillName, filename) pairs across passes; a skill
|
||||
// is removed from the work-set as soon as any candidate resolves.
|
||||
var resolved = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var pending = skills
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Name))
|
||||
.GroupBy(s => s.Name, StringComparer.Ordinal)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine($"-> Resolving icon URLs for {pending.Count} skills…");
|
||||
|
||||
// Build the (base name, suffix) sweep order. We try high-res first so
|
||||
// a skill that has both ends up with the larger asset.
|
||||
var maxCandidates = pending.Max(s => s.IconBaseNames.Count);
|
||||
if (maxCandidates == 0)
|
||||
{
|
||||
maxCandidates = 1;
|
||||
}
|
||||
|
||||
for (var candidateIdx = 0; candidateIdx < maxCandidates; candidateIdx++)
|
||||
{
|
||||
foreach (var suffix in new[] { " (large)", string.Empty })
|
||||
{
|
||||
var work = pending
|
||||
.Where(s => !resolved.ContainsKey(s.Name))
|
||||
.Where(s => candidateIdx < s.IconBaseNames.Count
|
||||
|| (candidateIdx == 0 && s.IconBaseNames.Count == 0))
|
||||
.Select(s => (
|
||||
SkillName: s.Name,
|
||||
BaseName: s.IconBaseNames.Count == 0 ? s.Name : s.IconBaseNames[candidateIdx]))
|
||||
.ToList();
|
||||
|
||||
if (work.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var label = candidateIdx == 0
|
||||
? (suffix == " (large)" ? "(large)" : "fallback")
|
||||
: (suffix == " (large)" ? $"alt#{candidateIdx} (large)" : $"alt#{candidateIdx} fallback");
|
||||
await ResolveBatchAsync(work, suffix, resolved, label, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
var unresolved = pending.Count - resolved.Count;
|
||||
Console.WriteLine($" icons resolved: {resolved.Count} hit, {unresolved} unresolved (empty url)");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private async Task ResolveBatchAsync(
|
||||
IReadOnlyList<(string SkillName, string BaseName)> work,
|
||||
string suffix,
|
||||
Dictionary<string, string> resolved,
|
||||
string passLabel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var totalBatches = (work.Count + BatchSize - 1) / BatchSize;
|
||||
var batchIndex = 0;
|
||||
for (var offset = 0; offset < work.Count; offset += BatchSize)
|
||||
{
|
||||
batchIndex++;
|
||||
var slice = new List<(string SkillName, string BaseName)>();
|
||||
for (var i = offset; i < Math.Min(offset + BatchSize, work.Count); i++)
|
||||
{
|
||||
slice.Add(work[i]);
|
||||
}
|
||||
|
||||
var found = await ResolveSliceAsync(slice, suffix, resolved, cancellationToken);
|
||||
Console.WriteLine($" batch {batchIndex,2}/{totalBatches} ({passLabel}) … {found} resolved");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends one <c>prop=imageinfo</c> query for the slice and falls back to
|
||||
/// halving + retry if the wiki's WAF rejects the URL with 403.
|
||||
/// </summary>
|
||||
private async Task<int> ResolveSliceAsync(
|
||||
IReadOnlyList<(string SkillName, string BaseName)> work,
|
||||
string suffix,
|
||||
Dictionary<string, string> resolved,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (work.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var titleToSkill = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var titles = new List<string>(work.Count);
|
||||
foreach (var (skillName, baseName) in work)
|
||||
{
|
||||
var title = $"File:{baseName}{suffix}.jpg";
|
||||
titles.Add(title);
|
||||
titleToSkill[NormalizeWikiTitle(title)] = skillName;
|
||||
}
|
||||
|
||||
var url = BuildImageInfoUrl(titles);
|
||||
string json;
|
||||
try
|
||||
{
|
||||
json = await client.GetStringAsync(url, cancellationToken);
|
||||
}
|
||||
catch (HttpRequestException ex) when (IsForbidden(ex) && work.Count > 1)
|
||||
{
|
||||
var mid = work.Count / 2;
|
||||
var left = work.Take(mid).ToList();
|
||||
var right = work.Skip(mid).ToList();
|
||||
var l = await ResolveSliceAsync(left, suffix, resolved, cancellationToken);
|
||||
var r = await ResolveSliceAsync(right, suffix, resolved, cancellationToken);
|
||||
return l + r;
|
||||
}
|
||||
|
||||
return ExtractUrls(json, titleToSkill, resolved);
|
||||
}
|
||||
|
||||
private static bool IsForbidden(HttpRequestException ex) =>
|
||||
ex.Message.Contains("403", StringComparison.Ordinal);
|
||||
|
||||
private static int ExtractUrls(string json, IReadOnlyDictionary<string, string> titleToSkill, Dictionary<string, string> resolved)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("query", out var query) ||
|
||||
!query.TryGetProperty("pages", out var pages))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var hit = 0;
|
||||
// formatversion=2 normalises titles for us; map them back via the lookup table.
|
||||
var rewrites = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (query.TryGetProperty("normalized", out var normalized))
|
||||
{
|
||||
foreach (var entry in normalized.EnumerateArray())
|
||||
{
|
||||
if (entry.TryGetProperty("from", out var fromEl) &&
|
||||
entry.TryGetProperty("to", out var toEl))
|
||||
{
|
||||
rewrites[NormalizeWikiTitle(toEl.GetString() ?? "")] = NormalizeWikiTitle(fromEl.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var page in pages.EnumerateArray())
|
||||
{
|
||||
if (!page.TryGetProperty("title", out var titleEl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var canonical = NormalizeWikiTitle(titleEl.GetString() ?? "");
|
||||
var lookupKey = rewrites.GetValueOrDefault(canonical, canonical);
|
||||
if (!titleToSkill.TryGetValue(lookupKey, out var skillName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!page.TryGetProperty("imageinfo", out var imageInfo) || imageInfo.GetArrayLength() == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!imageInfo[0].TryGetProperty("url", out var urlEl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var url = urlEl.GetString();
|
||||
if (!string.IsNullOrEmpty(url))
|
||||
{
|
||||
resolved[skillName] = url;
|
||||
hit++;
|
||||
}
|
||||
}
|
||||
|
||||
return hit;
|
||||
}
|
||||
|
||||
/// <summary>MediaWiki normalises whitespace and URL-encodes nothing in titles.</summary>
|
||||
private static string NormalizeWikiTitle(string title) => title.Replace('_', ' ').Trim();
|
||||
|
||||
private static string BuildImageInfoUrl(IReadOnlyList<string> titles)
|
||||
{
|
||||
var encoder = UrlEncoder.Default;
|
||||
var encodedTitles = encoder.Encode(string.Join("|", titles));
|
||||
return $"{ApiBase}?action=query&format=json&formatversion=2&titles={encodedTitles}&prop=imageinfo&iiprop=url";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Daybreak.Tools.SkillUpdater;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private const string UserAgent = "Daybreak-SkillUpdater/1.0 (+https://github.com/gwdevhub/Daybreak)";
|
||||
|
||||
public static async Task<int> Main()
|
||||
{
|
||||
using var cancellationSource = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
cancellationSource.Cancel();
|
||||
};
|
||||
|
||||
var repoRoot = LocateRepoRoot();
|
||||
var skillFile = Path.Combine(repoRoot, "Daybreak.Shared", "Models", "Guildwars", "Skill.g.cs");
|
||||
|
||||
Console.WriteLine($"Repo root: {repoRoot}");
|
||||
Console.WriteLine($"Output file: {skillFile}");
|
||||
Console.WriteLine();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new WikiHttpClient(UserAgent);
|
||||
var enumerator = new SkillEnumerator(client);
|
||||
|
||||
Console.WriteLine("Enumerating skills from wiki…");
|
||||
var skills = await enumerator.EnumerateAsync(cancellationSource.Token);
|
||||
Console.WriteLine($"Collected {skills.Count} skills.");
|
||||
Console.WriteLine();
|
||||
|
||||
var iconResolver = new IconResolver(client);
|
||||
var iconUrls = await iconResolver.ResolveAsync(skills, cancellationSource.Token);
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("Rendering Skill.g.cs…");
|
||||
var (content, warnings) = SkillFileWriter.Render(skills, iconUrls);
|
||||
await File.WriteAllTextAsync(skillFile, content, cancellationSource.Token);
|
||||
Console.WriteLine($"Wrote {content.Length:N0} chars to {skillFile}");
|
||||
foreach (var warn in warnings)
|
||||
{
|
||||
Console.Error.WriteLine($" ! {warn}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Console.Error.WriteLine("aborted.");
|
||||
return 130;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"FATAL: {ex}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static string LocateRepoRoot()
|
||||
{
|
||||
// The compiled tool sits at <repo>/Tools/SkillUpdater/bin/<config>/<tfm>/.
|
||||
// Walk upwards until we find Daybreak.slnx — works for both `dotnet run`
|
||||
// and a published binary launched from anywhere inside the repo.
|
||||
var dir = new DirectoryInfo(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!);
|
||||
while (dir is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "Daybreak.slnx")))
|
||||
{
|
||||
return dir.FullName;
|
||||
}
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not locate Daybreak.slnx walking upwards from the assembly location.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# SkillUpdater
|
||||
|
||||
One-shot console tool that regenerates `Daybreak.Shared/Models/Guildwars/Skill.g.cs`
|
||||
from <https://wiki.guildwars.com>. The wiki is the single source of truth for skill
|
||||
data — this tool does not read the existing `Skill.g.cs`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
dotnet run --project Tools/SkillUpdater
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
1. Enumerates every skill page via the MediaWiki API
|
||||
(`generator=categorymembers` over the five campaign categories
|
||||
`Core_skills`, `Prophecies_skills`, `Factions_skills`, `Nightfall_skills`,
|
||||
`Eye_of_the_North_skills`) with `prop=revisions&rvslots=main&rvprop=content`.
|
||||
2. Filters to pages containing a `{{Skill infobox}}` template.
|
||||
3. Parses each infobox via the tool-local `WikiSkillParser`.
|
||||
4. Resolves the canonical CDN URL for each skill icon by batched
|
||||
`prop=imageinfo` queries — preferring the high-resolution
|
||||
`<Name> (large).jpg`, falling back to `<Name>.jpg`, leaving the URL
|
||||
empty when neither file exists.
|
||||
5. Writes a sorted, grouped `Skill.g.cs`.
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Daybreak.Tools.SkillUpdater;
|
||||
|
||||
/// <summary>
|
||||
/// Walks the five per-campaign "Skills" categories on the wiki using
|
||||
/// <c>generator=categorymembers</c> joined with
|
||||
/// <c>prop=revisions&rvslots=main&rvprop=content</c>. Pagination via
|
||||
/// <c>gcmcontinue</c>. Pages without a <c>{{Skill infobox}}</c> block (category
|
||||
/// indexes, "List of …" pages) are skipped. De-duplicates by page id.
|
||||
/// </summary>
|
||||
internal sealed class SkillEnumerator(WikiHttpClient client)
|
||||
{
|
||||
private const string ApiBase = "https://wiki.guildwars.com/api.php";
|
||||
|
||||
private static readonly string[] CampaignCategories =
|
||||
[
|
||||
"Core_skills",
|
||||
"Prophecies_skills",
|
||||
"Factions_skills",
|
||||
"Nightfall_skills",
|
||||
"Eye_of_the_North_skills",
|
||||
];
|
||||
|
||||
public async Task<IReadOnlyList<ParsedSkill>> EnumerateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var seen = new Dictionary<int, ParsedSkill>();
|
||||
foreach (var category in CampaignCategories)
|
||||
{
|
||||
Console.WriteLine($"-> Category:{category}");
|
||||
var newCount = 0;
|
||||
var skipped = 0;
|
||||
await foreach (var (pageId, skill, isSkill) in this.EnumerateCategoryAsync(category, cancellationToken))
|
||||
{
|
||||
if (!isSkill)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.TryAdd(pageId, skill!))
|
||||
{
|
||||
newCount++;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($" category complete: +{newCount} new (skipped {skipped} non-skill pages, running total {seen.Count})");
|
||||
}
|
||||
|
||||
return [.. seen.Values];
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<(int PageId, ParsedSkill? Skill, bool IsSkill)> EnumerateCategoryAsync(
|
||||
string category,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
string? gcmcontinue = null;
|
||||
var batchIndex = 0;
|
||||
do
|
||||
{
|
||||
batchIndex++;
|
||||
var url = BuildCategoryQueryUrl(category, gcmcontinue);
|
||||
Console.Write($" batch {batchIndex,2} … ");
|
||||
var json = await client.GetStringAsync(url, cancellationToken);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
gcmcontinue = root.TryGetProperty("continue", out var cont) && cont.TryGetProperty("gcmcontinue", out var gcm)
|
||||
? gcm.GetString()
|
||||
: null;
|
||||
|
||||
var batchSkills = 0;
|
||||
var batchSkipped = 0;
|
||||
if (root.TryGetProperty("query", out var query) &&
|
||||
query.TryGetProperty("pages", out var pages))
|
||||
{
|
||||
foreach (var page in pages.EnumerateArray())
|
||||
{
|
||||
if (TryProjectSkill(page, out var pageId, out var skill))
|
||||
{
|
||||
batchSkills++;
|
||||
yield return (pageId, skill, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
batchSkipped++;
|
||||
yield return (0, null, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"{batchSkills} skills (+{batchSkipped} skipped){(gcmcontinue is null ? " [last]" : string.Empty)}");
|
||||
}
|
||||
while (gcmcontinue is not null);
|
||||
}
|
||||
|
||||
private static bool TryProjectSkill(JsonElement page, out int pageId, out ParsedSkill skill)
|
||||
{
|
||||
pageId = 0;
|
||||
skill = null!;
|
||||
if (!page.TryGetProperty("title", out var titleEl) ||
|
||||
!page.TryGetProperty("pageid", out var pageIdEl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var title = titleEl.GetString();
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!page.TryGetProperty("revisions", out var revisions) || revisions.GetArrayLength() == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string? content = null;
|
||||
if (revisions[0].TryGetProperty("slots", out var slots) &&
|
||||
slots.TryGetProperty("main", out var main) &&
|
||||
main.TryGetProperty("content", out var contentEl))
|
||||
{
|
||||
content = contentEl.GetString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content) ||
|
||||
!content.Contains("{{Skill infobox", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!WikiSkillParser.TryParse(content, out var parsed))
|
||||
{
|
||||
Console.Error.WriteLine($" ! parse failed for '{title}' (pageid {pageIdEl.GetInt32()})");
|
||||
return false;
|
||||
}
|
||||
|
||||
// The infobox `name` field can omit the `(PvP)` suffix even though the
|
||||
// page title carries it. Prefer the page title as the canonical name,
|
||||
// stripping the surrounding quotes some shout titles carry — and
|
||||
// remember the quoted form so the icon resolver can try it as well
|
||||
// (image files keep the quotes in their filenames).
|
||||
var rawTitle = title.Trim();
|
||||
var isQuoted = rawTitle.Length >= 2 && rawTitle[0] == '"' && rawTitle[^1] == '"';
|
||||
var canonicalTitle = isQuoted ? rawTitle[1..^1] : rawTitle;
|
||||
|
||||
if (!string.Equals(parsed.Name, canonicalTitle, StringComparison.Ordinal))
|
||||
{
|
||||
parsed = parsed with { Name = canonicalTitle };
|
||||
}
|
||||
|
||||
var iconBaseNames = isQuoted
|
||||
? (IReadOnlyList<string>)[canonicalTitle, rawTitle]
|
||||
: [canonicalTitle];
|
||||
parsed = parsed with { IconBaseNames = iconBaseNames };
|
||||
|
||||
pageId = pageIdEl.GetInt32();
|
||||
skill = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string BuildCategoryQueryUrl(string category, string? gcmcontinue)
|
||||
{
|
||||
var encoder = UrlEncoder.Default;
|
||||
var continueParam = gcmcontinue is null ? string.Empty : $"&gcmcontinue={encoder.Encode(gcmcontinue)}";
|
||||
return $"{ApiBase}?action=query&format=json&formatversion=2"
|
||||
+ $"&generator=categorymembers&gcmtitle=Category:{encoder.Encode(category)}"
|
||||
+ "&gcmlimit=50&gcmtype=page&gcmnamespace=0"
|
||||
+ "&prop=revisions&rvslots=main&rvprop=content"
|
||||
+ continueParam;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>$(TargetFrameworkVersion)</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>Daybreak.Tools.SkillUpdater</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- Standalone developer tool. Intentionally has no project references -->
|
||||
<!-- so a broken Skill.g.cs in Daybreak.Shared can never block re-running it. -->
|
||||
<Platforms>AnyCPU</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace Daybreak.Tools.SkillUpdater;
|
||||
|
||||
/// <summary>
|
||||
/// Polite HTTP client for the Guild Wars wiki.
|
||||
/// * Single in-flight request (serialised behind a SemaphoreSlim).
|
||||
/// * 250 ms minimum spacing between requests.
|
||||
/// * Exponential back-off on 429/5xx (3 retries: 1s → 2s → 4s).
|
||||
/// * Custom User-Agent identifying Daybreak.
|
||||
/// </summary>
|
||||
public sealed class WikiHttpClient : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan MinSpacing = TimeSpan.FromMilliseconds(250);
|
||||
private static readonly TimeSpan[] BackoffDelays =
|
||||
[
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(2),
|
||||
TimeSpan.FromSeconds(4),
|
||||
];
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
private DateTimeOffset nextSlot = DateTimeOffset.MinValue;
|
||||
|
||||
public WikiHttpClient(string userAgent)
|
||||
{
|
||||
this.httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(60),
|
||||
};
|
||||
this.httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
this.httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
}
|
||||
|
||||
public async Task<string> GetStringAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
await this.gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (now < this.nextSlot)
|
||||
{
|
||||
await Task.Delay(this.nextSlot - now, cancellationToken);
|
||||
}
|
||||
|
||||
using var response = await this.httpClient.GetAsync(url, cancellationToken);
|
||||
this.nextSlot = DateTimeOffset.UtcNow + MinSpacing;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (ShouldRetry(response.StatusCode) && attempt < BackoffDelays.Length)
|
||||
{
|
||||
var delay = response.Headers.RetryAfter?.Delta ?? BackoffDelays[attempt];
|
||||
Console.Error.WriteLine($" ! {response.StatusCode}, backing off {delay.TotalSeconds:F0}s (attempt {attempt + 1}/{BackoffDelays.Length})");
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new HttpRequestException($"GET {url} → {(int)response.StatusCode} {response.StatusCode}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldRetry(HttpStatusCode code) =>
|
||||
code == HttpStatusCode.TooManyRequests ||
|
||||
(int)code >= 500;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this.httpClient.Dispose();
|
||||
this.gate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Daybreak.Tools.SkillUpdater;
|
||||
|
||||
/// <summary>
|
||||
/// A skill in the exact shape the writer will emit it: every value is already
|
||||
/// the literal C# token it should appear as in <c>Skill.g.cs</c>. This is
|
||||
/// deliberately tool-specific — the runtime <c>WikiService</c> has its own
|
||||
/// parser in <c>Daybreak.Shared</c> built around typed model objects.
|
||||
/// </summary>
|
||||
public sealed record ParsedSkill(
|
||||
int Id,
|
||||
string Name,
|
||||
string CampaignIdentifier,
|
||||
string ProfessionIdentifier,
|
||||
string AttributeIdentifier,
|
||||
bool PvEOnly,
|
||||
bool PvP,
|
||||
bool Elite,
|
||||
string TypeExpression,
|
||||
double? Energy,
|
||||
double? Activation,
|
||||
double? Recharge,
|
||||
double? Overcast,
|
||||
double? Adrenaline,
|
||||
double? Sacrifice,
|
||||
double? Upkeep,
|
||||
string Description,
|
||||
string ConciseDescription)
|
||||
{
|
||||
/// <summary>
|
||||
/// Extra ids the wiki listed alongside <see cref="Id"/> in the same
|
||||
/// <c>id =</c> field (e.g. <c>id = 1954, 2097</c> for Luxon/Kurzick
|
||||
/// "Save Yourselves!"). Each one becomes its own emitted skill.
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> AdditionalIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Filenames (without the <c>File:</c> prefix) the icon resolver should
|
||||
/// try, in priority order. Defaults to just <see cref="Name"/>; shouts
|
||||
/// also include the quoted form because their image files preserve the
|
||||
/// surrounding quotes (e.g. <c>"Save Yourselves!".jpg</c>).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> IconBaseNames { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a wiki page's <c>{{Skill infobox …}}</c> into a <see cref="ParsedSkill"/>
|
||||
/// directly addressed at the codegen output. Every helper here exists because
|
||||
/// the codegen step needs it; nothing is exposed beyond <see cref="TryParse"/>.
|
||||
/// </summary>
|
||||
internal static partial class WikiSkillParser
|
||||
{
|
||||
public static bool TryParse(string? wikiText, out ParsedSkill skill)
|
||||
{
|
||||
skill = null!;
|
||||
if (string.IsNullOrWhiteSpace(wikiText))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cleaned = CleanWikiText(wikiText);
|
||||
var body = ExtractInfoboxBody(cleaned);
|
||||
if (body is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fields = ExtractFields(body);
|
||||
|
||||
string Get(string key) => fields.TryGetValue(key, out var v) ? v : string.Empty;
|
||||
bool YesFlag(string key) => Get(key) is var v && v.Length > 0 && v[0] is 'y' or 'Y';
|
||||
|
||||
var allIds = ParseAllIds(Get("id"));
|
||||
|
||||
skill = new ParsedSkill(
|
||||
Id: allIds.Count > 0 ? allIds[0] : 0,
|
||||
Name: NormalizeName(Get("name")),
|
||||
CampaignIdentifier: ResolveCampaign(Get("campaign")),
|
||||
ProfessionIdentifier: ResolveProfession(Get("profession")),
|
||||
AttributeIdentifier: ResolveAttribute(Get("attribute")),
|
||||
PvEOnly: YesFlag("pve-only"),
|
||||
PvP: YesFlag("is-pvp"),
|
||||
Elite: YesFlag("elite"),
|
||||
TypeExpression: ResolveSkillType(Get("type")),
|
||||
Energy: ParseNumber(Get("energy")),
|
||||
Activation: ParseNumber(Get("activation")),
|
||||
Recharge: ParseNumber(Get("recharge")),
|
||||
Overcast: ParseNumber(Get("overcast")),
|
||||
Adrenaline: ParseNumber(Get("adrenaline")),
|
||||
Sacrifice: ParseNumber(Get("sacrifice")),
|
||||
Upkeep: ParseNumber(Get("upkeep")),
|
||||
Description: Get("description"),
|
||||
ConciseDescription: Get("concise description"));
|
||||
if (allIds.Count > 1)
|
||||
{
|
||||
skill = skill with { AdditionalIds = [.. allIds.Skip(1)] };
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips wiki markup the codegen output should not carry over (links,
|
||||
/// fraction templates, ranges, italics, HTML).
|
||||
/// </summary>
|
||||
private static string CleanWikiText(string wikiText)
|
||||
{
|
||||
var c = wikiText;
|
||||
c = HalfFractionRegex().Replace(c, "¹⁄₂");
|
||||
c = QuarterFractionRegex().Replace(c, "¹⁄₄");
|
||||
c = ThreeQuarterFractionRegex().Replace(c, "³⁄₄");
|
||||
c = ThreeOverTwoFractionRegex().Replace(c, "³⁄₂");
|
||||
c = DecimalRegex().Replace(c, "$1");
|
||||
c = GreenRangeRegex().Replace(c, "$1…$2");
|
||||
c = GreenRange2Regex().Replace(c, "$1…$2");
|
||||
c = GrayRangeRegex().Replace(c, "$1…$2");
|
||||
c = LinkWithDisplayTextRegex().Replace(c, "$2");
|
||||
c = SimpleLinkRegex().Replace(c, "$1");
|
||||
c = GrayTextRegex().Replace(c, "");
|
||||
c = GreyTextRegex().Replace(c, "");
|
||||
c = SicTextRegex().Replace(c, "");
|
||||
c = BoldTextRegex().Replace(c, "$1");
|
||||
c = ItalicTextRegex().Replace(c, "$1");
|
||||
c = HtmlTagRegex().Replace(c, "");
|
||||
return c;
|
||||
}
|
||||
|
||||
private static string? ExtractInfoboxBody(string wikiText)
|
||||
{
|
||||
const string startPattern = "{{Skill infobox";
|
||||
var startIndex = wikiText.IndexOf(startPattern, StringComparison.OrdinalIgnoreCase);
|
||||
if (startIndex == -1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var pipeIndex = wikiText.IndexOf('|', startIndex);
|
||||
if (pipeIndex == -1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var braceCount = 2;
|
||||
var index = startIndex + startPattern.Length;
|
||||
while (index < wikiText.Length && braceCount > 0)
|
||||
{
|
||||
if (wikiText[index] == '{' && index + 1 < wikiText.Length && wikiText[index + 1] == '{') { braceCount += 2; index += 2; }
|
||||
else if (wikiText[index] == '}' && index + 1 < wikiText.Length && wikiText[index + 1] == '}') { braceCount -= 2; index += 2; }
|
||||
else { index++; }
|
||||
}
|
||||
|
||||
if (braceCount != 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return wikiText.Substring(pipeIndex + 1, index - pipeIndex - 3).Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks the body manually so that values containing pipes inside nested
|
||||
/// templates (<c>{{gr|5|11}}</c>) or links don't get truncated.
|
||||
/// </summary>
|
||||
private static Dictionary<string, string> ExtractFields(string body)
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var i = 0;
|
||||
while (i < body.Length)
|
||||
{
|
||||
while (i < body.Length && (body[i] == '|' || char.IsWhiteSpace(body[i])))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
var nameStart = i;
|
||||
while (i < body.Length && body[i] != '=' && body[i] != '|')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i >= body.Length || body[i] != '=')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = body[nameStart..i].Trim();
|
||||
i++;
|
||||
|
||||
var valueStart = i;
|
||||
int braceDepth = 0, bracketDepth = 0;
|
||||
while (i < body.Length)
|
||||
{
|
||||
var c = body[i];
|
||||
if (c == '{' && i + 1 < body.Length && body[i + 1] == '{') { braceDepth++; i += 2; continue; }
|
||||
if (c == '}' && i + 1 < body.Length && body[i + 1] == '}') { braceDepth--; i += 2; continue; }
|
||||
if (c == '[' && i + 1 < body.Length && body[i + 1] == '[') { bracketDepth++; i += 2; continue; }
|
||||
if (c == ']' && i + 1 < body.Length && body[i + 1] == ']') { bracketDepth--; i += 2; continue; }
|
||||
if (c == '|' && braceDepth == 0 && bracketDepth == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
var value = WhitespaceRegex().Replace(body[valueStart..i].Trim(), " ").Trim();
|
||||
fields[name] = value;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Numeric infobox value → <c>double?</c>. Empty / non-numeric text
|
||||
/// (percentages, "morale boost", etc.) becomes <c>null</c>.
|
||||
/// </summary>
|
||||
private static double? ParseNumber(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var s = raw.Trim();
|
||||
|
||||
// Plain mixed/unit fractions glyph-substituted by CleanWikiText.
|
||||
var fraction = s.Contains("¹⁄₂") ? 0.5
|
||||
: s.Contains("¹⁄₄") ? 0.25
|
||||
: s.Contains("³⁄₄") ? 0.75
|
||||
: s.Contains("³⁄₂") ? 1.5
|
||||
: (double?)null;
|
||||
|
||||
if (fraction is double frac)
|
||||
{
|
||||
var fractionStart = s.IndexOfAny(['¹', '³']);
|
||||
if (fractionStart > 0 &&
|
||||
double.TryParse(s[..fractionStart].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var whole))
|
||||
{
|
||||
return whole + frac;
|
||||
}
|
||||
|
||||
return frac;
|
||||
}
|
||||
|
||||
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d))
|
||||
{
|
||||
return d;
|
||||
}
|
||||
|
||||
if (s.EndsWith('%') &&
|
||||
double.TryParse(s[..^1].TrimEnd('+'), NumberStyles.Float, CultureInfo.InvariantCulture, out var pct))
|
||||
{
|
||||
return pct / 100.0;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Some skill infoboxes carry multiple ids in one field
|
||||
/// (e.g. <c>id = 1954, 2097</c> for Luxon/Kurzick variants of "Save Yourselves!").
|
||||
/// We return every integer found in source order; the writer turns each
|
||||
/// one into its own emitted <c>Skill</c> entry.
|
||||
/// </summary>
|
||||
private static List<int> ParseAllIds(string raw)
|
||||
{
|
||||
var ids = new List<int>();
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return ids;
|
||||
}
|
||||
|
||||
foreach (Match match in FirstIntegerRegex().Matches(raw))
|
||||
{
|
||||
if (int.TryParse(match.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
|
||||
{
|
||||
ids.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips the surrounding double quotes some wiki page titles carry
|
||||
/// (shouts, e.g. <c>"Save Yourselves!"</c>) so the C# identifier and
|
||||
/// display name match the historical Daybreak convention.
|
||||
/// </summary>
|
||||
private static string NormalizeName(string raw)
|
||||
{
|
||||
var trimmed = raw.Trim();
|
||||
if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"')
|
||||
{
|
||||
return trimmed[1..^1];
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string ResolveSkillType(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return "SkillType.None";
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var parts = new List<string>();
|
||||
foreach (var token in TypeTokenSplitRegex().Split(raw))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TypeTokens.TryGetValue(token, out var canonical) && seen.Add(canonical))
|
||||
{
|
||||
parts.Add($"SkillType.{canonical}");
|
||||
}
|
||||
}
|
||||
|
||||
return parts.Count == 0 ? "SkillType.None" : string.Join(" | ", parts);
|
||||
}
|
||||
|
||||
private static string ResolveCampaign(string raw) =>
|
||||
Campaigns.GetValueOrDefault(raw.Trim(), "None");
|
||||
|
||||
private static string ResolveProfession(string raw) =>
|
||||
Professions.GetValueOrDefault(raw.Trim(), "None");
|
||||
|
||||
private static string ResolveAttribute(string raw) =>
|
||||
Attributes.GetValueOrDefault(raw.Trim(), "None");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The lookup tables intentionally live in this file, since they're a
|
||||
// codegen detail of the parser — the runtime types in Daybreak.Shared
|
||||
// already have their own canonical name lists.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static readonly Dictionary<string, string> Campaigns = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[""] = "None",
|
||||
["None"] = "None",
|
||||
["Core"] = "Core",
|
||||
["Prophecies"] = "Prophecies",
|
||||
["Factions"] = "Factions",
|
||||
["Nightfall"] = "Nightfall",
|
||||
["Eye of the North"] = "EyeOfTheNorth",
|
||||
["EotN"] = "EyeOfTheNorth",
|
||||
["Bonus Mission Pack"] = "BonusMissionPack",
|
||||
["BMP"] = "BonusMissionPack",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> Professions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[""] = "None",
|
||||
["None"] = "None",
|
||||
["Common"] = "None",
|
||||
["Warrior"] = "Warrior",
|
||||
["Ranger"] = "Ranger",
|
||||
["Monk"] = "Monk",
|
||||
["Necromancer"] = "Necromancer",
|
||||
["Mesmer"] = "Mesmer",
|
||||
["Elementalist"] = "Elementalist",
|
||||
["Assassin"] = "Assassin",
|
||||
["Ritualist"] = "Ritualist",
|
||||
["Paragon"] = "Paragon",
|
||||
["Dervish"] = "Dervish",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> Attributes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[""] = "None",
|
||||
["None"] = "None",
|
||||
["Fast Casting"] = "FastCasting",
|
||||
["Illusion Magic"] = "IllusionMagic",
|
||||
["Domination Magic"] = "DominationMagic",
|
||||
["Inspiration Magic"] = "InspirationMagic",
|
||||
["Blood Magic"] = "BloodMagic",
|
||||
["Death Magic"] = "DeathMagic",
|
||||
["Soul Reaping"] = "SoulReaping",
|
||||
["Curses"] = "Curses",
|
||||
["Air Magic"] = "AirMagic",
|
||||
["Earth Magic"] = "EarthMagic",
|
||||
["Fire Magic"] = "FireMagic",
|
||||
["Water Magic"] = "WaterMagic",
|
||||
["Energy Storage"] = "EnergyStorage",
|
||||
["Healing Prayers"] = "HealingPrayers",
|
||||
["Smiting Prayers"] = "SmitingPrayers",
|
||||
["Protection Prayers"] = "ProtectionPrayers",
|
||||
["Divine Favor"] = "DivineFavor",
|
||||
["Strength"] = "Strength",
|
||||
["Axe Mastery"] = "AxeMastery",
|
||||
["Hammer Mastery"] = "HammerMastery",
|
||||
["Swordsmanship"] = "Swordsmanship",
|
||||
["Tactics"] = "Tactics",
|
||||
["Beast Mastery"] = "BeastMastery",
|
||||
["Expertise"] = "Expertise",
|
||||
["Wilderness Survival"] = "WildernessSurvival",
|
||||
["Marksmanship"] = "Marksmanship",
|
||||
["Critical Strikes"] = "CriticalStrikes",
|
||||
["Dagger Mastery"] = "DaggerMastery",
|
||||
["Deadly Arts"] = "DeadlyArts",
|
||||
["Shadow Arts"] = "ShadowArts",
|
||||
["Spawning Power"] = "SpawningPower",
|
||||
["Channeling Magic"] = "ChannelingMagic",
|
||||
["Communing"] = "Communing",
|
||||
["Restoration Magic"] = "RestorationMagic",
|
||||
["Spear Mastery"] = "SpearMastery",
|
||||
["Command"] = "Command",
|
||||
["Motivation"] = "Motivation",
|
||||
["Leadership"] = "Leadership",
|
||||
["Scythe Mastery"] = "ScytheMastery",
|
||||
["Wind Prayers"] = "WindPrayers",
|
||||
["Earth Prayers"] = "EarthPrayers",
|
||||
["Mysticism"] = "Mysticism",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> TypeTokens = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["skill"] = "Skill",
|
||||
["touch"] = "Touch",
|
||||
["spell"] = "Spell",
|
||||
["hex"] = "Hex",
|
||||
["enchantment"] = "Enchantment",
|
||||
["flash"] = "Flash",
|
||||
["echo"] = "Echo",
|
||||
["ward"] = "Ward",
|
||||
["glyph"] = "Glyph",
|
||||
["well"] = "Well",
|
||||
["ritual"] = "Ritual",
|
||||
["binding"] = "Binding",
|
||||
["nature"] = "Nature",
|
||||
["ebon"] = "EbonVanguard",
|
||||
["vanguard"] = "EbonVanguard",
|
||||
["item"] = "Item",
|
||||
["weapon"] = "Weapon",
|
||||
["attack"] = "Attack",
|
||||
["axe"] = "Axe",
|
||||
["bow"] = "Bow",
|
||||
["melee"] = "Melee",
|
||||
["hammer"] = "Hammer",
|
||||
["sword"] = "Sword",
|
||||
["spear"] = "Spear",
|
||||
["pet"] = "Pet",
|
||||
["lead"] = "Lead",
|
||||
["off-hand"] = "OffHand",
|
||||
["offhand"] = "OffHand",
|
||||
["dual"] = "Dual",
|
||||
["scythe"] = "Scythe",
|
||||
["ranged"] = "Ranged",
|
||||
["chant"] = "Chant",
|
||||
["shout"] = "Shout",
|
||||
["signet"] = "Signet",
|
||||
["preparation"] = "Preparation",
|
||||
["stance"] = "Stance",
|
||||
["form"] = "Form",
|
||||
["trap"] = "Trap",
|
||||
};
|
||||
|
||||
[GeneratedRegex(@"\{\{1/2\}\}", RegexOptions.IgnoreCase)] private static partial Regex HalfFractionRegex();
|
||||
[GeneratedRegex(@"\{\{1/4\}\}", RegexOptions.IgnoreCase)] private static partial Regex QuarterFractionRegex();
|
||||
[GeneratedRegex(@"\{\{3/4\}\}", RegexOptions.IgnoreCase)] private static partial Regex ThreeQuarterFractionRegex();
|
||||
[GeneratedRegex(@"\{\{3/2\}\}", RegexOptions.IgnoreCase)] private static partial Regex ThreeOverTwoFractionRegex();
|
||||
[GeneratedRegex(@"\{\{(\d+(?:\.\d+)?)\}\}")] private static partial Regex DecimalRegex();
|
||||
[GeneratedRegex(@"\{\{gr\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)] private static partial Regex GreenRangeRegex();
|
||||
[GeneratedRegex(@"\{\{gr2\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)] private static partial Regex GreenRange2Regex();
|
||||
[GeneratedRegex(@"\{\{gray\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)] private static partial Regex GrayRangeRegex();
|
||||
[GeneratedRegex(@"\{\{sic(?:\|([^}]*))?\}\}", RegexOptions.IgnoreCase)] private static partial Regex SicTextRegex();
|
||||
[GeneratedRegex(@"\[\[([^\]|]+)\]\]")] private static partial Regex SimpleLinkRegex();
|
||||
[GeneratedRegex(@"\[\[([^|\]]+)\|([^\]]+)\]\]")] private static partial Regex LinkWithDisplayTextRegex();
|
||||
[GeneratedRegex(@"'''([^']+)'''")] private static partial Regex BoldTextRegex();
|
||||
[GeneratedRegex(@"''([^']+)''")] private static partial Regex ItalicTextRegex();
|
||||
[GeneratedRegex(@"<[^>]*>")] private static partial Regex HtmlTagRegex();
|
||||
[GeneratedRegex(@"\s+")] private static partial Regex WhitespaceRegex();
|
||||
[GeneratedRegex(@"\{\{gray\|([^}]+)\}\}", RegexOptions.IgnoreCase)] private static partial Regex GrayTextRegex();
|
||||
[GeneratedRegex(@"\{\{grey\|([^}]+)\}\}", RegexOptions.IgnoreCase)] private static partial Regex GreyTextRegex();
|
||||
[GeneratedRegex(@"[\s\-]+")] private static partial Regex TypeTokenSplitRegex();
|
||||
[GeneratedRegex(@"\d+")] private static partial Regex FirstIntegerRegex();
|
||||
}
|
||||
Reference in New Issue
Block a user