Reorganize skills (#1541)

* Reorganize skills

* Minor fixes
This commit is contained in:
2026-05-15 16:57:27 +02:00
parent 65767d6ab5
commit 089a10a056
31 changed files with 8159 additions and 3580 deletions
+83 -81
View File
@@ -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.GenericValueID popped at line 70
// [NAMESPACE] GWCA.GW.Packet.StoC.JumboMessageType popped at line 82 // [NAMESPACE] GWCA.GW.Packet.StoC.JumboMessageType popped at line 82
// [NAMESPACE] GWCA.GW.Packet.StoC.JumboMessageValue popped at line 99 // [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.PlayerMgr popped at line 56
// [NAMESPACE] GWCA.GW.QuestMgr popped at line 43 // [NAMESPACE] GWCA.GW.QuestMgr popped at line 43
// [NAMESPACE] GWCA.GW.Render popped at line 122 // [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.Inventory: 39 fields [OK]
// GWCA.GW.InventoryTableEntry: 3 fields [OK] // GWCA.GW.InventoryTableEntry: 3 fields [OK]
// GWCA.GW.Item: 26 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.ItemData: 4 fields [OK]
// GWCA.GW.ItemFormula: 5 fields [OK] // GWCA.GW.ItemFormula: 5 fields [OK]
// GWCA.GW.ItemModifier: 1 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." Test_Map_solo_areas, // "This is a debug map to allow multiplayer testing of current solo areas."
Nightfallen_Garden, Nightfallen_Garden,
Churrhir_Fields, Churrhir_Fields,
Beknur_Harbor_outpost, Beknur_Harbor,
Kodonur_Crossroads_cinematic, Kodonur_Crossroads_cinematic,
Rilohn_Refuge_cinematic, Rilohn_Refuge_cinematic,
Pogahn_Passage_cinematic, Pogahn_Passage_cinematic,
@@ -3903,7 +3903,7 @@ public static unsafe partial class GWCA
Lahtenda_Bog, Lahtenda_Bog,
Arbor_Bay, Arbor_Bay,
Issnur_Isles, Issnur_Isles,
Beknur_Harbor, Beknur_Harbor_outpost,
Mehtani_Keys, Mehtani_Keys,
Kodlonu_Hamlet_outpost, Kodlonu_Hamlet_outpost,
Island_of_Shehkah, Island_of_Shehkah,
@@ -6825,9 +6825,9 @@ public static unsafe partial class GWCA
Aura_of_the_Bloodstone1, Aura_of_the_Bloodstone1,
Aura_of_the_Bloodstone2, Aura_of_the_Bloodstone2,
Haunted_Ground, Haunted_Ground,
Asuran_Bodyguard, Asuran_Bodyguard_Rank2,
Asuran_Bodyguard1, Asuran_Bodyguard_Rank3,
Asuran_Bodyguard2, Asuran_Bodyguard_Rank4,
Energy_Channel, Energy_Channel,
Hunt_Rampage, Hunt_Rampage,
Boss_Bounty = 2440, Boss_Bounty = 2440,
@@ -6836,9 +6836,9 @@ public static unsafe partial class GWCA
Hunt_Point_Bonus2, Hunt_Point_Bonus2,
Time_Attack, Time_Attack,
Dwarven_Raider, Dwarven_Raider,
Dwarven_Raider1, Dwarven_Raider_Rank2,
Dwarven_Raider2, Dwarven_Raider_Rank3,
Dwarven_Raider3, Dwarven_Raider_Rank4,
Great_Dwarfs_Blessing, Great_Dwarfs_Blessing,
Hunt_Rampage1, Hunt_Rampage1,
Boss_Bounty1 = 2452, Boss_Bounty1 = 2452,
@@ -6846,19 +6846,19 @@ public static unsafe partial class GWCA
Hunt_Point_Bonus4, Hunt_Point_Bonus4,
Time_Attack1 = 2456, Time_Attack1 = 2456,
Vanguard_Patrol, Vanguard_Patrol,
Vanguard_Patrol1, Vanguard_Patrol_Rank2,
Vanguard_Patrol2, Vanguard_Patrol_Rank3,
Vanguard_Patrol3, Vanguard_Patrol_Rank4,
Vanguard_Commendation, Vanguard_Commendation,
Hunt_Rampage2, Hunt_Rampage2,
Boss_Bounty2 = 2464, Boss_Bounty2 = 2464,
Norn_Hunting_Party = 2469, Norn_Hunting_Party = 2469,
Norn_Hunting_Party1, Norn_Hunting_Party_Rank2,
Norn_Hunting_Party2, Norn_Hunting_Party_Rank3,
Norn_Hunting_Party3, Norn_Hunting_Party_Rank4,
Strength_of_the_Norn, Strength_of_the_Norn,
Hunt_Rampage3, Hunt_Rampage3,
Asuran_Bodyguard3 = 2481, Asuran_Bodyguard = 2481,
Desperate_Howl, Desperate_Howl,
Gloat, Gloat,
Metamorphosis, Metamorphosis,
@@ -9302,45 +9302,47 @@ public static unsafe partial class GWCA
kShowHeroPanel, // 0x100001a3, wparam = hero_id kShowHeroPanel, // 0x100001a3, wparam = hero_id
kMessage_0x1000019e, // 0x100001a4 kMessage_0x1000019e, // 0x100001a4
kMessage_0x1000019f, // 0x100001a5 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 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 kInventoryRelated1, // 0x100001a8, added to GW 2026-02-26
kInventoryRelated2, // 0x100001a9, added to GW 2026-02-26 kInventoryRelated2, // 0x100001a9, added to GW 2026-02-26
kInventoryRelated3, // 0x100001aa, added to GW 2026-02-26 kInventoryRelated3, // 0x100001aa, added to GW 2026-02-26
kEquipItem, // 0x100001ab, wparam = { item_id, agent_id } kInventoryRelated4, // 0x100001ab, added to GW 2026-04-28
kMoveItem, // 0x100001ac, wparam = { item_id, to_bag, to_slot, bool prompt } kEquipItem, // 0x100001ac, wparam = { item_id, agent_id }
kItemRelated_1, // 0x100001ad kMoveItem, // 0x100001ad, wparam = { item_id, to_bag, to_slot, bool prompt }
kItemTooltip, // 0x100001ae kItemRelated_1, // 0x100001ae
kItemRelated_3, // 0x100001af, added to GW 2026-02-26 kItemTooltip, // 0x100001af
kItemRelated_4, // 0x100001b0, added to GW 2026-02-26 kItemRelated_3, // 0x100001b0, added to GW 2026-02-26
kInitiateTrade, // 0x100001b1 kItemRelated_4, // 0x100001b1, added to GW 2026-02-26
kMessage_0x100001a7, // 0x100001b2 kItemRelated_5, // 0x100001b2, added to GW 2026-04-28
kMessage_0x100001a8, // 0x100001b3 kInitiateTrade, // 0x100001b3
kMessage_0x100001a9, // 0x100001b4 kMessage_0x100001a7, // 0x100001b4
kMessage_0x100001aa, // 0x100001b5 kMessage_0x100001a8, // 0x100001b5
kMessage_0x100001ab, // 0x100001b6 kMessage_0x100001a9, // 0x100001b6
kMessage_0x100001ac, // 0x100001b7 kMessage_0x100001aa, // 0x100001b7
kMessage_0x100001ad, // 0x100001b8 kMessage_0x100001ab, // 0x100001b8
kMessage_0x100001ae, // 0x100001b9 kMessage_0x100001ac, // 0x100001b9
kMessage_0x100001af, // 0x100001ba kMessage_0x100001ad, // 0x100001ba
kMessage_0x100001b0, // 0x100001bb kMessage_0x100001ae, // 0x100001bb
kMessage_0x100001b1, // 0x100001bc kMessage_0x100001af, // 0x100001bc
kMessage_0x100001b2, // 0x100001bd kMessage_0x100001b0, // 0x100001bd
kMessage_0x100001b3, // 0x100001be kMessage_0x100001b1, // 0x100001be
kMessage_0x100001b4, // 0x100001bf kMessage_0x100001b2, // 0x100001bf
kMessage_0x100001b5, // 0x100001c0 kMessage_0x100001b3, // 0x100001c0
kInventoryAgentChanged, // 0x100001c1, Triggered when inventory needs updating due to agent change; no args kMessage_0x100001b4, // 0x100001c1
kInventoryRelated_1, // 0x100001c2 kMessage_0x100001b5, // 0x100001c2
kInventoryRelated_2, // 0x100001c3 kInventoryAgentChanged, // 0x100001c3, Triggered when inventory needs updating due to agent change; no args
kMissionStatusRelated, // 0x100001c4 kInventoryRelated_1, // 0x100001c4
kUnused_1c2, // 0x100001c5 kInventoryRelated_2, // 0x100001c5
kCollapseExpandSkillListSection, // 0x100001c6 kMissionStatusRelated, // 0x100001c6
kTemplateRelated_1, // 0x100001c7 kUnused_1c2, // 0x100001c7
kTemplateRelated_2, // 0x100001c8 kCollapseExpandSkillListSection, // 0x100001c8
kPromptSaveTemplate, // 0x100001c9 kTemplateRelated_1, // 0x100001c9
kOpenTemplate, // 0x100001ca, wparam = GW::UI::ChatTemplate* kTemplateRelated_2, // 0x100001ca
kTemplateRelated_3, // 0x100001cb kPromptSaveTemplate, // 0x100001cb
kTemplateRelated_4, // 0x100001cc kOpenTemplate, // 0x100001cc, wparam = GW::UI::ChatTemplate*
kTemplateRelated_3, // 0x100001cd
kTemplateRelated_4, // 0x100001ce
kSendLoadSkillTemplate = 0x30000000 | 0x3, // 0x30000003, wparam = SkillbarMgr::SkillTemplate* kSendLoadSkillTemplate = 0x30000000 | 0x3, // 0x30000003, wparam = SkillbarMgr::SkillTemplate*
kSendPingWeaponSet = 0x30000000 | 0x4, // 0x30000004, wparam = UIPacket::kSendPingWeaponSet* kSendPingWeaponSet = 0x30000000 | 0x4, // 0x30000004, wparam = UIPacket::kSendPingWeaponSet*
kSendMoveItem = 0x30000000 | 0x5, // 0x30000005, wparam = UIPacket::kSendMoveItem* kSendMoveItem = 0x30000000 | 0x5, // 0x30000005, wparam = UIPacket::kSendMoveItem*
@@ -9448,6 +9450,14 @@ namespace Daybreak.API.Interop.GuildWars
public nint NextNode; public nint NextNode;
} }
/// <summary>
/// THash&lt;T&gt; - 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> /// <summary>
/// Inline array of 54 AttributeStruct elements. /// Inline array of 54 AttributeStruct elements.
/// </summary> /// </summary>
@@ -10533,7 +10543,7 @@ namespace Daybreak.API.Interop.GuildWars
Test_Map_solo_areas, Test_Map_solo_areas,
Nightfallen_Garden, Nightfallen_Garden,
Churrhir_Fields, Churrhir_Fields,
Beknur_Harbor_outpost, Beknur_Harbor,
Kodonur_Crossroads_cinematic, Kodonur_Crossroads_cinematic,
Rilohn_Refuge_cinematic, Rilohn_Refuge_cinematic,
Pogahn_Passage_cinematic, Pogahn_Passage_cinematic,
@@ -10563,7 +10573,7 @@ namespace Daybreak.API.Interop.GuildWars
Lahtenda_Bog, Lahtenda_Bog,
Arbor_Bay, Arbor_Bay,
Issnur_Isles, Issnur_Isles,
Beknur_Harbor, Beknur_Harbor_outpost,
Mehtani_Keys, Mehtani_Keys,
Kodlonu_Hamlet_outpost, Kodlonu_Hamlet_outpost,
Island_of_Shehkah, Island_of_Shehkah,
@@ -13485,9 +13495,9 @@ namespace Daybreak.API.Interop.GuildWars
Aura_of_the_Bloodstone1, Aura_of_the_Bloodstone1,
Aura_of_the_Bloodstone2, Aura_of_the_Bloodstone2,
Haunted_Ground, Haunted_Ground,
Asuran_Bodyguard, Asuran_Bodyguard_Rank2,
Asuran_Bodyguard1, Asuran_Bodyguard_Rank3,
Asuran_Bodyguard2, Asuran_Bodyguard_Rank4,
Energy_Channel, Energy_Channel,
Hunt_Rampage, Hunt_Rampage,
Boss_Bounty = 2440, Boss_Bounty = 2440,
@@ -13496,9 +13506,9 @@ namespace Daybreak.API.Interop.GuildWars
Hunt_Point_Bonus2, Hunt_Point_Bonus2,
Time_Attack, Time_Attack,
Dwarven_Raider, Dwarven_Raider,
Dwarven_Raider1, Dwarven_Raider_Rank2,
Dwarven_Raider2, Dwarven_Raider_Rank3,
Dwarven_Raider3, Dwarven_Raider_Rank4,
Great_Dwarfs_Blessing, Great_Dwarfs_Blessing,
Hunt_Rampage1, Hunt_Rampage1,
Boss_Bounty1 = 2452, Boss_Bounty1 = 2452,
@@ -13506,19 +13516,19 @@ namespace Daybreak.API.Interop.GuildWars
Hunt_Point_Bonus4, Hunt_Point_Bonus4,
Time_Attack1 = 2456, Time_Attack1 = 2456,
Vanguard_Patrol, Vanguard_Patrol,
Vanguard_Patrol1, Vanguard_Patrol_Rank2,
Vanguard_Patrol2, Vanguard_Patrol_Rank3,
Vanguard_Patrol3, Vanguard_Patrol_Rank4,
Vanguard_Commendation, Vanguard_Commendation,
Hunt_Rampage2, Hunt_Rampage2,
Boss_Bounty2 = 2464, Boss_Bounty2 = 2464,
Norn_Hunting_Party = 2469, Norn_Hunting_Party = 2469,
Norn_Hunting_Party1, Norn_Hunting_Party_Rank2,
Norn_Hunting_Party2, Norn_Hunting_Party_Rank3,
Norn_Hunting_Party3, Norn_Hunting_Party_Rank4,
Strength_of_the_Norn, Strength_of_the_Norn,
Hunt_Rampage3, Hunt_Rampage3,
Asuran_Bodyguard3 = 2481, Asuran_Bodyguard = 2481,
Desperate_Howl, Desperate_Howl,
Gloat, Gloat,
Metamorphosis, Metamorphosis,
@@ -15348,17 +15358,19 @@ namespace Daybreak.API.Interop.GuildWars
kShowHeroPanel, kShowHeroPanel,
kMessage_0x1000019e, kMessage_0x1000019e,
kMessage_0x1000019f, kMessage_0x1000019f,
kMessage_0x100001a0, kQuerySuppressedKeyAction,
kGetInventoryAgentId, kGetInventoryAgentId,
kInventoryRelated1, kInventoryRelated1,
kInventoryRelated2, kInventoryRelated2,
kInventoryRelated3, kInventoryRelated3,
kInventoryRelated4,
kEquipItem, kEquipItem,
kMoveItem, kMoveItem,
kItemRelated_1, kItemRelated_1,
kItemTooltip, kItemTooltip,
kItemRelated_3, kItemRelated_3,
kItemRelated_4, kItemRelated_4,
kItemRelated_5,
kInitiateTrade, kInitiateTrade,
kMessage_0x100001a7, kMessage_0x100001a7,
kMessage_0x100001a8, kMessage_0x100001a8,
@@ -16718,7 +16730,7 @@ namespace Daybreak.API.Interop.GuildWars
public uint Level; 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 public unsafe struct Inventory
{ {
[global::System.Runtime.InteropServices.FieldOffset(0x0000)] [global::System.Runtime.InteropServices.FieldOffset(0x0000)]
@@ -16940,19 +16952,9 @@ namespace Daybreak.API.Interop.GuildWars
[global::System.Runtime.InteropServices.FieldOffset(0x00D0)] [global::System.Runtime.InteropServices.FieldOffset(0x00D0)]
public uint H00D0; public uint H00D0;
[global::System.Runtime.InteropServices.FieldOffset(0x00D4)] [global::System.Runtime.InteropServices.FieldOffset(0x00D4)]
public uint H00D4; public global::Daybreak.API.Interop.GuildWars.THash InventoryTable; // 0x24 bytes, keyed by agent_id
[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;
[global::System.Runtime.InteropServices.FieldOffset(0x00F8)] [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)] [global::System.Runtime.InteropServices.FieldOffset(0x00FC)]
public global::Daybreak.API.Interop.GuildWars.GuildWarsArray<nint> H00FC; 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.ApplicationLauncher;
using Daybreak.Services.BuildTemplates; using Daybreak.Services.BuildTemplates;
using Daybreak.Services.Credentials; using Daybreak.Services.Credentials;
using Daybreak.Services.Formatting;
using Daybreak.Services.DirectSong; using Daybreak.Services.DirectSong;
using Daybreak.Services.Downloads; using Daybreak.Services.Downloads;
using Daybreak.Services.Events; using Daybreak.Services.Events;
@@ -48,12 +49,12 @@ using Daybreak.Services.TradeChat.Models;
using Daybreak.Services.TradeChat.Notifications; using Daybreak.Services.TradeChat.Notifications;
using Daybreak.Services.UMod; using Daybreak.Services.UMod;
using Daybreak.Services.Updater; using Daybreak.Services.Updater;
using Daybreak.Services.Wiki;
using Daybreak.Shared.Models.Plugins; using Daybreak.Shared.Models.Plugins;
using Daybreak.Shared.Services.Api; using Daybreak.Shared.Services.Api;
using Daybreak.Shared.Services.ApplicationArguments; using Daybreak.Shared.Services.ApplicationArguments;
using Daybreak.Shared.Services.ApplicationLauncher; using Daybreak.Shared.Services.ApplicationLauncher;
using Daybreak.Shared.Services.BuildTemplates; using Daybreak.Shared.Services.BuildTemplates;
using Daybreak.Shared.Services.Formatting;
using Daybreak.Shared.Services.BuildTemplates.Parsers; using Daybreak.Shared.Services.BuildTemplates.Parsers;
using Daybreak.Shared.Services.Credentials; using Daybreak.Shared.Services.Credentials;
using Daybreak.Shared.Services.DirectSong; using Daybreak.Shared.Services.DirectSong;
@@ -82,7 +83,6 @@ using Daybreak.Shared.Services.Toolbox;
using Daybreak.Shared.Services.TradeChat; using Daybreak.Shared.Services.TradeChat;
using Daybreak.Shared.Services.UMod; using Daybreak.Shared.Services.UMod;
using Daybreak.Shared.Services.Updater; using Daybreak.Shared.Services.Updater;
using Daybreak.Shared.Services.Wiki;
using Daybreak.Services.Graph; using Daybreak.Services.Graph;
using Daybreak.Themes; using Daybreak.Themes;
using Daybreak.Views; using Daybreak.Views;
@@ -225,6 +225,7 @@ public class ProjectConfiguration : PluginConfigurationBase
services.AddScoped<IOnboardingService, OnboardingService>(); services.AddScoped<IOnboardingService, OnboardingService>();
services.AddScoped<IExperienceCalculator, ExperienceCalculator>(); services.AddScoped<IExperienceCalculator, ExperienceCalculator>();
services.AddScoped<IAttributePointCalculator, AttributePointCalculator>(); services.AddScoped<IAttributePointCalculator, AttributePointCalculator>();
services.AddScoped<ISkillNumberFormatter, SkillNumberFormatter>();
services.AddScoped<IDownloadService, DownloadService>(); services.AddScoped<IDownloadService, DownloadService>();
services.AddScoped<IGuildWarsInstaller, IntegratedGuildwarsInstaller>(); services.AddScoped<IGuildWarsInstaller, IntegratedGuildwarsInstaller>();
services.AddScoped<IExceptionHandler, ExceptionHandler>(); services.AddScoped<IExceptionHandler, ExceptionHandler>();
@@ -248,7 +249,6 @@ public class ProjectConfiguration : PluginConfigurationBase
services.AddScoped<ILaunchConfigurationService, LaunchConfigurationService>(); services.AddScoped<ILaunchConfigurationService, LaunchConfigurationService>();
services.AddScoped<IEventService, EventService>(); services.AddScoped<IEventService, EventService>();
services.AddScoped<IApplicationArgumentService, ApplicationArgumentService>(); services.AddScoped<IApplicationArgumentService, ApplicationArgumentService>();
services.AddScoped<IWikiService, WikiService>();
services.AddScoped<IScreenshotService, ScreenshotService>(); services.AddScoped<IScreenshotService, ScreenshotService>();
services.AddHostedSingleton<IApplicationUpdater, ApplicationUpdater>(); services.AddHostedSingleton<IApplicationUpdater, ApplicationUpdater>();
@@ -568,10 +568,6 @@ public class ProjectConfiguration : PluginConfigurationBase
.WithMessageHandler(SetupLoggingAndMetrics<ScopedApiContext>) .WithMessageHandler(SetupLoggingAndMetrics<ScopedApiContext>)
.WithDefaultRequestHeadersSetup(SetupDaybreakUserAgent) .WithDefaultRequestHeadersSetup(SetupDaybreakUserAgent)
.Build() .Build()
.RegisterHttpClient<WikiService>()
.WithMessageHandler(SetupLoggingAndMetrics<WikiService>)
.WithDefaultRequestHeadersSetup(SetupDaybreakUserAgent)
.Build()
.RegisterHttpClient<ReShadeService>() .RegisterHttpClient<ReShadeService>()
.WithMessageHandler(SetupLoggingAndMetrics<ReShadeService>) .WithMessageHandler(SetupLoggingAndMetrics<ReShadeService>)
.WithDefaultRequestHeadersSetup(SetupDaybreakUserAgent) .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,
};
}
-299
View File
@@ -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) protected void FilterSkillsByProfessionsAndString(string? searchFilter = default)
{ {
var filteredSkills = Skill.Skills.Where(skill => var filteredSkills = Skill.AllSkills.Where(skill =>
{ {
if (skill.PvP) 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="left-side">
<div class="skill-icon"> <div class="skill-icon">
<SkillIcon Skill="this.Context.Skill" /> <SkillIcon Skill="this.Context.Skill" />
@@ -23,67 +25,67 @@
<div class="skill-concise-description">@this.Context.Skill.ConciseDescription</div> <div class="skill-concise-description">@this.Context.Skill.ConciseDescription</div>
</div> </div>
<div class="right-side"> <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">
<div class="skill-resource-img"> <div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Energy /> <Daybreak.Views.Components.Icons.Misc.Energy />
</div> </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> </div>
} }
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Activation)) @if (this.Context.Skill.Activation.HasValue)
{ {
<div class="skill-resource"> <div class="skill-resource">
<div class="skill-resource-img"> <div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Activation /> <Daybreak.Views.Components.Icons.Misc.Activation />
</div> </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> </div>
} }
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Recharge)) @if (this.Context.Skill.Recharge.HasValue)
{ {
<div class="skill-resource"> <div class="skill-resource">
<div class="skill-resource-img"> <div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Recharge /> <Daybreak.Views.Components.Icons.Misc.Recharge />
</div> </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> </div>
} }
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Overcast)) @if (this.Context.Skill.Overcast.HasValue)
{ {
<div class="skill-resource"> <div class="skill-resource">
<div class="skill-resource-img"> <div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Overcast /> <Daybreak.Views.Components.Icons.Misc.Overcast />
</div> </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> </div>
} }
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Adrenaline)) @if (this.Context.Skill.Adrenaline.HasValue)
{ {
<div class="skill-resource"> <div class="skill-resource">
<div class="skill-resource-img"> <div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Adrenaline /> <Daybreak.Views.Components.Icons.Misc.Adrenaline />
</div> </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> </div>
} }
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Sacrifice)) @if (this.Context.Skill.Sacrifice.HasValue)
{ {
<div class="skill-resource"> <div class="skill-resource">
<div class="skill-resource-img"> <div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Sacrifice /> <Daybreak.Views.Components.Icons.Misc.Sacrifice />
</div> </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> </div>
} }
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Upkeep)) @if (this.Context.Skill.Upkeep.HasValue)
{ {
<div class="skill-resource"> <div class="skill-resource">
<div class="skill-resource-img"> <div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Upkeep /> <Daybreak.Views.Components.Icons.Misc.Upkeep />
</div> </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>
} }
</div> </div>
+2
View File
@@ -21,6 +21,8 @@
@using Daybreak.Shared.Models.Notifications @using Daybreak.Shared.Models.Notifications
@using Daybreak.Shared.Models.Options @using Daybreak.Shared.Models.Options
@using Daybreak.Shared.Models.FocusView @using Daybreak.Shared.Models.FocusView
@using Daybreak.Shared.Models.Guildwars
@using Daybreak.Shared.Services.Formatting
@using Daybreak.Shared.Utils @using Daybreak.Shared.Utils
@using Daybreak.Shared.Validators @using Daybreak.Shared.Validators
@using Daybreak.Shared @using Daybreak.Shared
@@ -974,6 +974,21 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
sb.AppendLine($"{pad}}}"); sb.AppendLine($"{pad}}}");
sb.AppendLine(); 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&lt;T&gt; - 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> /// <summary>
@@ -1250,6 +1265,12 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
return "global::Daybreak.API.Interop.GuildWars.TLink"; 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 // Handle pointers
bool isPointer = cppType.EndsWith("*"); bool isPointer = cppType.EndsWith("*");
if (isPointer) 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);
}
+3
View File
@@ -50,4 +50,7 @@
<Project Path="Daybreak.Generators/Daybreak.Generators.csproj"> <Project Path="Daybreak.Generators/Daybreak.Generators.csproj">
<Platform Project="Any CPU" /> <Platform Project="Any CPU" />
</Project> </Project>
<Project Path="Tools/SkillUpdater/SkillUpdater.csproj">
<Platform Project="Any CPU" />
</Project>
</Solution> </Solution>
+2 -2
View File
@@ -463,7 +463,7 @@ namespace GW {
Test_Map_solo_areas, // "This is a debug map to allow multiplayer testing of current solo areas." Test_Map_solo_areas, // "This is a debug map to allow multiplayer testing of current solo areas."
Nightfallen_Garden, Nightfallen_Garden,
Churrhir_Fields, Churrhir_Fields,
Beknur_Harbor_outpost, Beknur_Harbor,
Kodonur_Crossroads_cinematic, Kodonur_Crossroads_cinematic,
Rilohn_Refuge_cinematic, Rilohn_Refuge_cinematic,
Pogahn_Passage_cinematic, Pogahn_Passage_cinematic,
@@ -493,7 +493,7 @@ namespace GW {
Lahtenda_Bog, Lahtenda_Bog,
Arbor_Bay, Arbor_Bay,
Issnur_Isles, Issnur_Isles,
Beknur_Harbor, Beknur_Harbor_outpost,
Mehtani_Keys, Mehtani_Keys,
Kodlonu_Hamlet_outpost, Kodlonu_Hamlet_outpost,
Island_of_Shehkah, Island_of_Shehkah,
+13 -13
View File
@@ -2213,9 +2213,9 @@ namespace GW {
Aura_of_the_Bloodstone1, Aura_of_the_Bloodstone1,
Aura_of_the_Bloodstone2, Aura_of_the_Bloodstone2,
Haunted_Ground, Haunted_Ground,
Asuran_Bodyguard, Asuran_Bodyguard_Rank2,
Asuran_Bodyguard1, Asuran_Bodyguard_Rank3,
Asuran_Bodyguard2, Asuran_Bodyguard_Rank4,
Energy_Channel, Energy_Channel,
Hunt_Rampage, Hunt_Rampage,
Boss_Bounty = 2440, Boss_Bounty = 2440,
@@ -2224,9 +2224,9 @@ namespace GW {
Hunt_Point_Bonus2, Hunt_Point_Bonus2,
Time_Attack, Time_Attack,
Dwarven_Raider, Dwarven_Raider,
Dwarven_Raider1, Dwarven_Raider_Rank2,
Dwarven_Raider2, Dwarven_Raider_Rank3,
Dwarven_Raider3, Dwarven_Raider_Rank4,
Great_Dwarfs_Blessing, Great_Dwarfs_Blessing,
Hunt_Rampage1, Hunt_Rampage1,
Boss_Bounty1 = 2452, Boss_Bounty1 = 2452,
@@ -2234,19 +2234,19 @@ namespace GW {
Hunt_Point_Bonus4, Hunt_Point_Bonus4,
Time_Attack1 = 2456, Time_Attack1 = 2456,
Vanguard_Patrol, Vanguard_Patrol,
Vanguard_Patrol1, Vanguard_Patrol_Rank2,
Vanguard_Patrol2, Vanguard_Patrol_Rank3,
Vanguard_Patrol3, Vanguard_Patrol_Rank4,
Vanguard_Commendation, Vanguard_Commendation,
Hunt_Rampage2, Hunt_Rampage2,
Boss_Bounty2 = 2464, Boss_Bounty2 = 2464,
Norn_Hunting_Party = 2469, Norn_Hunting_Party = 2469,
Norn_Hunting_Party1, Norn_Hunting_Party_Rank2,
Norn_Hunting_Party2, Norn_Hunting_Party_Rank3,
Norn_Hunting_Party3, Norn_Hunting_Party_Rank4,
Strength_of_the_Norn, Strength_of_the_Norn,
Hunt_Rampage3, Hunt_Rampage3,
Asuran_Bodyguard3 = 2481, Asuran_Bodyguard = 2481,
Desperate_Howl, Desperate_Howl,
Gloat, Gloat,
Metamorphosis, Metamorphosis,
+37 -35
View File
@@ -548,45 +548,47 @@ namespace GW {
kShowHeroPanel, // 0x100001a3, wparam = hero_id kShowHeroPanel, // 0x100001a3, wparam = hero_id
kMessage_0x1000019e, // 0x100001a4 kMessage_0x1000019e, // 0x100001a4
kMessage_0x1000019f, // 0x100001a5 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 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 kInventoryRelated1, // 0x100001a8, added to GW 2026-02-26
kInventoryRelated2, // 0x100001a9, added to GW 2026-02-26 kInventoryRelated2, // 0x100001a9, added to GW 2026-02-26
kInventoryRelated3, // 0x100001aa, added to GW 2026-02-26 kInventoryRelated3, // 0x100001aa, added to GW 2026-02-26
kEquipItem, // 0x100001ab, wparam = { item_id, agent_id } kInventoryRelated4, // 0x100001ab, added to GW 2026-04-28
kMoveItem, // 0x100001ac, wparam = { item_id, to_bag, to_slot, bool prompt } kEquipItem, // 0x100001ac, wparam = { item_id, agent_id }
kItemRelated_1, // 0x100001ad kMoveItem, // 0x100001ad, wparam = { item_id, to_bag, to_slot, bool prompt }
kItemTooltip, // 0x100001ae kItemRelated_1, // 0x100001ae
kItemRelated_3, // 0x100001af, added to GW 2026-02-26 kItemTooltip, // 0x100001af
kItemRelated_4, // 0x100001b0, added to GW 2026-02-26 kItemRelated_3, // 0x100001b0, added to GW 2026-02-26
kInitiateTrade, // 0x100001b1 kItemRelated_4, // 0x100001b1, added to GW 2026-02-26
kMessage_0x100001a7, // 0x100001b2 kItemRelated_5, // 0x100001b2, added to GW 2026-04-28
kMessage_0x100001a8, // 0x100001b3 kInitiateTrade, // 0x100001b3
kMessage_0x100001a9, // 0x100001b4 kMessage_0x100001a7, // 0x100001b4
kMessage_0x100001aa, // 0x100001b5 kMessage_0x100001a8, // 0x100001b5
kMessage_0x100001ab, // 0x100001b6 kMessage_0x100001a9, // 0x100001b6
kMessage_0x100001ac, // 0x100001b7 kMessage_0x100001aa, // 0x100001b7
kMessage_0x100001ad, // 0x100001b8 kMessage_0x100001ab, // 0x100001b8
kMessage_0x100001ae, // 0x100001b9 kMessage_0x100001ac, // 0x100001b9
kMessage_0x100001af, // 0x100001ba kMessage_0x100001ad, // 0x100001ba
kMessage_0x100001b0, // 0x100001bb kMessage_0x100001ae, // 0x100001bb
kMessage_0x100001b1, // 0x100001bc kMessage_0x100001af, // 0x100001bc
kMessage_0x100001b2, // 0x100001bd kMessage_0x100001b0, // 0x100001bd
kMessage_0x100001b3, // 0x100001be kMessage_0x100001b1, // 0x100001be
kMessage_0x100001b4, // 0x100001bf kMessage_0x100001b2, // 0x100001bf
kMessage_0x100001b5, // 0x100001c0 kMessage_0x100001b3, // 0x100001c0
kInventoryAgentChanged, // 0x100001c1, Triggered when inventory needs updating due to agent change; no args kMessage_0x100001b4, // 0x100001c1
kInventoryRelated_1, // 0x100001c2 kMessage_0x100001b5, // 0x100001c2
kInventoryRelated_2, // 0x100001c3 kInventoryAgentChanged, // 0x100001c3, Triggered when inventory needs updating due to agent change; no args
kMissionStatusRelated, // 0x100001c4 kInventoryRelated_1, // 0x100001c4
kUnused_1c2, // 0x100001c5 kInventoryRelated_2, // 0x100001c5
kCollapseExpandSkillListSection, // 0x100001c6 kMissionStatusRelated, // 0x100001c6
kTemplateRelated_1, // 0x100001c7 kUnused_1c2, // 0x100001c7
kTemplateRelated_2, // 0x100001c8 kCollapseExpandSkillListSection, // 0x100001c8
kPromptSaveTemplate, // 0x100001c9 kTemplateRelated_1, // 0x100001c9
kOpenTemplate, // 0x100001ca, wparam = GW::UI::ChatTemplate* kTemplateRelated_2, // 0x100001ca
kTemplateRelated_3, // 0x100001cb kPromptSaveTemplate, // 0x100001cb
kTemplateRelated_4, // 0x100001cc 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 // GWCA Client to Server commands. Only added the ones that are used for hooks, everything else goes straight into GW
+37 -35
View File
@@ -548,45 +548,47 @@ namespace GW {
kShowHeroPanel, // 0x100001a3, wparam = hero_id kShowHeroPanel, // 0x100001a3, wparam = hero_id
kMessage_0x1000019e, // 0x100001a4 kMessage_0x1000019e, // 0x100001a4
kMessage_0x1000019f, // 0x100001a5 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 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 kInventoryRelated1, // 0x100001a8, added to GW 2026-02-26
kInventoryRelated2, // 0x100001a9, added to GW 2026-02-26 kInventoryRelated2, // 0x100001a9, added to GW 2026-02-26
kInventoryRelated3, // 0x100001aa, added to GW 2026-02-26 kInventoryRelated3, // 0x100001aa, added to GW 2026-02-26
kEquipItem, // 0x100001ab, wparam = { item_id, agent_id } kInventoryRelated4, // 0x100001ab, added to GW 2026-04-28
kMoveItem, // 0x100001ac, wparam = { item_id, to_bag, to_slot, bool prompt } kEquipItem, // 0x100001ac, wparam = { item_id, agent_id }
kItemRelated_1, // 0x100001ad kMoveItem, // 0x100001ad, wparam = { item_id, to_bag, to_slot, bool prompt }
kItemTooltip, // 0x100001ae kItemRelated_1, // 0x100001ae
kItemRelated_3, // 0x100001af, added to GW 2026-02-26 kItemTooltip, // 0x100001af
kItemRelated_4, // 0x100001b0, added to GW 2026-02-26 kItemRelated_3, // 0x100001b0, added to GW 2026-02-26
kInitiateTrade, // 0x100001b1 kItemRelated_4, // 0x100001b1, added to GW 2026-02-26
kMessage_0x100001a7, // 0x100001b2 kItemRelated_5, // 0x100001b2, added to GW 2026-04-28
kMessage_0x100001a8, // 0x100001b3 kInitiateTrade, // 0x100001b3
kMessage_0x100001a9, // 0x100001b4 kMessage_0x100001a7, // 0x100001b4
kMessage_0x100001aa, // 0x100001b5 kMessage_0x100001a8, // 0x100001b5
kMessage_0x100001ab, // 0x100001b6 kMessage_0x100001a9, // 0x100001b6
kMessage_0x100001ac, // 0x100001b7 kMessage_0x100001aa, // 0x100001b7
kMessage_0x100001ad, // 0x100001b8 kMessage_0x100001ab, // 0x100001b8
kMessage_0x100001ae, // 0x100001b9 kMessage_0x100001ac, // 0x100001b9
kMessage_0x100001af, // 0x100001ba kMessage_0x100001ad, // 0x100001ba
kMessage_0x100001b0, // 0x100001bb kMessage_0x100001ae, // 0x100001bb
kMessage_0x100001b1, // 0x100001bc kMessage_0x100001af, // 0x100001bc
kMessage_0x100001b2, // 0x100001bd kMessage_0x100001b0, // 0x100001bd
kMessage_0x100001b3, // 0x100001be kMessage_0x100001b1, // 0x100001be
kMessage_0x100001b4, // 0x100001bf kMessage_0x100001b2, // 0x100001bf
kMessage_0x100001b5, // 0x100001c0 kMessage_0x100001b3, // 0x100001c0
kInventoryAgentChanged, // 0x100001c1, Triggered when inventory needs updating due to agent change; no args kMessage_0x100001b4, // 0x100001c1
kInventoryRelated_1, // 0x100001c2 kMessage_0x100001b5, // 0x100001c2
kInventoryRelated_2, // 0x100001c3 kInventoryAgentChanged, // 0x100001c3, Triggered when inventory needs updating due to agent change; no args
kMissionStatusRelated, // 0x100001c4 kInventoryRelated_1, // 0x100001c4
kUnused_1c2, // 0x100001c5 kInventoryRelated_2, // 0x100001c5
kCollapseExpandSkillListSection, // 0x100001c6 kMissionStatusRelated, // 0x100001c6
kTemplateRelated_1, // 0x100001c7 kUnused_1c2, // 0x100001c7
kTemplateRelated_2, // 0x100001c8 kCollapseExpandSkillListSection, // 0x100001c8
kPromptSaveTemplate, // 0x100001c9 kTemplateRelated_1, // 0x100001c9
kOpenTemplate, // 0x100001ca, wparam = GW::UI::ChatTemplate* kTemplateRelated_2, // 0x100001ca
kTemplateRelated_3, // 0x100001cb kPromptSaveTemplate, // 0x100001cb
kTemplateRelated_4, // 0x100001cc 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 // GWCA Client to Server commands. Only added the ones that are used for hooks, everything else goes straight into GW
+4 -8
View File
@@ -2,6 +2,7 @@
#include <GWCA/GameContainers/Array.h> #include <GWCA/GameContainers/Array.h>
#include <GWCA/Utilities/Export.h> #include <GWCA/Utilities/Export.h>
#include <GWCA/GameContainers/Hash.h>
namespace GW { namespace GW {
struct ItemContext; struct ItemContext;
@@ -53,14 +54,9 @@ namespace GW {
/* +h00C8 */ uint32_t h00C8; /* +h00C8 */ uint32_t h00C8;
/* +h00CC */ uint32_t h00CC; /* +h00CC */ uint32_t h00CC;
/* +h00D0 */ uint32_t h00D0; /* +h00D0 */ uint32_t h00D0;
/* +h00D4 */ uint32_t h00D4; /* +h00D4 */ THash<Inventory> inventory_table; // 0x24 bytes, keyed by agent_id
/* +h00D8 */ uint32_t h00D8; /* +h00F8 */ Inventory* inventory; // shortcut to player's own inventory
/* +h00DC */ uint32_t h00DC;
/* +h00E0 */ uint32_t h00E0;
/* +h00E4 */ Array<InventoryTableEntry> inventory_table;
/* +h00F4 */ uint32_t h00F4;
/* +h00F8 */ Inventory* inventory;
/* +h00FC */ Array<void*> h00FC; /* +h00FC */ Array<void*> h00FC;
}; };
static_assert(sizeof(ItemContext) == 0x10c); static_assert(sizeof(ItemContext) == 0x10C, "struct ItemContext has incorrect size");
} }
+2 -2
View File
@@ -2,9 +2,9 @@
#define GWCA_VERSION_MAJOR 4 #define GWCA_VERSION_MAJOR 4
#define GWCA_VERSION_MINOR 1 #define GWCA_VERSION_MINOR 1
#define GWCA_VERSION_PATCH 1 #define GWCA_VERSION_PATCH 4
#define GWCA_VERSION_BUILD 0 #define GWCA_VERSION_BUILD 0
#define GWCA_VERSION "4.1.1.0" #define GWCA_VERSION "4.1.4.0"
namespace GWCA { namespace GWCA {
constexpr int VersionMajor = GWCA_VERSION_MAJOR; constexpr int VersionMajor = GWCA_VERSION_MAJOR;
+55 -17
View File
@@ -1,34 +1,72 @@
#pragma once #pragma once
#include <GWCA/Utilities/Export.h> #include <GWCA/Utilities/Export.h>
#include <GWCA/GameContainers/List.h> #include <GWCA/GameContainers/List.h>
#include <GWCA/GameContainers/Array.h> #include <GWCA/GameContainers/Array.h>
namespace GW { 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 Hash8(uint8_t data);
GWCA_API uint32_t Hash16(uint16_t data); GWCA_API uint32_t Hash16(uint16_t data);
GWCA_API uint32_t Hash32(uint32_t data); GWCA_API uint32_t Hash32(uint32_t data);
GWCA_API uint32_t HashWString(const wchar_t* str, uint32_t maxLength = -1); GWCA_API uint32_t HashWString(const wchar_t* str, uint32_t maxLength = -1);
template <typename T> template <typename T>
struct THash { struct THashLink;
/* +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;
};
template <typename T> 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; /* +h0000 */ uint32_t hash;
/* +h0004 */ T *elem; /* +h0004 */ T* elem;
/* +h0008 */ uint32_t h0008; /* +h0008 */ uint32_t h0008;
/* +h000C */ uint32_t h000C; /* +h000C */ uint32_t h000C;
/* +h0010 */ TLink<T> m_fullListLink; /* +h0010 */ TLink<T> m_fullListLink; // 0x08 bytes
/* +h0018 */ TLink<T> m_slotListLink; /* +h0018 */ TLink<T> m_slotListLink; // 0x08 bytes
}; };
} static_assert(sizeof(THashLink<void*>) == 0x20, "THashLink has incorrect size");
}
+4 -1
View File
@@ -208,13 +208,16 @@ namespace GW {
/* +h0088 */ uint32_t h0088[2]; /* +h0088 */ uint32_t h0088[2];
/* +h0090 */ uint32_t gold_character; /* +h0090 */ uint32_t gold_character;
/* +h0094 */ uint32_t gold_storage; /* +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 [[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... 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 // Static struct for info about available item upgrade info, used for PvP Equipment window
struct PvPItemUpgradeInfo { struct PvPItemUpgradeInfo {
+1
View File
@@ -89,6 +89,7 @@ namespace GW {
GWCA_API bool SetHeroTarget(uint32_t hero_agent_id, uint32_t target_agent_id = 0); 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 SetHeroBehavior(uint32_t hero_agent_id, HeroBehavior behavior);
GWCA_API bool SetPetBehavior(uint32_t owner_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); GWCA_API PetInfo* GetPetInfo(uint32_t owner_agent_id = 0);
+222
View File
@@ -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>"&lt;Base&gt; (large).jpg"</c> first, then plain
/// <c>"&lt;Base&gt;.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";
}
}
+80
View File
@@ -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.");
}
}
+25
View File
@@ -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`.
+174
View File
@@ -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&amp;rvslots=main&amp;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;
}
}
+239
View File
@@ -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&lt;Skill&gt;</c> collections, then
/// <c>AllSkills</c>. Strictly emit-only — no model objects required.
/// </summary>
internal static class SkillFileWriter
{
private const string Header = """
// <auto-generated>
// Generated by Tools/SkillUpdater. Do not edit manually — re-run
// `dotnet run --project Tools/SkillUpdater` to refresh from the wiki.
// </auto-generated>
#nullable enable
using Daybreak.Shared.Models.Guildwars;
using Attribute = Daybreak.Shared.Models.Guildwars.Attribute;
namespace Daybreak.Shared.Models.Guildwars;
public sealed partial class Skill
{
""";
private const string Footer = "}\n";
public static (string Content, IReadOnlyList<string> Warnings) Render(
IEnumerable<ParsedSkill> skills,
IReadOnlyDictionary<string, string> iconUrls)
{
// Expand skills carrying multiple ids (e.g. "Save Yourselves!" with
// 1954 Luxon + 2097 Kurzick) into one emitted entry per id.
var expanded = new List<ParsedSkill>();
foreach (var skill in skills)
{
expanded.Add(skill);
for (var i = 0; i < skill.AdditionalIds.Count; i++)
{
expanded.Add(skill with
{
Id = skill.AdditionalIds[i],
AdditionalIds = [],
});
}
}
// Stable ordering: by id ascending so identifier collisions get resolved
// deterministically (lower id keeps the bare identifier).
var ordered = expanded
.OrderBy(s => s.Id)
.ThenBy(s => s.Name, StringComparer.Ordinal)
.ToList();
var seenIdentifier = new Dictionary<string, int>(StringComparer.Ordinal);
var rendered = new List<(string Identifier, ParsedSkill Skill)>(ordered.Count);
var warnings = new List<string>();
foreach (var entry in ordered)
{
var ident = ToIdentifier(entry.Name);
if (seenIdentifier.TryGetValue(ident, out var count))
{
seenIdentifier[ident] = count + 1;
var unique = $"{ident}_{count + 1}";
warnings.Add($"identifier collision: {entry.Name} (id {entry.Id}) → {unique}");
rendered.Add((unique, entry));
}
else
{
seenIdentifier[ident] = 1;
rendered.Add((ident, entry));
}
}
var sb = new StringBuilder(capacity: 1 << 20);
sb.Append(Header);
foreach (var (ident, skill) in rendered.OrderBy(r => r.Identifier, StringComparer.Ordinal))
{
sb.Append(" ").Append(RenderField(ident, skill, iconUrls)).Append('\n');
}
sb.Append('\n');
foreach (var (campaignIdentifier, listName) in CampaignListNames)
{
var members = rendered
.Where(r => r.Skill.CampaignIdentifier == campaignIdentifier)
.OrderBy(r => r.Identifier, StringComparer.Ordinal)
.Select(r => r.Identifier)
.ToList();
EmitList(sb, listName, members);
}
sb.Append('\n');
EmitList(sb, "AllSkills",
rendered.Select(r => r.Identifier).OrderBy(i => i, StringComparer.Ordinal).Prepend("None").ToList());
sb.Append(Footer);
return (sb.ToString(), warnings);
}
private static readonly (string CampaignIdentifier, string ListName)[] CampaignListNames =
[
("Core", "CoreSkills"),
("Prophecies", "PropheciesSkills"),
("Factions", "FactionsSkills"),
("Nightfall", "NightfallSkills"),
("EyeOfTheNorth", "EyeOfTheNorthSkills"),
("BonusMissionPack", "BonusMissionPackSkills"),
];
private static void EmitList(StringBuilder sb, string name, IReadOnlyList<string> identifiers)
{
if (identifiers.Count == 0)
{
sb.Append(" public static readonly IReadOnlyList<Skill> ").Append(name).Append(" = [];\n");
return;
}
sb.Append(" public static readonly IReadOnlyList<Skill> ").Append(name).Append(" =\n");
sb.Append(" [\n");
foreach (var ident in identifiers)
{
sb.Append(" ").Append(ident).Append(",\n");
}
sb.Append(" ];\n");
}
private static string RenderField(string identifier, ParsedSkill s, IReadOnlyDictionary<string, string> iconUrls)
{
var icon = iconUrls.GetValueOrDefault(s.Name, string.Empty);
return $"public static readonly Skill {identifier} = new() {{ "
+ $"Id = {s.Id.ToString(CultureInfo.InvariantCulture)}, "
+ $"Name = {Quote(s.Name)}, "
+ $"Elite = {Bool(s.Elite)}, "
+ $"PvEOnly = {Bool(s.PvEOnly)}, "
+ $"PvP = {Bool(s.PvP)}, "
+ $"Campaign = Campaign.{s.CampaignIdentifier}, "
+ $"Profession = Profession.{s.ProfessionIdentifier}, "
+ $"Attribute = Attribute.{s.AttributeIdentifier}, "
+ $"Type = {s.TypeExpression}, "
+ $"Energy = {Number(s.Energy)}, "
+ $"Activation = {Number(s.Activation)}, "
+ $"Recharge = {Number(s.Recharge)}, "
+ $"Overcast = {Number(s.Overcast)}, "
+ $"Adrenaline = {Number(s.Adrenaline)}, "
+ $"Sacrifice = {Number(s.Sacrifice)}, "
+ $"Upkeep = {Number(s.Upkeep)}, "
+ $"Description = {Quote(s.Description)}, "
+ $"ConciseDescription = {Quote(s.ConciseDescription)}, "
+ $"IconUrl = {Quote(icon)}"
+ " };";
}
private static string Bool(bool value) => value ? "true" : "false";
private static string Number(double? value)
{
if (value is not double d)
{
return "null";
}
return d == Math.Floor(d) && !double.IsInfinity(d)
? ((long)d).ToString(CultureInfo.InvariantCulture)
: d.ToString("G17", CultureInfo.InvariantCulture);
}
private static string Quote(string? value)
{
if (value is null)
{
return "\"\"";
}
var sb = new StringBuilder(value.Length + 2);
sb.Append('"');
foreach (var c in value)
{
switch (c)
{
case '\\': sb.Append("\\\\"); break;
case '"': sb.Append("\\\""); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default:
if (c < 0x20)
{
sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));
}
else
{
sb.Append(c);
}
break;
}
}
sb.Append('"');
return sb.ToString();
}
/// <summary>
/// Sanitises a skill display name into a C# identifier matching the
/// existing Daybreak convention: strip every non-alphanumeric character,
/// preserve original casing. PvP variants therefore become <c>FooPvP</c>.
/// </summary>
public static string ToIdentifier(string name)
{
var sb = new StringBuilder(name.Length);
foreach (var c in name)
{
if (char.IsLetterOrDigit(c))
{
sb.Append(c);
}
}
if (sb.Length == 0)
{
return "_";
}
if (char.IsDigit(sb[0]))
{
sb.Insert(0, '_');
}
return sb.ToString();
}
}
+17
View File
@@ -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>
+84
View File
@@ -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();
}
}
+482
View File
@@ -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();
}