mirror of
https://github.com/gwdevhub/Daybreak.git
synced 2026-07-15 15:19:57 +00:00
@@ -50,7 +50,7 @@ public static unsafe partial class GWCA
|
|||||||
// [NAMESPACE] GWCA.GW.Packet.StoC.GenericValueID popped at line 70
|
// [NAMESPACE] GWCA.GW.Packet.StoC.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<T> - hash table (36 bytes, opaque to managed code).
|
||||||
|
/// </summary>
|
||||||
|
[global::System.Runtime.InteropServices.StructLayout(global::System.Runtime.InteropServices.LayoutKind.Sequential, Pack = 1, Size = 0x24)]
|
||||||
|
public struct THash
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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<T> - hash table (36 bytes, opaque to managed code).");
|
||||||
|
sb.AppendLine($"{pad}/// </summary>");
|
||||||
|
sb.AppendLine($"{pad}[global::System.Runtime.InteropServices.StructLayout(global::System.Runtime.InteropServices.LayoutKind.Sequential, Pack = 1, Size = 0x24)]");
|
||||||
|
sb.AppendLine($"{pad}public struct THash");
|
||||||
|
sb.AppendLine($"{pad}{{");
|
||||||
|
sb.AppendLine($"{pad}}}");
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Daybreak.Tools.SkillUpdater;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves skill names to their canonical wiki image CDN URL via the
|
||||||
|
/// MediaWiki API (<c>prop=imageinfo</c>). Avoids the runtime
|
||||||
|
/// <c>Special:FilePath/...</c> redirect that has proven flaky.
|
||||||
|
///
|
||||||
|
/// Each skill carries one or more candidate base names (e.g. shouts ship with
|
||||||
|
/// both the unquoted and quoted form). For each base name we try the
|
||||||
|
/// high-resolution <c>"<Base> (large).jpg"</c> first, then plain
|
||||||
|
/// <c>"<Base>.jpg"</c>. Skills with no matching file get an empty URL,
|
||||||
|
/// matching the historical convention for PvP variants.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class IconResolver(WikiHttpClient client)
|
||||||
|
{
|
||||||
|
private const string ApiBase = "https://wiki.guildwars.com/api.php";
|
||||||
|
|
||||||
|
/// <summary>Maximum titles per <c>prop=imageinfo</c> query.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// MediaWiki itself accepts 50, but the wiki's WAF/Cloudflare returns 403
|
||||||
|
/// when the encoded URL grows past ~2 KB (which 50 skill titles routinely
|
||||||
|
/// hit, especially with parentheses and apostrophes). 25 keeps every URL
|
||||||
|
/// under the limit; <see cref="ResolveSliceAsync"/> additionally splits
|
||||||
|
/// on the fly if a single batch still gets rejected.
|
||||||
|
/// </remarks>
|
||||||
|
private const int BatchSize = 25;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyDictionary<string, string>> ResolveAsync(
|
||||||
|
IReadOnlyList<ParsedSkill> skills,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Track candidate (skillName, filename) pairs across passes; a skill
|
||||||
|
// is removed from the work-set as soon as any candidate resolves.
|
||||||
|
var resolved = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var pending = skills
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s.Name))
|
||||||
|
.GroupBy(s => s.Name, StringComparer.Ordinal)
|
||||||
|
.Select(g => g.First())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Console.WriteLine($"-> Resolving icon URLs for {pending.Count} skills…");
|
||||||
|
|
||||||
|
// Build the (base name, suffix) sweep order. We try high-res first so
|
||||||
|
// a skill that has both ends up with the larger asset.
|
||||||
|
var maxCandidates = pending.Max(s => s.IconBaseNames.Count);
|
||||||
|
if (maxCandidates == 0)
|
||||||
|
{
|
||||||
|
maxCandidates = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var candidateIdx = 0; candidateIdx < maxCandidates; candidateIdx++)
|
||||||
|
{
|
||||||
|
foreach (var suffix in new[] { " (large)", string.Empty })
|
||||||
|
{
|
||||||
|
var work = pending
|
||||||
|
.Where(s => !resolved.ContainsKey(s.Name))
|
||||||
|
.Where(s => candidateIdx < s.IconBaseNames.Count
|
||||||
|
|| (candidateIdx == 0 && s.IconBaseNames.Count == 0))
|
||||||
|
.Select(s => (
|
||||||
|
SkillName: s.Name,
|
||||||
|
BaseName: s.IconBaseNames.Count == 0 ? s.Name : s.IconBaseNames[candidateIdx]))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (work.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var label = candidateIdx == 0
|
||||||
|
? (suffix == " (large)" ? "(large)" : "fallback")
|
||||||
|
: (suffix == " (large)" ? $"alt#{candidateIdx} (large)" : $"alt#{candidateIdx} fallback");
|
||||||
|
await ResolveBatchAsync(work, suffix, resolved, label, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var unresolved = pending.Count - resolved.Count;
|
||||||
|
Console.WriteLine($" icons resolved: {resolved.Count} hit, {unresolved} unresolved (empty url)");
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResolveBatchAsync(
|
||||||
|
IReadOnlyList<(string SkillName, string BaseName)> work,
|
||||||
|
string suffix,
|
||||||
|
Dictionary<string, string> resolved,
|
||||||
|
string passLabel,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var totalBatches = (work.Count + BatchSize - 1) / BatchSize;
|
||||||
|
var batchIndex = 0;
|
||||||
|
for (var offset = 0; offset < work.Count; offset += BatchSize)
|
||||||
|
{
|
||||||
|
batchIndex++;
|
||||||
|
var slice = new List<(string SkillName, string BaseName)>();
|
||||||
|
for (var i = offset; i < Math.Min(offset + BatchSize, work.Count); i++)
|
||||||
|
{
|
||||||
|
slice.Add(work[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var found = await ResolveSliceAsync(slice, suffix, resolved, cancellationToken);
|
||||||
|
Console.WriteLine($" batch {batchIndex,2}/{totalBatches} ({passLabel}) … {found} resolved");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends one <c>prop=imageinfo</c> query for the slice and falls back to
|
||||||
|
/// halving + retry if the wiki's WAF rejects the URL with 403.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> ResolveSliceAsync(
|
||||||
|
IReadOnlyList<(string SkillName, string BaseName)> work,
|
||||||
|
string suffix,
|
||||||
|
Dictionary<string, string> resolved,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (work.Count == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var titleToSkill = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var titles = new List<string>(work.Count);
|
||||||
|
foreach (var (skillName, baseName) in work)
|
||||||
|
{
|
||||||
|
var title = $"File:{baseName}{suffix}.jpg";
|
||||||
|
titles.Add(title);
|
||||||
|
titleToSkill[NormalizeWikiTitle(title)] = skillName;
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = BuildImageInfoUrl(titles);
|
||||||
|
string json;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
json = await client.GetStringAsync(url, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex) when (IsForbidden(ex) && work.Count > 1)
|
||||||
|
{
|
||||||
|
var mid = work.Count / 2;
|
||||||
|
var left = work.Take(mid).ToList();
|
||||||
|
var right = work.Skip(mid).ToList();
|
||||||
|
var l = await ResolveSliceAsync(left, suffix, resolved, cancellationToken);
|
||||||
|
var r = await ResolveSliceAsync(right, suffix, resolved, cancellationToken);
|
||||||
|
return l + r;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtractUrls(json, titleToSkill, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsForbidden(HttpRequestException ex) =>
|
||||||
|
ex.Message.Contains("403", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static int ExtractUrls(string json, IReadOnlyDictionary<string, string> titleToSkill, Dictionary<string, string> resolved)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (!root.TryGetProperty("query", out var query) ||
|
||||||
|
!query.TryGetProperty("pages", out var pages))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hit = 0;
|
||||||
|
// formatversion=2 normalises titles for us; map them back via the lookup table.
|
||||||
|
var rewrites = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (query.TryGetProperty("normalized", out var normalized))
|
||||||
|
{
|
||||||
|
foreach (var entry in normalized.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (entry.TryGetProperty("from", out var fromEl) &&
|
||||||
|
entry.TryGetProperty("to", out var toEl))
|
||||||
|
{
|
||||||
|
rewrites[NormalizeWikiTitle(toEl.GetString() ?? "")] = NormalizeWikiTitle(fromEl.GetString() ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var page in pages.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!page.TryGetProperty("title", out var titleEl))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var canonical = NormalizeWikiTitle(titleEl.GetString() ?? "");
|
||||||
|
var lookupKey = rewrites.GetValueOrDefault(canonical, canonical);
|
||||||
|
if (!titleToSkill.TryGetValue(lookupKey, out var skillName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!page.TryGetProperty("imageinfo", out var imageInfo) || imageInfo.GetArrayLength() == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageInfo[0].TryGetProperty("url", out var urlEl))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = urlEl.GetString();
|
||||||
|
if (!string.IsNullOrEmpty(url))
|
||||||
|
{
|
||||||
|
resolved[skillName] = url;
|
||||||
|
hit++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>MediaWiki normalises whitespace and URL-encodes nothing in titles.</summary>
|
||||||
|
private static string NormalizeWikiTitle(string title) => title.Replace('_', ' ').Trim();
|
||||||
|
|
||||||
|
private static string BuildImageInfoUrl(IReadOnlyList<string> titles)
|
||||||
|
{
|
||||||
|
var encoder = UrlEncoder.Default;
|
||||||
|
var encodedTitles = encoder.Encode(string.Join("|", titles));
|
||||||
|
return $"{ApiBase}?action=query&format=json&formatversion=2&titles={encodedTitles}&prop=imageinfo&iiprop=url";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Daybreak.Tools.SkillUpdater;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
private const string UserAgent = "Daybreak-SkillUpdater/1.0 (+https://github.com/gwdevhub/Daybreak)";
|
||||||
|
|
||||||
|
public static async Task<int> Main()
|
||||||
|
{
|
||||||
|
using var cancellationSource = new CancellationTokenSource();
|
||||||
|
Console.CancelKeyPress += (_, e) =>
|
||||||
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
cancellationSource.Cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
var repoRoot = LocateRepoRoot();
|
||||||
|
var skillFile = Path.Combine(repoRoot, "Daybreak.Shared", "Models", "Guildwars", "Skill.g.cs");
|
||||||
|
|
||||||
|
Console.WriteLine($"Repo root: {repoRoot}");
|
||||||
|
Console.WriteLine($"Output file: {skillFile}");
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new WikiHttpClient(UserAgent);
|
||||||
|
var enumerator = new SkillEnumerator(client);
|
||||||
|
|
||||||
|
Console.WriteLine("Enumerating skills from wiki…");
|
||||||
|
var skills = await enumerator.EnumerateAsync(cancellationSource.Token);
|
||||||
|
Console.WriteLine($"Collected {skills.Count} skills.");
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
var iconResolver = new IconResolver(client);
|
||||||
|
var iconUrls = await iconResolver.ResolveAsync(skills, cancellationSource.Token);
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
Console.WriteLine("Rendering Skill.g.cs…");
|
||||||
|
var (content, warnings) = SkillFileWriter.Render(skills, iconUrls);
|
||||||
|
await File.WriteAllTextAsync(skillFile, content, cancellationSource.Token);
|
||||||
|
Console.WriteLine($"Wrote {content.Length:N0} chars to {skillFile}");
|
||||||
|
foreach (var warn in warnings)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($" ! {warn}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("aborted.");
|
||||||
|
return 130;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"FATAL: {ex}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LocateRepoRoot()
|
||||||
|
{
|
||||||
|
// The compiled tool sits at <repo>/Tools/SkillUpdater/bin/<config>/<tfm>/.
|
||||||
|
// Walk upwards until we find Daybreak.slnx — works for both `dotnet run`
|
||||||
|
// and a published binary launched from anywhere inside the repo.
|
||||||
|
var dir = new DirectoryInfo(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!);
|
||||||
|
while (dir is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(dir.FullName, "Daybreak.slnx")))
|
||||||
|
{
|
||||||
|
return dir.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
dir = dir.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Could not locate Daybreak.slnx walking upwards from the assembly location.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# SkillUpdater
|
||||||
|
|
||||||
|
One-shot console tool that regenerates `Daybreak.Shared/Models/Guildwars/Skill.g.cs`
|
||||||
|
from <https://wiki.guildwars.com>. The wiki is the single source of truth for skill
|
||||||
|
data — this tool does not read the existing `Skill.g.cs`.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project Tools/SkillUpdater
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
1. Enumerates every skill page via the MediaWiki API
|
||||||
|
(`generator=categorymembers` over the five campaign categories
|
||||||
|
`Core_skills`, `Prophecies_skills`, `Factions_skills`, `Nightfall_skills`,
|
||||||
|
`Eye_of_the_North_skills`) with `prop=revisions&rvslots=main&rvprop=content`.
|
||||||
|
2. Filters to pages containing a `{{Skill infobox}}` template.
|
||||||
|
3. Parses each infobox via the tool-local `WikiSkillParser`.
|
||||||
|
4. Resolves the canonical CDN URL for each skill icon by batched
|
||||||
|
`prop=imageinfo` queries — preferring the high-resolution
|
||||||
|
`<Name> (large).jpg`, falling back to `<Name>.jpg`, leaving the URL
|
||||||
|
empty when neither file exists.
|
||||||
|
5. Writes a sorted, grouped `Skill.g.cs`.
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Daybreak.Tools.SkillUpdater;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks the five per-campaign "Skills" categories on the wiki using
|
||||||
|
/// <c>generator=categorymembers</c> joined with
|
||||||
|
/// <c>prop=revisions&rvslots=main&rvprop=content</c>. Pagination via
|
||||||
|
/// <c>gcmcontinue</c>. Pages without a <c>{{Skill infobox}}</c> block (category
|
||||||
|
/// indexes, "List of …" pages) are skipped. De-duplicates by page id.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SkillEnumerator(WikiHttpClient client)
|
||||||
|
{
|
||||||
|
private const string ApiBase = "https://wiki.guildwars.com/api.php";
|
||||||
|
|
||||||
|
private static readonly string[] CampaignCategories =
|
||||||
|
[
|
||||||
|
"Core_skills",
|
||||||
|
"Prophecies_skills",
|
||||||
|
"Factions_skills",
|
||||||
|
"Nightfall_skills",
|
||||||
|
"Eye_of_the_North_skills",
|
||||||
|
];
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ParsedSkill>> EnumerateAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var seen = new Dictionary<int, ParsedSkill>();
|
||||||
|
foreach (var category in CampaignCategories)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"-> Category:{category}");
|
||||||
|
var newCount = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
await foreach (var (pageId, skill, isSkill) in this.EnumerateCategoryAsync(category, cancellationToken))
|
||||||
|
{
|
||||||
|
if (!isSkill)
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.TryAdd(pageId, skill!))
|
||||||
|
{
|
||||||
|
newCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($" category complete: +{newCount} new (skipped {skipped} non-skill pages, running total {seen.Count})");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. seen.Values];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async IAsyncEnumerable<(int PageId, ParsedSkill? Skill, bool IsSkill)> EnumerateCategoryAsync(
|
||||||
|
string category,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? gcmcontinue = null;
|
||||||
|
var batchIndex = 0;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
batchIndex++;
|
||||||
|
var url = BuildCategoryQueryUrl(category, gcmcontinue);
|
||||||
|
Console.Write($" batch {batchIndex,2} … ");
|
||||||
|
var json = await client.GetStringAsync(url, cancellationToken);
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
gcmcontinue = root.TryGetProperty("continue", out var cont) && cont.TryGetProperty("gcmcontinue", out var gcm)
|
||||||
|
? gcm.GetString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var batchSkills = 0;
|
||||||
|
var batchSkipped = 0;
|
||||||
|
if (root.TryGetProperty("query", out var query) &&
|
||||||
|
query.TryGetProperty("pages", out var pages))
|
||||||
|
{
|
||||||
|
foreach (var page in pages.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (TryProjectSkill(page, out var pageId, out var skill))
|
||||||
|
{
|
||||||
|
batchSkills++;
|
||||||
|
yield return (pageId, skill, true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
batchSkipped++;
|
||||||
|
yield return (0, null, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"{batchSkills} skills (+{batchSkipped} skipped){(gcmcontinue is null ? " [last]" : string.Empty)}");
|
||||||
|
}
|
||||||
|
while (gcmcontinue is not null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryProjectSkill(JsonElement page, out int pageId, out ParsedSkill skill)
|
||||||
|
{
|
||||||
|
pageId = 0;
|
||||||
|
skill = null!;
|
||||||
|
if (!page.TryGetProperty("title", out var titleEl) ||
|
||||||
|
!page.TryGetProperty("pageid", out var pageIdEl))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var title = titleEl.GetString();
|
||||||
|
if (string.IsNullOrEmpty(title))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!page.TryGetProperty("revisions", out var revisions) || revisions.GetArrayLength() == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? content = null;
|
||||||
|
if (revisions[0].TryGetProperty("slots", out var slots) &&
|
||||||
|
slots.TryGetProperty("main", out var main) &&
|
||||||
|
main.TryGetProperty("content", out var contentEl))
|
||||||
|
{
|
||||||
|
content = contentEl.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(content) ||
|
||||||
|
!content.Contains("{{Skill infobox", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!WikiSkillParser.TryParse(content, out var parsed))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($" ! parse failed for '{title}' (pageid {pageIdEl.GetInt32()})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The infobox `name` field can omit the `(PvP)` suffix even though the
|
||||||
|
// page title carries it. Prefer the page title as the canonical name,
|
||||||
|
// stripping the surrounding quotes some shout titles carry — and
|
||||||
|
// remember the quoted form so the icon resolver can try it as well
|
||||||
|
// (image files keep the quotes in their filenames).
|
||||||
|
var rawTitle = title.Trim();
|
||||||
|
var isQuoted = rawTitle.Length >= 2 && rawTitle[0] == '"' && rawTitle[^1] == '"';
|
||||||
|
var canonicalTitle = isQuoted ? rawTitle[1..^1] : rawTitle;
|
||||||
|
|
||||||
|
if (!string.Equals(parsed.Name, canonicalTitle, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
parsed = parsed with { Name = canonicalTitle };
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconBaseNames = isQuoted
|
||||||
|
? (IReadOnlyList<string>)[canonicalTitle, rawTitle]
|
||||||
|
: [canonicalTitle];
|
||||||
|
parsed = parsed with { IconBaseNames = iconBaseNames };
|
||||||
|
|
||||||
|
pageId = pageIdEl.GetInt32();
|
||||||
|
skill = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCategoryQueryUrl(string category, string? gcmcontinue)
|
||||||
|
{
|
||||||
|
var encoder = UrlEncoder.Default;
|
||||||
|
var continueParam = gcmcontinue is null ? string.Empty : $"&gcmcontinue={encoder.Encode(gcmcontinue)}";
|
||||||
|
return $"{ApiBase}?action=query&format=json&formatversion=2"
|
||||||
|
+ $"&generator=categorymembers&gcmtitle=Category:{encoder.Encode(category)}"
|
||||||
|
+ "&gcmlimit=50&gcmtype=page&gcmnamespace=0"
|
||||||
|
+ "&prop=revisions&rvslots=main&rvprop=content"
|
||||||
|
+ continueParam;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Daybreak.Tools.SkillUpdater;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the wiki-derived skill set into <c>Skill.g.cs</c>: one
|
||||||
|
/// <c>public static readonly Skill X = new() {…};</c> per skill, then
|
||||||
|
/// per-campaign <c>IReadOnlyList<Skill></c> collections, then
|
||||||
|
/// <c>AllSkills</c>. Strictly emit-only — no model objects required.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SkillFileWriter
|
||||||
|
{
|
||||||
|
private const string Header = """
|
||||||
|
// <auto-generated>
|
||||||
|
// Generated by Tools/SkillUpdater. Do not edit manually — re-run
|
||||||
|
// `dotnet run --project Tools/SkillUpdater` to refresh from the wiki.
|
||||||
|
// </auto-generated>
|
||||||
|
#nullable enable
|
||||||
|
using Daybreak.Shared.Models.Guildwars;
|
||||||
|
using Attribute = Daybreak.Shared.Models.Guildwars.Attribute;
|
||||||
|
|
||||||
|
namespace Daybreak.Shared.Models.Guildwars;
|
||||||
|
|
||||||
|
public sealed partial class Skill
|
||||||
|
{
|
||||||
|
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string Footer = "}\n";
|
||||||
|
|
||||||
|
public static (string Content, IReadOnlyList<string> Warnings) Render(
|
||||||
|
IEnumerable<ParsedSkill> skills,
|
||||||
|
IReadOnlyDictionary<string, string> iconUrls)
|
||||||
|
{
|
||||||
|
// Expand skills carrying multiple ids (e.g. "Save Yourselves!" with
|
||||||
|
// 1954 Luxon + 2097 Kurzick) into one emitted entry per id.
|
||||||
|
var expanded = new List<ParsedSkill>();
|
||||||
|
foreach (var skill in skills)
|
||||||
|
{
|
||||||
|
expanded.Add(skill);
|
||||||
|
for (var i = 0; i < skill.AdditionalIds.Count; i++)
|
||||||
|
{
|
||||||
|
expanded.Add(skill with
|
||||||
|
{
|
||||||
|
Id = skill.AdditionalIds[i],
|
||||||
|
AdditionalIds = [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable ordering: by id ascending so identifier collisions get resolved
|
||||||
|
// deterministically (lower id keeps the bare identifier).
|
||||||
|
var ordered = expanded
|
||||||
|
.OrderBy(s => s.Id)
|
||||||
|
.ThenBy(s => s.Name, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var seenIdentifier = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
var rendered = new List<(string Identifier, ParsedSkill Skill)>(ordered.Count);
|
||||||
|
var warnings = new List<string>();
|
||||||
|
foreach (var entry in ordered)
|
||||||
|
{
|
||||||
|
var ident = ToIdentifier(entry.Name);
|
||||||
|
if (seenIdentifier.TryGetValue(ident, out var count))
|
||||||
|
{
|
||||||
|
seenIdentifier[ident] = count + 1;
|
||||||
|
var unique = $"{ident}_{count + 1}";
|
||||||
|
warnings.Add($"identifier collision: {entry.Name} (id {entry.Id}) → {unique}");
|
||||||
|
rendered.Add((unique, entry));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
seenIdentifier[ident] = 1;
|
||||||
|
rendered.Add((ident, entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder(capacity: 1 << 20);
|
||||||
|
sb.Append(Header);
|
||||||
|
|
||||||
|
foreach (var (ident, skill) in rendered.OrderBy(r => r.Identifier, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
sb.Append(" ").Append(RenderField(ident, skill, iconUrls)).Append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append('\n');
|
||||||
|
|
||||||
|
foreach (var (campaignIdentifier, listName) in CampaignListNames)
|
||||||
|
{
|
||||||
|
var members = rendered
|
||||||
|
.Where(r => r.Skill.CampaignIdentifier == campaignIdentifier)
|
||||||
|
.OrderBy(r => r.Identifier, StringComparer.Ordinal)
|
||||||
|
.Select(r => r.Identifier)
|
||||||
|
.ToList();
|
||||||
|
EmitList(sb, listName, members);
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append('\n');
|
||||||
|
EmitList(sb, "AllSkills",
|
||||||
|
rendered.Select(r => r.Identifier).OrderBy(i => i, StringComparer.Ordinal).Prepend("None").ToList());
|
||||||
|
|
||||||
|
sb.Append(Footer);
|
||||||
|
return (sb.ToString(), warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly (string CampaignIdentifier, string ListName)[] CampaignListNames =
|
||||||
|
[
|
||||||
|
("Core", "CoreSkills"),
|
||||||
|
("Prophecies", "PropheciesSkills"),
|
||||||
|
("Factions", "FactionsSkills"),
|
||||||
|
("Nightfall", "NightfallSkills"),
|
||||||
|
("EyeOfTheNorth", "EyeOfTheNorthSkills"),
|
||||||
|
("BonusMissionPack", "BonusMissionPackSkills"),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static void EmitList(StringBuilder sb, string name, IReadOnlyList<string> identifiers)
|
||||||
|
{
|
||||||
|
if (identifiers.Count == 0)
|
||||||
|
{
|
||||||
|
sb.Append(" public static readonly IReadOnlyList<Skill> ").Append(name).Append(" = [];\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(" public static readonly IReadOnlyList<Skill> ").Append(name).Append(" =\n");
|
||||||
|
sb.Append(" [\n");
|
||||||
|
foreach (var ident in identifiers)
|
||||||
|
{
|
||||||
|
sb.Append(" ").Append(ident).Append(",\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(" ];\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RenderField(string identifier, ParsedSkill s, IReadOnlyDictionary<string, string> iconUrls)
|
||||||
|
{
|
||||||
|
var icon = iconUrls.GetValueOrDefault(s.Name, string.Empty);
|
||||||
|
return $"public static readonly Skill {identifier} = new() {{ "
|
||||||
|
+ $"Id = {s.Id.ToString(CultureInfo.InvariantCulture)}, "
|
||||||
|
+ $"Name = {Quote(s.Name)}, "
|
||||||
|
+ $"Elite = {Bool(s.Elite)}, "
|
||||||
|
+ $"PvEOnly = {Bool(s.PvEOnly)}, "
|
||||||
|
+ $"PvP = {Bool(s.PvP)}, "
|
||||||
|
+ $"Campaign = Campaign.{s.CampaignIdentifier}, "
|
||||||
|
+ $"Profession = Profession.{s.ProfessionIdentifier}, "
|
||||||
|
+ $"Attribute = Attribute.{s.AttributeIdentifier}, "
|
||||||
|
+ $"Type = {s.TypeExpression}, "
|
||||||
|
+ $"Energy = {Number(s.Energy)}, "
|
||||||
|
+ $"Activation = {Number(s.Activation)}, "
|
||||||
|
+ $"Recharge = {Number(s.Recharge)}, "
|
||||||
|
+ $"Overcast = {Number(s.Overcast)}, "
|
||||||
|
+ $"Adrenaline = {Number(s.Adrenaline)}, "
|
||||||
|
+ $"Sacrifice = {Number(s.Sacrifice)}, "
|
||||||
|
+ $"Upkeep = {Number(s.Upkeep)}, "
|
||||||
|
+ $"Description = {Quote(s.Description)}, "
|
||||||
|
+ $"ConciseDescription = {Quote(s.ConciseDescription)}, "
|
||||||
|
+ $"IconUrl = {Quote(icon)}"
|
||||||
|
+ " };";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Bool(bool value) => value ? "true" : "false";
|
||||||
|
|
||||||
|
private static string Number(double? value)
|
||||||
|
{
|
||||||
|
if (value is not double d)
|
||||||
|
{
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
|
||||||
|
return d == Math.Floor(d) && !double.IsInfinity(d)
|
||||||
|
? ((long)d).ToString(CultureInfo.InvariantCulture)
|
||||||
|
: d.ToString("G17", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Quote(string? value)
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
return "\"\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder(value.Length + 2);
|
||||||
|
sb.Append('"');
|
||||||
|
foreach (var c in value)
|
||||||
|
{
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case '\\': sb.Append("\\\\"); break;
|
||||||
|
case '"': sb.Append("\\\""); break;
|
||||||
|
case '\n': sb.Append("\\n"); break;
|
||||||
|
case '\r': sb.Append("\\r"); break;
|
||||||
|
case '\t': sb.Append("\\t"); break;
|
||||||
|
default:
|
||||||
|
if (c < 0x20)
|
||||||
|
{
|
||||||
|
sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append('"');
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sanitises a skill display name into a C# identifier matching the
|
||||||
|
/// existing Daybreak convention: strip every non-alphanumeric character,
|
||||||
|
/// preserve original casing. PvP variants therefore become <c>FooPvP</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToIdentifier(string name)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(name.Length);
|
||||||
|
foreach (var c in name)
|
||||||
|
{
|
||||||
|
if (char.IsLetterOrDigit(c))
|
||||||
|
{
|
||||||
|
sb.Append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sb.Length == 0)
|
||||||
|
{
|
||||||
|
return "_";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char.IsDigit(sb[0]))
|
||||||
|
{
|
||||||
|
sb.Insert(0, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>$(TargetFrameworkVersion)</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<RootNamespace>Daybreak.Tools.SkillUpdater</RootNamespace>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<!-- Standalone developer tool. Intentionally has no project references -->
|
||||||
|
<!-- so a broken Skill.g.cs in Daybreak.Shared can never block re-running it. -->
|
||||||
|
<Platforms>AnyCPU</Platforms>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace Daybreak.Tools.SkillUpdater;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Polite HTTP client for the Guild Wars wiki.
|
||||||
|
/// * Single in-flight request (serialised behind a SemaphoreSlim).
|
||||||
|
/// * 250 ms minimum spacing between requests.
|
||||||
|
/// * Exponential back-off on 429/5xx (3 retries: 1s → 2s → 4s).
|
||||||
|
/// * Custom User-Agent identifying Daybreak.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WikiHttpClient : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan MinSpacing = TimeSpan.FromMilliseconds(250);
|
||||||
|
private static readonly TimeSpan[] BackoffDelays =
|
||||||
|
[
|
||||||
|
TimeSpan.FromSeconds(1),
|
||||||
|
TimeSpan.FromSeconds(2),
|
||||||
|
TimeSpan.FromSeconds(4),
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly HttpClient httpClient;
|
||||||
|
private readonly SemaphoreSlim gate = new(1, 1);
|
||||||
|
private DateTimeOffset nextSlot = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
public WikiHttpClient(string userAgent)
|
||||||
|
{
|
||||||
|
this.httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(60),
|
||||||
|
};
|
||||||
|
this.httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||||
|
this.httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetStringAsync(string url, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; ; attempt++)
|
||||||
|
{
|
||||||
|
await this.gate.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
if (now < this.nextSlot)
|
||||||
|
{
|
||||||
|
await Task.Delay(this.nextSlot - now, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var response = await this.httpClient.GetAsync(url, cancellationToken);
|
||||||
|
this.nextSlot = DateTimeOffset.UtcNow + MinSpacing;
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ShouldRetry(response.StatusCode) && attempt < BackoffDelays.Length)
|
||||||
|
{
|
||||||
|
var delay = response.Headers.RetryAfter?.Delta ?? BackoffDelays[attempt];
|
||||||
|
Console.Error.WriteLine($" ! {response.StatusCode}, backing off {delay.TotalSeconds:F0}s (attempt {attempt + 1}/{BackoffDelays.Length})");
|
||||||
|
await Task.Delay(delay, cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpRequestException($"GET {url} → {(int)response.StatusCode} {response.StatusCode}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
this.gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldRetry(HttpStatusCode code) =>
|
||||||
|
code == HttpStatusCode.TooManyRequests ||
|
||||||
|
(int)code >= 500;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
this.httpClient.Dispose();
|
||||||
|
this.gate.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Daybreak.Tools.SkillUpdater;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A skill in the exact shape the writer will emit it: every value is already
|
||||||
|
/// the literal C# token it should appear as in <c>Skill.g.cs</c>. This is
|
||||||
|
/// deliberately tool-specific — the runtime <c>WikiService</c> has its own
|
||||||
|
/// parser in <c>Daybreak.Shared</c> built around typed model objects.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ParsedSkill(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
string CampaignIdentifier,
|
||||||
|
string ProfessionIdentifier,
|
||||||
|
string AttributeIdentifier,
|
||||||
|
bool PvEOnly,
|
||||||
|
bool PvP,
|
||||||
|
bool Elite,
|
||||||
|
string TypeExpression,
|
||||||
|
double? Energy,
|
||||||
|
double? Activation,
|
||||||
|
double? Recharge,
|
||||||
|
double? Overcast,
|
||||||
|
double? Adrenaline,
|
||||||
|
double? Sacrifice,
|
||||||
|
double? Upkeep,
|
||||||
|
string Description,
|
||||||
|
string ConciseDescription)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extra ids the wiki listed alongside <see cref="Id"/> in the same
|
||||||
|
/// <c>id =</c> field (e.g. <c>id = 1954, 2097</c> for Luxon/Kurzick
|
||||||
|
/// "Save Yourselves!"). Each one becomes its own emitted skill.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<int> AdditionalIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filenames (without the <c>File:</c> prefix) the icon resolver should
|
||||||
|
/// try, in priority order. Defaults to just <see cref="Name"/>; shouts
|
||||||
|
/// also include the quoted form because their image files preserve the
|
||||||
|
/// surrounding quotes (e.g. <c>"Save Yourselves!".jpg</c>).
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> IconBaseNames { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a wiki page's <c>{{Skill infobox …}}</c> into a <see cref="ParsedSkill"/>
|
||||||
|
/// directly addressed at the codegen output. Every helper here exists because
|
||||||
|
/// the codegen step needs it; nothing is exposed beyond <see cref="TryParse"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class WikiSkillParser
|
||||||
|
{
|
||||||
|
public static bool TryParse(string? wikiText, out ParsedSkill skill)
|
||||||
|
{
|
||||||
|
skill = null!;
|
||||||
|
if (string.IsNullOrWhiteSpace(wikiText))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleaned = CleanWikiText(wikiText);
|
||||||
|
var body = ExtractInfoboxBody(cleaned);
|
||||||
|
if (body is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fields = ExtractFields(body);
|
||||||
|
|
||||||
|
string Get(string key) => fields.TryGetValue(key, out var v) ? v : string.Empty;
|
||||||
|
bool YesFlag(string key) => Get(key) is var v && v.Length > 0 && v[0] is 'y' or 'Y';
|
||||||
|
|
||||||
|
var allIds = ParseAllIds(Get("id"));
|
||||||
|
|
||||||
|
skill = new ParsedSkill(
|
||||||
|
Id: allIds.Count > 0 ? allIds[0] : 0,
|
||||||
|
Name: NormalizeName(Get("name")),
|
||||||
|
CampaignIdentifier: ResolveCampaign(Get("campaign")),
|
||||||
|
ProfessionIdentifier: ResolveProfession(Get("profession")),
|
||||||
|
AttributeIdentifier: ResolveAttribute(Get("attribute")),
|
||||||
|
PvEOnly: YesFlag("pve-only"),
|
||||||
|
PvP: YesFlag("is-pvp"),
|
||||||
|
Elite: YesFlag("elite"),
|
||||||
|
TypeExpression: ResolveSkillType(Get("type")),
|
||||||
|
Energy: ParseNumber(Get("energy")),
|
||||||
|
Activation: ParseNumber(Get("activation")),
|
||||||
|
Recharge: ParseNumber(Get("recharge")),
|
||||||
|
Overcast: ParseNumber(Get("overcast")),
|
||||||
|
Adrenaline: ParseNumber(Get("adrenaline")),
|
||||||
|
Sacrifice: ParseNumber(Get("sacrifice")),
|
||||||
|
Upkeep: ParseNumber(Get("upkeep")),
|
||||||
|
Description: Get("description"),
|
||||||
|
ConciseDescription: Get("concise description"));
|
||||||
|
if (allIds.Count > 1)
|
||||||
|
{
|
||||||
|
skill = skill with { AdditionalIds = [.. allIds.Skip(1)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strips wiki markup the codegen output should not carry over (links,
|
||||||
|
/// fraction templates, ranges, italics, HTML).
|
||||||
|
/// </summary>
|
||||||
|
private static string CleanWikiText(string wikiText)
|
||||||
|
{
|
||||||
|
var c = wikiText;
|
||||||
|
c = HalfFractionRegex().Replace(c, "¹⁄₂");
|
||||||
|
c = QuarterFractionRegex().Replace(c, "¹⁄₄");
|
||||||
|
c = ThreeQuarterFractionRegex().Replace(c, "³⁄₄");
|
||||||
|
c = ThreeOverTwoFractionRegex().Replace(c, "³⁄₂");
|
||||||
|
c = DecimalRegex().Replace(c, "$1");
|
||||||
|
c = GreenRangeRegex().Replace(c, "$1…$2");
|
||||||
|
c = GreenRange2Regex().Replace(c, "$1…$2");
|
||||||
|
c = GrayRangeRegex().Replace(c, "$1…$2");
|
||||||
|
c = LinkWithDisplayTextRegex().Replace(c, "$2");
|
||||||
|
c = SimpleLinkRegex().Replace(c, "$1");
|
||||||
|
c = GrayTextRegex().Replace(c, "");
|
||||||
|
c = GreyTextRegex().Replace(c, "");
|
||||||
|
c = SicTextRegex().Replace(c, "");
|
||||||
|
c = BoldTextRegex().Replace(c, "$1");
|
||||||
|
c = ItalicTextRegex().Replace(c, "$1");
|
||||||
|
c = HtmlTagRegex().Replace(c, "");
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractInfoboxBody(string wikiText)
|
||||||
|
{
|
||||||
|
const string startPattern = "{{Skill infobox";
|
||||||
|
var startIndex = wikiText.IndexOf(startPattern, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (startIndex == -1)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pipeIndex = wikiText.IndexOf('|', startIndex);
|
||||||
|
if (pipeIndex == -1)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var braceCount = 2;
|
||||||
|
var index = startIndex + startPattern.Length;
|
||||||
|
while (index < wikiText.Length && braceCount > 0)
|
||||||
|
{
|
||||||
|
if (wikiText[index] == '{' && index + 1 < wikiText.Length && wikiText[index + 1] == '{') { braceCount += 2; index += 2; }
|
||||||
|
else if (wikiText[index] == '}' && index + 1 < wikiText.Length && wikiText[index + 1] == '}') { braceCount -= 2; index += 2; }
|
||||||
|
else { index++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (braceCount != 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return wikiText.Substring(pipeIndex + 1, index - pipeIndex - 3).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks the body manually so that values containing pipes inside nested
|
||||||
|
/// templates (<c>{{gr|5|11}}</c>) or links don't get truncated.
|
||||||
|
/// </summary>
|
||||||
|
private static Dictionary<string, string> ExtractFields(string body)
|
||||||
|
{
|
||||||
|
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var i = 0;
|
||||||
|
while (i < body.Length)
|
||||||
|
{
|
||||||
|
while (i < body.Length && (body[i] == '|' || char.IsWhiteSpace(body[i])))
|
||||||
|
{
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameStart = i;
|
||||||
|
while (i < body.Length && body[i] != '=' && body[i] != '|')
|
||||||
|
{
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i >= body.Length || body[i] != '=')
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = body[nameStart..i].Trim();
|
||||||
|
i++;
|
||||||
|
|
||||||
|
var valueStart = i;
|
||||||
|
int braceDepth = 0, bracketDepth = 0;
|
||||||
|
while (i < body.Length)
|
||||||
|
{
|
||||||
|
var c = body[i];
|
||||||
|
if (c == '{' && i + 1 < body.Length && body[i + 1] == '{') { braceDepth++; i += 2; continue; }
|
||||||
|
if (c == '}' && i + 1 < body.Length && body[i + 1] == '}') { braceDepth--; i += 2; continue; }
|
||||||
|
if (c == '[' && i + 1 < body.Length && body[i + 1] == '[') { bracketDepth++; i += 2; continue; }
|
||||||
|
if (c == ']' && i + 1 < body.Length && body[i + 1] == ']') { bracketDepth--; i += 2; continue; }
|
||||||
|
if (c == '|' && braceDepth == 0 && bracketDepth == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = WhitespaceRegex().Replace(body[valueStart..i].Trim(), " ").Trim();
|
||||||
|
fields[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Numeric infobox value → <c>double?</c>. Empty / non-numeric text
|
||||||
|
/// (percentages, "morale boost", etc.) becomes <c>null</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static double? ParseNumber(string raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var s = raw.Trim();
|
||||||
|
|
||||||
|
// Plain mixed/unit fractions glyph-substituted by CleanWikiText.
|
||||||
|
var fraction = s.Contains("¹⁄₂") ? 0.5
|
||||||
|
: s.Contains("¹⁄₄") ? 0.25
|
||||||
|
: s.Contains("³⁄₄") ? 0.75
|
||||||
|
: s.Contains("³⁄₂") ? 1.5
|
||||||
|
: (double?)null;
|
||||||
|
|
||||||
|
if (fraction is double frac)
|
||||||
|
{
|
||||||
|
var fractionStart = s.IndexOfAny(['¹', '³']);
|
||||||
|
if (fractionStart > 0 &&
|
||||||
|
double.TryParse(s[..fractionStart].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var whole))
|
||||||
|
{
|
||||||
|
return whole + frac;
|
||||||
|
}
|
||||||
|
|
||||||
|
return frac;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d))
|
||||||
|
{
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.EndsWith('%') &&
|
||||||
|
double.TryParse(s[..^1].TrimEnd('+'), NumberStyles.Float, CultureInfo.InvariantCulture, out var pct))
|
||||||
|
{
|
||||||
|
return pct / 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Some skill infoboxes carry multiple ids in one field
|
||||||
|
/// (e.g. <c>id = 1954, 2097</c> for Luxon/Kurzick variants of "Save Yourselves!").
|
||||||
|
/// We return every integer found in source order; the writer turns each
|
||||||
|
/// one into its own emitted <c>Skill</c> entry.
|
||||||
|
/// </summary>
|
||||||
|
private static List<int> ParseAllIds(string raw)
|
||||||
|
{
|
||||||
|
var ids = new List<int>();
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Match match in FirstIntegerRegex().Matches(raw))
|
||||||
|
{
|
||||||
|
if (int.TryParse(match.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
|
||||||
|
{
|
||||||
|
ids.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strips the surrounding double quotes some wiki page titles carry
|
||||||
|
/// (shouts, e.g. <c>"Save Yourselves!"</c>) so the C# identifier and
|
||||||
|
/// display name match the historical Daybreak convention.
|
||||||
|
/// </summary>
|
||||||
|
private static string NormalizeName(string raw)
|
||||||
|
{
|
||||||
|
var trimmed = raw.Trim();
|
||||||
|
if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"')
|
||||||
|
{
|
||||||
|
return trimmed[1..^1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveSkillType(string raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return "SkillType.None";
|
||||||
|
}
|
||||||
|
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var parts = new List<string>();
|
||||||
|
foreach (var token in TypeTokenSplitRegex().Split(raw))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TypeTokens.TryGetValue(token, out var canonical) && seen.Add(canonical))
|
||||||
|
{
|
||||||
|
parts.Add($"SkillType.{canonical}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.Count == 0 ? "SkillType.None" : string.Join(" | ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCampaign(string raw) =>
|
||||||
|
Campaigns.GetValueOrDefault(raw.Trim(), "None");
|
||||||
|
|
||||||
|
private static string ResolveProfession(string raw) =>
|
||||||
|
Professions.GetValueOrDefault(raw.Trim(), "None");
|
||||||
|
|
||||||
|
private static string ResolveAttribute(string raw) =>
|
||||||
|
Attributes.GetValueOrDefault(raw.Trim(), "None");
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// The lookup tables intentionally live in this file, since they're a
|
||||||
|
// codegen detail of the parser — the runtime types in Daybreak.Shared
|
||||||
|
// already have their own canonical name lists.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> Campaigns = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[""] = "None",
|
||||||
|
["None"] = "None",
|
||||||
|
["Core"] = "Core",
|
||||||
|
["Prophecies"] = "Prophecies",
|
||||||
|
["Factions"] = "Factions",
|
||||||
|
["Nightfall"] = "Nightfall",
|
||||||
|
["Eye of the North"] = "EyeOfTheNorth",
|
||||||
|
["EotN"] = "EyeOfTheNorth",
|
||||||
|
["Bonus Mission Pack"] = "BonusMissionPack",
|
||||||
|
["BMP"] = "BonusMissionPack",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> Professions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[""] = "None",
|
||||||
|
["None"] = "None",
|
||||||
|
["Common"] = "None",
|
||||||
|
["Warrior"] = "Warrior",
|
||||||
|
["Ranger"] = "Ranger",
|
||||||
|
["Monk"] = "Monk",
|
||||||
|
["Necromancer"] = "Necromancer",
|
||||||
|
["Mesmer"] = "Mesmer",
|
||||||
|
["Elementalist"] = "Elementalist",
|
||||||
|
["Assassin"] = "Assassin",
|
||||||
|
["Ritualist"] = "Ritualist",
|
||||||
|
["Paragon"] = "Paragon",
|
||||||
|
["Dervish"] = "Dervish",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> Attributes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[""] = "None",
|
||||||
|
["None"] = "None",
|
||||||
|
["Fast Casting"] = "FastCasting",
|
||||||
|
["Illusion Magic"] = "IllusionMagic",
|
||||||
|
["Domination Magic"] = "DominationMagic",
|
||||||
|
["Inspiration Magic"] = "InspirationMagic",
|
||||||
|
["Blood Magic"] = "BloodMagic",
|
||||||
|
["Death Magic"] = "DeathMagic",
|
||||||
|
["Soul Reaping"] = "SoulReaping",
|
||||||
|
["Curses"] = "Curses",
|
||||||
|
["Air Magic"] = "AirMagic",
|
||||||
|
["Earth Magic"] = "EarthMagic",
|
||||||
|
["Fire Magic"] = "FireMagic",
|
||||||
|
["Water Magic"] = "WaterMagic",
|
||||||
|
["Energy Storage"] = "EnergyStorage",
|
||||||
|
["Healing Prayers"] = "HealingPrayers",
|
||||||
|
["Smiting Prayers"] = "SmitingPrayers",
|
||||||
|
["Protection Prayers"] = "ProtectionPrayers",
|
||||||
|
["Divine Favor"] = "DivineFavor",
|
||||||
|
["Strength"] = "Strength",
|
||||||
|
["Axe Mastery"] = "AxeMastery",
|
||||||
|
["Hammer Mastery"] = "HammerMastery",
|
||||||
|
["Swordsmanship"] = "Swordsmanship",
|
||||||
|
["Tactics"] = "Tactics",
|
||||||
|
["Beast Mastery"] = "BeastMastery",
|
||||||
|
["Expertise"] = "Expertise",
|
||||||
|
["Wilderness Survival"] = "WildernessSurvival",
|
||||||
|
["Marksmanship"] = "Marksmanship",
|
||||||
|
["Critical Strikes"] = "CriticalStrikes",
|
||||||
|
["Dagger Mastery"] = "DaggerMastery",
|
||||||
|
["Deadly Arts"] = "DeadlyArts",
|
||||||
|
["Shadow Arts"] = "ShadowArts",
|
||||||
|
["Spawning Power"] = "SpawningPower",
|
||||||
|
["Channeling Magic"] = "ChannelingMagic",
|
||||||
|
["Communing"] = "Communing",
|
||||||
|
["Restoration Magic"] = "RestorationMagic",
|
||||||
|
["Spear Mastery"] = "SpearMastery",
|
||||||
|
["Command"] = "Command",
|
||||||
|
["Motivation"] = "Motivation",
|
||||||
|
["Leadership"] = "Leadership",
|
||||||
|
["Scythe Mastery"] = "ScytheMastery",
|
||||||
|
["Wind Prayers"] = "WindPrayers",
|
||||||
|
["Earth Prayers"] = "EarthPrayers",
|
||||||
|
["Mysticism"] = "Mysticism",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> TypeTokens = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["skill"] = "Skill",
|
||||||
|
["touch"] = "Touch",
|
||||||
|
["spell"] = "Spell",
|
||||||
|
["hex"] = "Hex",
|
||||||
|
["enchantment"] = "Enchantment",
|
||||||
|
["flash"] = "Flash",
|
||||||
|
["echo"] = "Echo",
|
||||||
|
["ward"] = "Ward",
|
||||||
|
["glyph"] = "Glyph",
|
||||||
|
["well"] = "Well",
|
||||||
|
["ritual"] = "Ritual",
|
||||||
|
["binding"] = "Binding",
|
||||||
|
["nature"] = "Nature",
|
||||||
|
["ebon"] = "EbonVanguard",
|
||||||
|
["vanguard"] = "EbonVanguard",
|
||||||
|
["item"] = "Item",
|
||||||
|
["weapon"] = "Weapon",
|
||||||
|
["attack"] = "Attack",
|
||||||
|
["axe"] = "Axe",
|
||||||
|
["bow"] = "Bow",
|
||||||
|
["melee"] = "Melee",
|
||||||
|
["hammer"] = "Hammer",
|
||||||
|
["sword"] = "Sword",
|
||||||
|
["spear"] = "Spear",
|
||||||
|
["pet"] = "Pet",
|
||||||
|
["lead"] = "Lead",
|
||||||
|
["off-hand"] = "OffHand",
|
||||||
|
["offhand"] = "OffHand",
|
||||||
|
["dual"] = "Dual",
|
||||||
|
["scythe"] = "Scythe",
|
||||||
|
["ranged"] = "Ranged",
|
||||||
|
["chant"] = "Chant",
|
||||||
|
["shout"] = "Shout",
|
||||||
|
["signet"] = "Signet",
|
||||||
|
["preparation"] = "Preparation",
|
||||||
|
["stance"] = "Stance",
|
||||||
|
["form"] = "Form",
|
||||||
|
["trap"] = "Trap",
|
||||||
|
};
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\{\{1/2\}\}", RegexOptions.IgnoreCase)] private static partial Regex HalfFractionRegex();
|
||||||
|
[GeneratedRegex(@"\{\{1/4\}\}", RegexOptions.IgnoreCase)] private static partial Regex QuarterFractionRegex();
|
||||||
|
[GeneratedRegex(@"\{\{3/4\}\}", RegexOptions.IgnoreCase)] private static partial Regex ThreeQuarterFractionRegex();
|
||||||
|
[GeneratedRegex(@"\{\{3/2\}\}", RegexOptions.IgnoreCase)] private static partial Regex ThreeOverTwoFractionRegex();
|
||||||
|
[GeneratedRegex(@"\{\{(\d+(?:\.\d+)?)\}\}")] private static partial Regex DecimalRegex();
|
||||||
|
[GeneratedRegex(@"\{\{gr\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)] private static partial Regex GreenRangeRegex();
|
||||||
|
[GeneratedRegex(@"\{\{gr2\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)] private static partial Regex GreenRange2Regex();
|
||||||
|
[GeneratedRegex(@"\{\{gray\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)] private static partial Regex GrayRangeRegex();
|
||||||
|
[GeneratedRegex(@"\{\{sic(?:\|([^}]*))?\}\}", RegexOptions.IgnoreCase)] private static partial Regex SicTextRegex();
|
||||||
|
[GeneratedRegex(@"\[\[([^\]|]+)\]\]")] private static partial Regex SimpleLinkRegex();
|
||||||
|
[GeneratedRegex(@"\[\[([^|\]]+)\|([^\]]+)\]\]")] private static partial Regex LinkWithDisplayTextRegex();
|
||||||
|
[GeneratedRegex(@"'''([^']+)'''")] private static partial Regex BoldTextRegex();
|
||||||
|
[GeneratedRegex(@"''([^']+)''")] private static partial Regex ItalicTextRegex();
|
||||||
|
[GeneratedRegex(@"<[^>]*>")] private static partial Regex HtmlTagRegex();
|
||||||
|
[GeneratedRegex(@"\s+")] private static partial Regex WhitespaceRegex();
|
||||||
|
[GeneratedRegex(@"\{\{gray\|([^}]+)\}\}", RegexOptions.IgnoreCase)] private static partial Regex GrayTextRegex();
|
||||||
|
[GeneratedRegex(@"\{\{grey\|([^}]+)\}\}", RegexOptions.IgnoreCase)] private static partial Regex GreyTextRegex();
|
||||||
|
[GeneratedRegex(@"[\s\-]+")] private static partial Regex TypeTokenSplitRegex();
|
||||||
|
[GeneratedRegex(@"\d+")] private static partial Regex FirstIntegerRegex();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user