From 503d535d955c39470d7a04d78b540ada82dfc9ff Mon Sep 17 00:00:00 2001 From: Gent Semaj Date: Thu, 18 Jun 2026 19:06:54 -0700 Subject: [PATCH 01/22] Fix incorrect calculation of reliable packet IDs under backpressure This was a REAL server logic bug and the true cause of the G9 error. --- .../Internal/UdpReliableChannel.cs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/Sanctuary.UdpLibrary/Internal/UdpReliableChannel.cs b/src/Sanctuary.UdpLibrary/Internal/UdpReliableChannel.cs index dad7ef9..04a0bca 100644 --- a/src/Sanctuary.UdpLibrary/Internal/UdpReliableChannel.cs +++ b/src/Sanctuary.UdpLibrary/Internal/UdpReliableChannel.cs @@ -63,6 +63,7 @@ private struct PhysicalPacket public LogicalPacket? Parent; public int? DataPtr; public int DataLen; + public long ReliableId; } private struct IncomingQueueEntry @@ -461,7 +462,12 @@ public UdpClockStamp GiveTime() // if we have queue space if (readyPtr < readyEnd) { - ReadyQueue[readyPtr++] = entry; + ReadyQueue[readyPtr] = entry; + // remember this entry's true reliable id (its ring position), + // mirroring the original library which stored pointers into the + // physical-packet ring and recovered the id from the offset. + ReadyQueue[readyPtr].ReliableId = i; + readyPtr++; } else { @@ -513,9 +519,14 @@ public UdpClockStamp GiveTime() if (entry.DataPtr.Value != 0 || entry.DataLen != entry.Parent.GetDataLen()) fragment = true; - // we can calculate what our reliableId should be based on our position in the array - // need to handle the case where we wrap around the end of the array - var reliableId = ReliableOutgoingPendingId + (readyWalk - 1); + // use the entry's true reliable id (captured from its ring position when it + // was queued). Deriving the id from the ready-queue index instead is only + // correct when the ready queue is a gapless prefix of the outstanding window; + // under partial resends the queue is compacted, which would otherwise stamp the + // payload with the wrong sequence number and duplicate data under a fresh id. + // That causes a correctly-fired G9 (corrupted packet) error from the client + // whenever there's enough backpressure on the wire to cause many retransmissions. + var reliableId = entry.ReliableId; // prep the actual packet and send it buf[0] = 0; From f62d3793104cee129dcf89aa090a023c1005a566 Mon Sep 17 00:00:00 2001 From: Gent Semaj Date: Thu, 18 Jun 2026 19:09:40 -0700 Subject: [PATCH 02/22] Revert "G9 Fix: Change `MaxOutstandingBytes` to match client UDP socket buffer size (64kb) (#16)" This change was a mitigation; now that the root cause was fixed, it's no longer needed. This reverts commit 7689101b9c3d0d48f4c735674931a06a3c849e74. --- src/Sanctuary.Gateway/Program.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Sanctuary.Gateway/Program.cs b/src/Sanctuary.Gateway/Program.cs index 9a55d5c..1152f2e 100644 --- a/src/Sanctuary.Gateway/Program.cs +++ b/src/Sanctuary.Gateway/Program.cs @@ -81,12 +81,6 @@ ProtocolName = "CGAPI_527" }; - for (int i = 0; i < udpParams.Reliable.Length; i++) - { - // DO NOT INCREASE; latent client bug when larger than socket buffer size - udpParams.Reliable[i].MaxOutstandingBytes = 64 * 1024; - } - if (serverOptions.UseCompression) { udpParams.EncryptMethod[0] = EncryptMethod.UserSupplied; From 1bf38592eab90b1d5491c6aa94be750183b211a2 Mon Sep 17 00:00:00 2001 From: Gent Semaj Date: Fri, 19 Jun 2026 07:00:32 -0700 Subject: [PATCH 03/22] Trim comment --- src/Sanctuary.UdpLibrary/Internal/UdpReliableChannel.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Sanctuary.UdpLibrary/Internal/UdpReliableChannel.cs b/src/Sanctuary.UdpLibrary/Internal/UdpReliableChannel.cs index 04a0bca..678d61e 100644 --- a/src/Sanctuary.UdpLibrary/Internal/UdpReliableChannel.cs +++ b/src/Sanctuary.UdpLibrary/Internal/UdpReliableChannel.cs @@ -524,8 +524,6 @@ public UdpClockStamp GiveTime() // correct when the ready queue is a gapless prefix of the outstanding window; // under partial resends the queue is compacted, which would otherwise stamp the // payload with the wrong sequence number and duplicate data under a fresh id. - // That causes a correctly-fired G9 (corrupted packet) error from the client - // whenever there's enough backpressure on the wire to cause many retransmissions. var reliableId = entry.ReliableId; // prep the actual packet and send it From 91d0a9a8c686a4bba52d59e8dbf319e56d29f2d2 Mon Sep 17 00:00:00 2001 From: JadenY Date: Sat, 27 Jun 2026 23:34:03 -0500 Subject: [PATCH 04/22] Add consumables system: transformations, boomboxes, food effects - 70+ transformation entries with model swap, duration, cooldown - Boombox items with NPC spawning, music, and dance sequences - Food effects for visual particle overlays - ConsumableCollection, definitions, and data loading - Ability packet handler processes transformations, boomboxes, and food effects - Player model replacement and temporary appearance packets - ResourceManager integration for Consumables.json hot-reload --- src/Resources/Consumables.json | 111 +++ src/Sanctuary.Game/Entities/Npc.cs | 5 +- src/Sanctuary.Game/Entities/Player.cs | 63 +- src/Sanctuary.Game/IResourceManager.cs | 5 +- src/Sanctuary.Game/ResourceManager.cs | 10 +- .../Resources/BoomboxDefinitionCollection.cs | 70 ++ .../Resources/ConsumableCollection.cs | 103 ++ .../Definitions/BoomboxDefinition.cs | 9 + .../Definitions/ConsumableDefinitions.cs | 25 + .../Definitions/FoodEffectDefinition.cs | 8 + .../Definitions/TransformAbilityDefinition.cs | 9 + .../Resources/FoodEffectCollection.cs | 70 ++ .../Resources/TransformAbilityCollection.cs | 70 ++ src/Sanctuary.Game/Zones/BaseZone.cs | 8 +- src/Sanctuary.Game/Zones/IZone.cs | 5 +- ...yPacketClientRequestStartAbilityHandler.cs | 938 +++++++++++++++++- src/Sanctuary.Packet.Common/ActionBarSlot.cs | 1 + src/Sanctuary.Packet.Common/ClientPcData.cs | 16 +- .../NotificationInfo.cs | 39 + .../AbilityPacketAbilityDefinition.cs | 35 + .../AbilityPacketExecuteClientLua.cs | 25 + .../AbilityPacketRequestAbilityDefinition.cs | 31 + .../AbilityPacketSetDefinition.cs | 29 + .../ClientUpdatePacketAddEffectTag.cs | 26 + .../ClientUpdatePacketRemoveEffectTag.cs | 25 + src/Sanctuary.Packet/BaseCombatPacket.cs | 32 + .../CombatPacketAttackProcessed.cs | 24 + .../CombatPacketAttackTargetDamage.cs | 37 + .../CombatPacketAutoAttackTarget.cs | 35 + .../PlayerUpdatePacketAddNotifications.cs | 28 + .../PlayerUpdatePacketDestroyed.cs | 40 + .../PlayerUpdatePacketHitPointModification.cs | 41 + .../PlayerUpdatePacketQueueAnimation.cs | 29 + ...erUpdatePacketRemoveTemporaryAppearance.cs | 29 + .../PlayerUpdatePacketReplaceBaseModel.cs | 31 + .../PlayerUpdatePacketRequestStripEffect.cs | 27 + .../PlayerUpdatePacketUpdateHitpoints.cs | 32 + .../PlayerUpdatePacketUpdateScale.cs | 30 + ...erUpdatePacketUpdateTemporaryAppearance.cs | 30 + 39 files changed, 2161 insertions(+), 20 deletions(-) create mode 100644 src/Resources/Consumables.json create mode 100644 src/Sanctuary.Game/Resources/BoomboxDefinitionCollection.cs create mode 100644 src/Sanctuary.Game/Resources/ConsumableCollection.cs create mode 100644 src/Sanctuary.Game/Resources/Definitions/BoomboxDefinition.cs create mode 100644 src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs create mode 100644 src/Sanctuary.Game/Resources/Definitions/FoodEffectDefinition.cs create mode 100644 src/Sanctuary.Game/Resources/Definitions/TransformAbilityDefinition.cs create mode 100644 src/Sanctuary.Game/Resources/FoodEffectCollection.cs create mode 100644 src/Sanctuary.Game/Resources/TransformAbilityCollection.cs create mode 100644 src/Sanctuary.Packet.Common/NotificationInfo.cs create mode 100644 src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketAbilityDefinition.cs create mode 100644 src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketExecuteClientLua.cs create mode 100644 src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketRequestAbilityDefinition.cs create mode 100644 src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketSetDefinition.cs create mode 100644 src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketAddEffectTag.cs create mode 100644 src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketRemoveEffectTag.cs create mode 100644 src/Sanctuary.Packet/BaseCombatPacket.cs create mode 100644 src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackProcessed.cs create mode 100644 src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackTargetDamage.cs create mode 100644 src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAutoAttackTarget.cs create mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketAddNotifications.cs create mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketDestroyed.cs create mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketHitPointModification.cs create mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketQueueAnimation.cs create mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketRemoveTemporaryAppearance.cs create mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketReplaceBaseModel.cs create mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketRequestStripEffect.cs create mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateHitpoints.cs create mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateScale.cs create mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateTemporaryAppearance.cs diff --git a/src/Resources/Consumables.json b/src/Resources/Consumables.json new file mode 100644 index 0000000..0baa853 --- /dev/null +++ b/src/Resources/Consumables.json @@ -0,0 +1,111 @@ +{ + "Boomboxes": [ + { "ItemId": 878, "ModelId": 1936, "EffectId": 16379, "DanceSequence": [3531, 3532, 3533, 3534] }, + { "ItemId": 76641, "ModelId": 1936, "EffectId": 16379, "DanceSequence": [3531, 3532, 3533, 3534] }, + { "ItemId": 1180, "ModelId": 1973, "EffectId": 0, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, + { "ItemId": 1913, "ModelId": 1979, "EffectId": 16452, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, + { "ItemId": 8185, "ModelId": 2217, "EffectId": 0, "DanceSequence": [3551, 3552, 3553, 3554] }, + { "ItemId": 35759, "ModelId": 4012, "EffectId": 17042, "DanceSequence": [3568004, 3569004, 3570004, 3571004] }, + { "ItemId": 48244, "ModelId": 3893, "EffectId": 0, "DanceSequence": [3568005, 3569005, 3570005, 3571005] }, + { "ItemId": 56774, "ModelId": 1661, "EffectId": 0, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, + { "ItemId": 69825, "ModelId": 1062, "EffectId": 0, "DanceSequence": [3511, 3512, 3513, 3514, 3515] }, + { "ItemId": 76927, "ModelId": 4076, "EffectId": 16452, "DanceSequence": [3568010, 3569010, 3570010, 3571010] }, + { "ItemId": 76928, "ModelId": 2201, "EffectId": 16451, "DanceSequence": [3568006, 3569006, 3570006, 3571006] }, + { "ItemId": 76930, "ModelId": 4096, "EffectId": 16453, "DanceSequence": [3568001, 3569001, 3570001, 3571001] }, + { "ItemId": 76933, "ModelId": 4099, "EffectId": 0, "DanceSequence": [3568008, 3569008, 3570008, 3571008] }, + { "ItemId": 76935, "ModelId": 4428, "EffectId": 16928, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, + { "ItemId": 77329, "ModelId": 2059, "EffectId": 16466, "DanceSequence": [3560, 3561, 3562, 3563] }, + { "ItemId": 76934, "ModelId": 4100, "EffectId": 16827, "DanceSequence": [3568009, 3569009, 3570009, 3571009] } + ], + + "FoodEffects": [ + { "AbilityId": 1956, "CompositeEffectId": 47, "Comment": "Electric Veal Meat → PFX_electricity_blue_skel_loop" }, + { "AbilityId": 1957, "CompositeEffectId": 5032, "Comment": "Rocking Road Mix → PFX_barrier-rock_gray_loop" }, + { "AbilityId": 1958, "CompositeEffectId": 1, "Comment": "Fiery Fury Candy → PFX_fire_skel_loop" }, + { "AbilityId": 1960, "CompositeEffectId": 5, "Comment": "Mr. Gleam's Granola Bar → PFX_magic-glow_white_neck_trail_loop" }, + { "AbilityId": 1961, "CompositeEffectId": 5102, "Comment": "Silver Surf 'n Turf → PFX_water_blue_cog_elemental-barrier_loop" }, + { "AbilityId": 1962, "CompositeEffectId": 174, "Comment": "Deep Mines Delight → PFX_spores-smoke_brown_chest_cloud" }, + { "AbilityId": 1963, "CompositeEffectId": 1115, "Comment": "Roaring Rapids Roast → PFX_waves_blue_body_land" }, + { "AbilityId": 1965, "CompositeEffectId": 5298, "Comment": "Noxious Pork Chops → PFX_smoke-bubbles_green_head_anesthesia_loop" }, + { "AbilityId": 1966, "CompositeEffectId": 12, "Comment": "Firefrog Legs → PFX_fire_orange_feet_sprint_loop" }, + { "AbilityId": 1967, "CompositeEffectId": 5370, "Comment": "Hot Sauce Buns → PFX_steam_red_ears_taunt_loop" }, + { "AbilityId": 1968, "CompositeEffectId": 5732, "Comment": "Arbor Buttercake → WFX_leaves_multi_falling_skel_loop" }, + { "AbilityId": 1969, "CompositeEffectId": 1107, "Comment": "Pixiedust Pie → PFX_pixie_dust_med_interact_loop" }, + { "AbilityId": 1970, "CompositeEffectId": 630, "Comment": "Frosty Flakes → PFX_clouds-snowflakes_white_mouth_breath_loop" }, + { "AbilityId": 1973, "CompositeEffectId": 15173, "Comment": "Scrumptious Soup → PFX_steam_white_rising_skel" }, + { "AbilityId": 2004, "CompositeEffectId": 4013, "Comment": "Steaming Pretzels → PFX_steam_white_rising" } + ], + + "Transformations": [ + { "AbilityId": 3042, "ModelId": 169, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3043, "ModelId": 170, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3044, "ModelId": 171, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3045, "ModelId": 172, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3046, "ModelId": 173, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3047, "ModelId": 364, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3056, "ModelId": 348, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3016, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3011, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3012, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3013, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3014, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3015, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 1990, "ModelId": 176, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 2031, "ModelId": 176, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 1998, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3027, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3028, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3029, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3030, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3031, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3032, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3033, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3034, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3035, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3036, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3037, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 1987, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3005, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3017, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3018, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3019, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3020, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3021, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3022, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3023, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3024, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3025, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 1986, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 1994, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3060, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3061, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3062, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3063, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3064, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3065, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3066, "ModelId": 142, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3057, "ModelId": 142, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3067, "ModelId": 142, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3059, "ModelId": 52, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3071, "ModelId": 71, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3072, "ModelId": 71, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3058, "ModelId": 180, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3026, "ModelId": 65, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3001, "ModelId": 70, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3002, "ModelId": 160, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 1979, "ModelId": 136, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3051, "ModelId": 136, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 1983, "ModelId": 139, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3052, "ModelId": 138, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3053, "ModelId": 139, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3055, "ModelId": 188, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 1997, "ModelId": 130, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3038, "ModelId": 130, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 286, "ModelId": 22, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 3070, "ModelId": 177, "DurationMs": 10000, "CooldownMs": 10000 }, + { "AbilityId": 4370, "ModelId": 2055, "DurationMs": 30000, "CooldownMs": 10000, "Comment": "Boss Cake → Stone Heart" }, + { "AbilityId": 4371, "ModelId": 1944, "DurationMs": 30000, "CooldownMs": 10000, "Comment": "Boss Cake → Abominable Snowman" }, + { "AbilityId": 4372, "ModelId": 1702, "DurationMs": 30000, "CooldownMs": 10000, "Comment": "Boss Cake → Pumpkin Prince" }, + { "AbilityId": 4373, "ModelId": 22, "DurationMs": 30000, "CooldownMs": 10000, "Comment": "Boss Cake → Cakenstein" } + ] +} diff --git a/src/Sanctuary.Game/Entities/Npc.cs b/src/Sanctuary.Game/Entities/Npc.cs index 4b65edc..24c75db 100644 --- a/src/Sanctuary.Game/Entities/Npc.cs +++ b/src/Sanctuary.Game/Entities/Npc.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Numerics; @@ -45,6 +45,7 @@ public class Npc : IEntity /// 2 - Ally /// public int Disposition { get; set; } = 1; + public System.Action? InteractAction { get; set; } public int Animation { get; set; } = 1; @@ -312,4 +313,4 @@ public virtual void Dispose() Zone.TryRemoveNpc(Guid); } -} \ No newline at end of file +} diff --git a/src/Sanctuary.Game/Entities/Player.cs b/src/Sanctuary.Game/Entities/Player.cs index 941c0dc..68c444c 100644 --- a/src/Sanctuary.Game/Entities/Player.cs +++ b/src/Sanctuary.Game/Entities/Player.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Frozen; using System.Collections.Generic; @@ -52,6 +52,11 @@ public sealed class Player : ClientPcData, IEntity public int TimezoneOffset { get; set; } + // Tracks which item GUIDs are on which action bar slot. + public Dictionary> ActionBarItemGuids { get; set; } = new(); + + + public Vector4 StartingZonePosition { get; set; } public Quaternion StartingZoneRotation { get; set; } @@ -522,6 +527,62 @@ public PlayerUpdatePacketAddPc GetAddPcPacket() return packet; } + + public void ApplyTemporaryAppearance(int modelId, int durationMs) + { + TemporaryAppearance = modelId; + + var replacePacket = new PlayerUpdatePacketReplaceBaseModel + { + Guid = Guid, + ModelId = modelId + }; + SendTunneled(replacePacket); + + var appearancePacket = new PlayerUpdatePacketUpdateTemporaryAppearance + { + Guid = Guid, + TemporaryAppearance = modelId + }; + SendToVisible(appearancePacket, true); + + if (durationMs > 0) + { + var capturedGuid = Guid; + System.Threading.Tasks.Task.Delay(durationMs).ContinueWith(_ => + { + try + { + TemporaryAppearance = 0; + var removePacket = new PlayerUpdatePacketRemoveTemporaryAppearance + { + Guid = capturedGuid + }; + SendToVisible(removePacket, true); + } + catch { } + }); + } + } + + public void RemoveTemporaryAppearance() + { + TemporaryAppearance = 0; + + var removePacket = new PlayerUpdatePacketRemoveTemporaryAppearance + { + Guid = Guid + }; + SendToVisible(removePacket, true); + + var replacePacket = new PlayerUpdatePacketReplaceBaseModel + { + Guid = Guid, + ModelId = Model + }; + SendTunneled(replacePacket); + } + #region Equatable public bool Equals(IEntity? other) diff --git a/src/Sanctuary.Game/IResourceManager.cs b/src/Sanctuary.Game/IResourceManager.cs index a0b1dbb..f1a345a 100644 --- a/src/Sanctuary.Game/IResourceManager.cs +++ b/src/Sanctuary.Game/IResourceManager.cs @@ -1,4 +1,4 @@ -using Sanctuary.Game.Resources; +using Sanctuary.Game.Resources; namespace Sanctuary.Game; @@ -31,6 +31,7 @@ public interface IResourceManager ProfileDefinitionCollection Profiles { get; } QuickChatDefinitionCollection QuickChats { get; } PointOfInterestDefinitionCollection PointOfInterests { get; } + ConsumableCollection Consumables { get; } bool Load(); -} \ No newline at end of file +} diff --git a/src/Sanctuary.Game/ResourceManager.cs b/src/Sanctuary.Game/ResourceManager.cs index 76fcbdb..3e5a38b 100644 --- a/src/Sanctuary.Game/ResourceManager.cs +++ b/src/Sanctuary.Game/ResourceManager.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using Microsoft.Extensions.Logging; @@ -42,6 +42,7 @@ public class ResourceManager : IResourceManager public static readonly string QuickChatsFile = Path.Combine(BaseDirectory, "QuickChats.json"); public static readonly string PlayerTitlesFile = Path.Combine(BaseDirectory, "PlayerTitles.json"); public static readonly string PointOfInterestsFile = Path.Combine(BaseDirectory, "PointOfInterests.json"); + public static readonly string ConsumablesFile = Path.Combine(BaseDirectory, "Consumables.json"); public IdToStringLookup HairMappings { get; } public IdToStringLookup HeadMappings { get; } @@ -71,6 +72,7 @@ public class ResourceManager : IResourceManager public ProfileDefinitionCollection Profiles { get; } public QuickChatDefinitionCollection QuickChats { get; } public PointOfInterestDefinitionCollection PointOfInterests { get; } + public ConsumableCollection Consumables { get; } public ResourceManager(ILogger logger) { @@ -108,6 +110,7 @@ public ResourceManager(ILogger logger) QuickChats = new(_logger); PlayerTitles = new(_logger); PointOfInterests = new(_logger); + Consumables = new(_logger); } public bool Load() @@ -178,6 +181,9 @@ public bool Load() if (!PointOfInterests.Load(PointOfInterestsFile)) return false; + if (!Consumables.Load(ConsumablesFile)) + return false; + return true; } @@ -245,4 +251,4 @@ private void _fileSystemWatcher_Changed(object sender, FileSystemEventArgs e) _fileSystemWatcher.EnableRaisingEvents = true; } } -} \ No newline at end of file +} diff --git a/src/Sanctuary.Game/Resources/BoomboxDefinitionCollection.cs b/src/Sanctuary.Game/Resources/BoomboxDefinitionCollection.cs new file mode 100644 index 0000000..341e581 --- /dev/null +++ b/src/Sanctuary.Game/Resources/BoomboxDefinitionCollection.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; + +using Microsoft.Extensions.Logging; + +using Sanctuary.Core.Collections; +using Sanctuary.Game.Resources.Definitions; + +namespace Sanctuary.Game.Resources; + +public class BoomboxDefinitionCollection : ObservableConcurrentDictionary +{ + private readonly ILogger _logger; + + public BoomboxDefinitionCollection(ILogger logger) + { + _logger = logger; + } + + public bool Load(string filePath) + { + if (!File.Exists(filePath)) + { + _logger.LogError("Failed to find file \"{file}\"", filePath); + return false; + } + + try + { + using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + + var jsonSerializerOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var list = JsonSerializer.Deserialize>(fileStream, jsonSerializerOptions); + + if (list is null) + { + _logger.LogError("No entries found in file \"{file}\".", filePath); + return false; + } + + foreach (var entry in list) + { + if (!TryAdd(entry.ItemId, entry)) + { + _logger.LogWarning("Failed to add entry. {id} \"{file}\"", entry.ItemId, filePath); + return false; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to parse file \"{file}\".", filePath); + return false; + } + + if (Count == 0) + { + _logger.LogError("No data was loaded. \"{file}\"", filePath); + return false; + } + + return true; + } +} diff --git a/src/Sanctuary.Game/Resources/ConsumableCollection.cs b/src/Sanctuary.Game/Resources/ConsumableCollection.cs new file mode 100644 index 0000000..737f681 --- /dev/null +++ b/src/Sanctuary.Game/Resources/ConsumableCollection.cs @@ -0,0 +1,103 @@ +using System; +using System.IO; +using System.Text.Json; + +using Microsoft.Extensions.Logging; + +using Sanctuary.Game.Resources.Definitions; + +namespace Sanctuary.Game.Resources; + +/// +/// Loads and manages consumable items from the consolidated Consumables.json file. +/// Provides access to Boomboxes, FoodEffects, and Transformations. +/// +public class ConsumableCollection +{ + private readonly ILogger _logger; + + public BoomboxDefinitionCollection Boomboxes { get; } + public FoodEffectCollection FoodEffects { get; } + public TransformAbilityCollection Transformations { get; } + + public ConsumableCollection(ILogger logger) + { + _logger = logger; + Boomboxes = new BoomboxDefinitionCollection(logger); + FoodEffects = new FoodEffectCollection(logger); + Transformations = new TransformAbilityCollection(logger); + } + + public bool Load(string filePath) + { + if (!File.Exists(filePath)) + { + _logger.LogError("Failed to find file \"{file}\"", filePath); + return false; + } + + try + { + using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + + var jsonSerializerOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var consumables = JsonSerializer.Deserialize(fileStream, jsonSerializerOptions); + + if (consumables is null) + { + _logger.LogError("No entries found in file \"{file}\".", filePath); + return false; + } + + // Load Boomboxes + foreach (var entry in consumables.Boomboxes) + { + if (!Boomboxes.TryAdd(entry.ItemId, entry)) + { + _logger.LogWarning("Failed to add Boombox entry. ItemId={id} \"{file}\"", entry.ItemId, filePath); + return false; + } + } + _logger.LogInformation("Loaded {count} Boombox definitions.", Boomboxes.Count); + + // Load FoodEffects + foreach (var entry in consumables.FoodEffects) + { + if (!FoodEffects.TryAdd(entry.AbilityId, entry)) + { + _logger.LogWarning("Failed to add FoodEffect entry. AbilityId={id} \"{file}\"", entry.AbilityId, filePath); + return false; + } + } + _logger.LogInformation("Loaded {count} FoodEffect definitions.", FoodEffects.Count); + + // Load Transformations + foreach (var entry in consumables.Transformations) + { + if (!Transformations.TryAdd(entry.AbilityId, entry)) + { + _logger.LogWarning("Failed to add Transformation entry. AbilityId={id} \"{file}\"", entry.AbilityId, filePath); + return false; + } + } + _logger.LogInformation("Loaded {count} Transformation definitions.", Transformations.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to parse file \"{file}\".", filePath); + return false; + } + + if (Boomboxes.Count == 0 && FoodEffects.Count == 0 && Transformations.Count == 0) + { + _logger.LogError("No data was loaded from \"{file}\"", filePath); + return false; + } + + return true; + } +} diff --git a/src/Sanctuary.Game/Resources/Definitions/BoomboxDefinition.cs b/src/Sanctuary.Game/Resources/Definitions/BoomboxDefinition.cs new file mode 100644 index 0000000..760a85b --- /dev/null +++ b/src/Sanctuary.Game/Resources/Definitions/BoomboxDefinition.cs @@ -0,0 +1,9 @@ +namespace Sanctuary.Game.Resources.Definitions; + +public class BoomboxDefinition +{ + public int ItemId { get; set; } + public int ModelId { get; set; } + public int EffectId { get; set; } + public int[] DanceSequence { get; set; } = []; +} diff --git a/src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs b/src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs new file mode 100644 index 0000000..4f3d23d --- /dev/null +++ b/src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace Sanctuary.Game.Resources.Definitions; + +/// +/// Container for all consumable item definitions loaded from Consumables.json. +/// Includes Boomboxes, FoodEffects, and Transformations. +/// +public class ConsumableDefinitions +{ + /// + /// Boombox items that play music and dances. + /// + public List Boomboxes { get; set; } = new(); + + /// + /// Food items that apply visual effects to the player. + /// + public List FoodEffects { get; set; } = new(); + + /// + /// Transformation abilities that change the player's appearance. + /// + public List Transformations { get; set; } = new(); +} diff --git a/src/Sanctuary.Game/Resources/Definitions/FoodEffectDefinition.cs b/src/Sanctuary.Game/Resources/Definitions/FoodEffectDefinition.cs new file mode 100644 index 0000000..346ed1b --- /dev/null +++ b/src/Sanctuary.Game/Resources/Definitions/FoodEffectDefinition.cs @@ -0,0 +1,8 @@ +namespace Sanctuary.Game.Resources.Definitions; + +public class FoodEffectDefinition +{ + public int AbilityId { get; set; } + public int CompositeEffectId { get; set; } + public string? Comment { get; set; } +} diff --git a/src/Sanctuary.Game/Resources/Definitions/TransformAbilityDefinition.cs b/src/Sanctuary.Game/Resources/Definitions/TransformAbilityDefinition.cs new file mode 100644 index 0000000..50e9f66 --- /dev/null +++ b/src/Sanctuary.Game/Resources/Definitions/TransformAbilityDefinition.cs @@ -0,0 +1,9 @@ +namespace Sanctuary.Game.Resources.Definitions; + +public class TransformAbilityDefinition +{ + public int AbilityId { get; set; } + public int ModelId { get; set; } + public int DurationMs { get; set; } + public int CooldownMs { get; set; } +} diff --git a/src/Sanctuary.Game/Resources/FoodEffectCollection.cs b/src/Sanctuary.Game/Resources/FoodEffectCollection.cs new file mode 100644 index 0000000..978d38b --- /dev/null +++ b/src/Sanctuary.Game/Resources/FoodEffectCollection.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; + +using Microsoft.Extensions.Logging; + +using Sanctuary.Core.Collections; +using Sanctuary.Game.Resources.Definitions; + +namespace Sanctuary.Game.Resources; + +public class FoodEffectCollection : ObservableConcurrentDictionary +{ + private readonly ILogger _logger; + + public FoodEffectCollection(ILogger logger) + { + _logger = logger; + } + + public bool Load(string filePath) + { + if (!File.Exists(filePath)) + { + _logger.LogError("Failed to find file \"{file}\"", filePath); + return false; + } + + try + { + using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + + var jsonSerializerOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var list = JsonSerializer.Deserialize>(fileStream, jsonSerializerOptions); + + if (list is null) + { + _logger.LogError("No entries found in file \"{file}\".", filePath); + return false; + } + + foreach (var entry in list) + { + if (!TryAdd(entry.AbilityId, entry)) + { + _logger.LogWarning("Failed to add entry. {id} \"{file}\"", entry.AbilityId, filePath); + return false; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to parse file \"{file}\".", filePath); + return false; + } + + if (Count == 0) + { + _logger.LogError("No data was loaded. \"{file}\"", filePath); + return false; + } + + return true; + } +} diff --git a/src/Sanctuary.Game/Resources/TransformAbilityCollection.cs b/src/Sanctuary.Game/Resources/TransformAbilityCollection.cs new file mode 100644 index 0000000..209afa9 --- /dev/null +++ b/src/Sanctuary.Game/Resources/TransformAbilityCollection.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; + +using Microsoft.Extensions.Logging; + +using Sanctuary.Core.Collections; +using Sanctuary.Game.Resources.Definitions; + +namespace Sanctuary.Game.Resources; + +public class TransformAbilityCollection : ObservableConcurrentDictionary +{ + private readonly ILogger _logger; + + public TransformAbilityCollection(ILogger logger) + { + _logger = logger; + } + + public bool Load(string filePath) + { + if (!File.Exists(filePath)) + { + _logger.LogError("Failed to find file \"{file}\"", filePath); + return false; + } + + try + { + using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + + var jsonSerializerOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var list = JsonSerializer.Deserialize>(fileStream, jsonSerializerOptions); + + if (list is null) + { + _logger.LogError("No entries found in file \"{file}\".", filePath); + return false; + } + + foreach (var entry in list) + { + if (!TryAdd(entry.AbilityId, entry)) + { + _logger.LogWarning("Failed to add entry. {id} \"{file}\"", entry.AbilityId, filePath); + return false; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to parse file \"{file}\".", filePath); + return false; + } + + if (Count == 0) + { + _logger.LogError("No data was loaded. \"{file}\"", filePath); + return false; + } + + return true; + } +} diff --git a/src/Sanctuary.Game/Zones/BaseZone.cs b/src/Sanctuary.Game/Zones/BaseZone.cs index e014f24..4de9ada 100644 --- a/src/Sanctuary.Game/Zones/BaseZone.cs +++ b/src/Sanctuary.Game/Zones/BaseZone.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Frozen; using System.Collections.Generic; @@ -82,6 +82,10 @@ public virtual void OnClientFinishedLoading(Player player) { } + public virtual void RefreshPlayerCustomizations(Player player) + { + } + #endregion #region Entities @@ -411,4 +415,4 @@ public void Dispose() _npcs.Clear(); _players.Clear(); } -} \ No newline at end of file +} diff --git a/src/Sanctuary.Game/Zones/IZone.cs b/src/Sanctuary.Game/Zones/IZone.cs index c5b471d..b77b0c5 100644 --- a/src/Sanctuary.Game/Zones/IZone.cs +++ b/src/Sanctuary.Game/Zones/IZone.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Numerics; @@ -17,6 +17,7 @@ public interface IZone void OnClientIsReady(Player entity); void OnClientFinishedLoading(Player entity); + void RefreshPlayerCustomizations(Player player); #endregion @@ -47,4 +48,4 @@ public interface IZone void UpdateEntityZoneTile(IEntity entity, ZoneTile from, ZoneTile to); #endregion -} \ No newline at end of file +} diff --git a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs index 2da0b71..f999230 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs @@ -1,8 +1,18 @@ -using System; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Sanctuary.Core.Helpers; +using Sanctuary.Core.IO; +using Sanctuary.Database; +using Sanctuary.Game; using Sanctuary.Packet; using Sanctuary.Packet.Common.Attributes; @@ -12,11 +22,19 @@ namespace Sanctuary.Gateway.Handlers; public static class AbilityPacketClientRequestStartAbilityHandler { private static ILogger _logger = null!; + private static IResourceManager _resourceManager = null!; + private static IDbContextFactory _dbContextFactory = null!; + + // playerGuid ? (itemDefinitionId ? cooldown expiry) + private static readonly ConcurrentDictionary> _boomboxCooldowns = new(); public static void ConfigureServices(IServiceProvider serviceProvider) { var loggerFactory = serviceProvider.GetRequiredService(); _logger = loggerFactory.CreateLogger(nameof(AbilityPacketClientRequestStartAbilityHandler)); + + _resourceManager = serviceProvider.GetRequiredService(); + _dbContextFactory = serviceProvider.GetRequiredService>(); } public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan data) @@ -27,7 +45,13 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan return false; } - _logger.LogTrace("Received {name} packet. ( {packet} )", nameof(AbilityPacketClientRequestStartAbility), packet); + _logger.LogInformation("AbilityPacket: Id={Id} Slot={Slot}", packet.Data.Id, packet.Data.Slot); + + // Check if this is an item ability (ActionBarId = 2 is ItemActionBar) + if (packet.Data.Id == 2) + { + return HandleItemAbility(connection, packet); + } var abilityPacketFailed = new AbilityPacketFailed { @@ -39,4 +63,914 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan return true; } + + private static bool HandleItemAbility(GatewayConnection connection, AbilityPacketClientRequestStartAbility packet) + { + // Get the action bar slot to find the item + connection.Player.ActionBars.TryGetValue(2, out var actionBar); + + Packet.Common.ActionBarSlot? slot = null; + if (actionBar != null && actionBar.Slots.TryGetValue(packet.Data.Slot, out var foundSlot) && !foundSlot.IsEmpty) + { + slot = foundSlot; + } + else + { + SendFailure(connection, 3079); + return true; + } + + // Get the item GUID from the tracked action bar items + if (!connection.Player.ActionBarItemGuids.TryGetValue(2, out var actionBarItems) || + !actionBarItems.TryGetValue(packet.Data.Slot, out var itemGuid)) + { + SendFailure(connection, 3079); + return true; + } + + // Find the item in the player's inventory by GUID + var clientItem = connection.Player.Items.FirstOrDefault(x => x.Id == itemGuid); + + if (clientItem is null) + { + SendFailure(connection, 3079); + return true; + } + + if (!_resourceManager.ClientItemDefinitions.TryGetValue(clientItem.Definition, out var clientItemDefinition)) + { + SendFailure(connection, 3079); + return true; + } + + // Check if item has an activatable ability + if (clientItemDefinition.ActivatableAbilityId == 0) + { + SendFailure(connection, 3079); + return true; + } + + bool isBoombox = _resourceManager.Consumables.Boomboxes.ContainsKey(clientItemDefinition.Id); + + bool isScaredyCake = clientItemDefinition.ActivatableAbilityId == 4360; + bool isBossCake = clientItemDefinition.ActivatableAbilityId >= 4370 && clientItemDefinition.ActivatableAbilityId <= 4373; + bool isCake = isScaredyCake || isBossCake; + + if (isBossCake || isBoombox || isScaredyCake) + { + var playerCooldowns = _boomboxCooldowns.GetOrAdd(connection.Player.Guid, _ => new ConcurrentDictionary()); + + if (playerCooldowns.TryGetValue(clientItemDefinition.Id, out var expiry) && DateTimeOffset.UtcNow < expiry) + { + SendFailure(connection, 3079); + return true; + } + + if (isBossCake) + SpawnBossCakeNpc(connection); + else if (isScaredyCake) + SpawnCakeNpc(connection); + else + SpawnBoomboxNpc(connection, clientItemDefinition); + + int cooldownMs = isBossCake ? 30_000 : 60_000; + playerCooldowns[clientItemDefinition.Id] = DateTimeOffset.UtcNow.AddMilliseconds(cooldownMs); + + var capturedSlot = packet.Data.Slot; + var capturedItemDef = clientItemDefinition; + var capturedCount = clientItem.Count; + var capturedPlayerGuid = connection.Player.Guid; + + // Cooldown sweep: Enabled=false hides the icon and lets C++ draw the sweep overlay. + // Unknown10 (RefreshTimeLeft) = elapsed ms from 0; TotalRefreshTime = total duration. + // Periodic ticks update elapsed so the sweep advances visually. + var cooldownSlot = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = capturedSlot } }; + cooldownSlot.Slot.IsEmpty = false; + cooldownSlot.Slot.IconId = capturedItemDef.Icon.Id; + cooldownSlot.Slot.NameId = capturedItemDef.NameId; + cooldownSlot.Slot.Unknown5 = 1; + cooldownSlot.Slot.Unknown6 = 4; + cooldownSlot.Slot.Clear = 15; + cooldownSlot.Slot.Enabled = false; + cooldownSlot.Slot.Unknown10 = 0; + cooldownSlot.Slot.TotalRefreshTime = cooldownMs; + cooldownSlot.Slot.Unknown12 = 0; + cooldownSlot.Slot.Quantity = capturedCount; + cooldownSlot.Slot.ForceDismount = true; + cooldownSlot.Slot.Unknown15 = 0; + connection.SendTunneled(cooldownSlot); + + // Send elapsed-time updates every 2s so the client sweep advances. + var startTime = DateTimeOffset.UtcNow; + _ = Task.Run(async () => + { + try + { + while (true) + { + await Task.Delay(2000); + int elapsed = (int)(DateTimeOffset.UtcNow - startTime).TotalMilliseconds; + if (elapsed >= cooldownMs) break; + + var tickSlot = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = capturedSlot } }; + tickSlot.Slot.IsEmpty = false; + tickSlot.Slot.IconId = capturedItemDef.Icon.Id; + tickSlot.Slot.NameId = capturedItemDef.NameId; + tickSlot.Slot.Unknown5 = 1; + tickSlot.Slot.Unknown6 = 4; + tickSlot.Slot.Clear = 15; + tickSlot.Slot.Enabled = false; + tickSlot.Slot.Unknown10 = elapsed; + tickSlot.Slot.TotalRefreshTime = cooldownMs; + tickSlot.Slot.Unknown12 = elapsed; + tickSlot.Slot.Quantity = capturedCount; + tickSlot.Slot.ForceDismount = true; + tickSlot.Slot.Unknown15 = elapsed; + connection.SendTunneled(tickSlot); + } + } + catch { } + }); + + Task.Delay(cooldownMs).ContinueWith(_ => + { + try + { + if (_boomboxCooldowns.TryGetValue(capturedPlayerGuid, out var cd)) + cd.TryRemove(capturedItemDef.Id, out DateTimeOffset _); + + var readySlot = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = capturedSlot } }; + readySlot.Slot.IsEmpty = false; + readySlot.Slot.IconId = capturedItemDef.Icon.Id; + readySlot.Slot.NameId = capturedItemDef.NameId; + readySlot.Slot.Unknown5 = 1; + readySlot.Slot.Unknown6 = 4; + readySlot.Slot.Clear = 15; + readySlot.Slot.Enabled = true; + readySlot.Slot.Unknown10 = 1000; + readySlot.Slot.TotalRefreshTime = 1000; + readySlot.Slot.Quantity = capturedCount; + readySlot.Slot.ForceDismount = true; + readySlot.Slot.Unknown15 = 1000; + connection.SendTunneled(readySlot); + } + catch { } + }); + + return true; + } + + _logger.LogInformation("HandleItemAbility: itemDef={ItemDefId} abilityId={AbilityId}", clientItemDefinition.Id, clientItemDefinition.ActivatableAbilityId); + + if (_resourceManager.Consumables.Transformations.TryGetValue(clientItemDefinition.ActivatableAbilityId, out var transform)) + { + _logger.LogInformation("Transform match: modelId={ModelId} durationMs={Duration}", transform.ModelId, transform.DurationMs); + var playerCooldowns = _boomboxCooldowns.GetOrAdd(connection.Player.Guid, _ => new ConcurrentDictionary()); + + if (playerCooldowns.TryGetValue(clientItemDefinition.Id, out var expiry) && DateTimeOffset.UtcNow < expiry) + { + SendFailure(connection, 3079); + return true; + } + + if (connection.Player.TemporaryAppearance != 0) + { + SendFailure(connection, 3079); + return true; + } + + ApplyTransform(connection, transform.ModelId, transform.DurationMs); + + playerCooldowns[clientItemDefinition.Id] = DateTimeOffset.UtcNow.AddMilliseconds(transform.CooldownMs); + + var capturedSlot = packet.Data.Slot; + var capturedItemDef = clientItemDefinition; + var capturedCount = clientItem.Count; + var capturedPlayerGuid = connection.Player.Guid; + var cooldownMs = transform.CooldownMs; + + // Show cooldown sweep only when the player has more of this item remaining in the stack. + // If this was the last one, ConsumeItem will clear the slot and no sweep is needed. + bool willHaveItemLeft = capturedCount > 1; + + if (clientItemDefinition.SingleUse) + ConsumeItem(connection, clientItem, clientItemDefinition, capturedSlot); + + if (willHaveItemLeft) + { + var startTime = DateTimeOffset.UtcNow; + + var cooldownSlot = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = capturedSlot } }; + cooldownSlot.Slot.IsEmpty = false; + cooldownSlot.Slot.IconId = capturedItemDef.Icon.Id; + cooldownSlot.Slot.NameId = capturedItemDef.NameId; + cooldownSlot.Slot.Unknown5 = 1; + cooldownSlot.Slot.Unknown6 = 4; + cooldownSlot.Slot.Clear = 15; + cooldownSlot.Slot.Enabled = false; + cooldownSlot.Slot.Unknown10 = 0; + cooldownSlot.Slot.TotalRefreshTime = cooldownMs; + cooldownSlot.Slot.Unknown12 = 0; + cooldownSlot.Slot.Quantity = capturedCount - 1; + cooldownSlot.Slot.ForceDismount = true; + cooldownSlot.Slot.Unknown15 = 0; + connection.SendTunneled(cooldownSlot); + + _ = Task.Run(async () => + { + try + { + while (true) + { + await Task.Delay(2000); + int elapsed = (int)(DateTimeOffset.UtcNow - startTime).TotalMilliseconds; + if (elapsed >= cooldownMs) break; + + var tickSlot = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = capturedSlot } }; + tickSlot.Slot.IsEmpty = false; + tickSlot.Slot.IconId = capturedItemDef.Icon.Id; + tickSlot.Slot.NameId = capturedItemDef.NameId; + tickSlot.Slot.Unknown5 = 1; + tickSlot.Slot.Unknown6 = 4; + tickSlot.Slot.Clear = 15; + tickSlot.Slot.Enabled = false; + tickSlot.Slot.Unknown10 = elapsed; + tickSlot.Slot.TotalRefreshTime = cooldownMs; + tickSlot.Slot.Unknown12 = elapsed; + tickSlot.Slot.Quantity = capturedCount - 1; + tickSlot.Slot.ForceDismount = true; + tickSlot.Slot.Unknown15 = elapsed; + connection.SendTunneled(tickSlot); + } + } + catch { } + }); + + Task.Delay(cooldownMs).ContinueWith(_ => + { + try + { + if (_boomboxCooldowns.TryGetValue(capturedPlayerGuid, out var cd)) + cd.TryRemove(capturedItemDef.Id, out DateTimeOffset _); + + var readySlot = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = capturedSlot } }; + readySlot.Slot.IsEmpty = false; + readySlot.Slot.IconId = capturedItemDef.Icon.Id; + readySlot.Slot.NameId = capturedItemDef.NameId; + readySlot.Slot.Unknown5 = 1; + readySlot.Slot.Unknown6 = 4; + readySlot.Slot.Clear = 15; + readySlot.Slot.Enabled = true; + readySlot.Slot.Unknown10 = 1000; + readySlot.Slot.TotalRefreshTime = 1000; + readySlot.Slot.Quantity = capturedCount - 1; + readySlot.Slot.ForceDismount = true; + readySlot.Slot.Unknown15 = 1000; + connection.SendTunneled(readySlot); + } + catch { } + }); + } + else + { + // Item was the last in the stack — slot is now empty, just clear the cooldown entry when done. + Task.Delay(cooldownMs).ContinueWith(_ => + { + if (_boomboxCooldowns.TryGetValue(capturedPlayerGuid, out var cd)) + cd.TryRemove(capturedItemDef.Id, out DateTimeOffset _); + }); + } + + return true; + } + + // Trigger the ability effect FIRST (before consuming) + TriggerAbilityEffect(connection, clientItemDefinition); + + if (clientItemDefinition.SingleUse) + return ConsumeItem(connection, clientItem, clientItemDefinition, packet.Data.Slot); + + return true; + } + + private static bool ConsumeItem(GatewayConnection connection, Packet.Common.ClientItem clientItem, + Packet.Common.ClientItemDefinition clientItemDefinition, int actionBarSlot) + { + using var dbContext = _dbContextFactory.CreateDbContext(); + + var characterId = GuidHelper.GetPlayerId(connection.Player.Guid); + var dbItem = dbContext.Items.SingleOrDefault(i => i.CharacterId == characterId && i.Id == clientItem.Id); + + if (dbItem is null) + { + SendFailure(connection, 3079); + return true; + } + + // Decrement item count + dbItem.Count--; + + var shouldDeleteItem = dbItem.Count <= 0; + + if (shouldDeleteItem) + { + dbContext.Items.Remove(dbItem); + } + + if (dbContext.SaveChanges() <= 0) + { + SendFailure(connection, 3079); + return true; + } + + // Update client-side item + if (shouldDeleteItem) + { + connection.Player.Items.Remove(clientItem); + + var clientUpdatePacketItemDelete = new ClientUpdatePacketItemDelete + { + ItemGuid = clientItem.Id + }; + + connection.SendTunneled(clientUpdatePacketItemDelete); + + // Clear the action bar slot + var clientUpdatePacketUpdateActionBarSlot = new ClientUpdatePacketUpdateActionBarSlot + { + Data = + { + Id = 2, + Slot = actionBarSlot + } + }; + + clientUpdatePacketUpdateActionBarSlot.Slot.IsEmpty = true; + + // Remove from tracked items + if (connection.Player.ActionBarItemGuids.TryGetValue(2, out var trackedItems)) + { + trackedItems.Remove(actionBarSlot); + } + + connection.SendTunneled(clientUpdatePacketUpdateActionBarSlot); + } + else + { + clientItem.Count--; + + var clientUpdatePacketItemUpdate = new ClientUpdatePacketItemUpdate + { + ItemGuid = clientItem.Id, + Count = clientItem.Count, + ConsumedCount = clientItem.ConsumedCount, + AbilityCount = clientItem.AbilityCount, + RentalExpirationTime = 0 + }; + + connection.SendTunneled(clientUpdatePacketItemUpdate); + + // Update action bar slot quantity + var clientUpdatePacketUpdateActionBarSlot = new ClientUpdatePacketUpdateActionBarSlot + { + Data = + { + Id = 2, + Slot = actionBarSlot + } + }; + + clientUpdatePacketUpdateActionBarSlot.Slot.IsEmpty = false; + clientUpdatePacketUpdateActionBarSlot.Slot.IconId = clientItemDefinition.Icon.Id; + clientUpdatePacketUpdateActionBarSlot.Slot.NameId = clientItemDefinition.NameId; + clientUpdatePacketUpdateActionBarSlot.Slot.Unknown5 = 1; + clientUpdatePacketUpdateActionBarSlot.Slot.Unknown6 = 4; + clientUpdatePacketUpdateActionBarSlot.Slot.Clear = 15; + clientUpdatePacketUpdateActionBarSlot.Slot.Enabled = true; + clientUpdatePacketUpdateActionBarSlot.Slot.Unknown10 = 1000; + clientUpdatePacketUpdateActionBarSlot.Slot.TotalRefreshTime = 1000; + clientUpdatePacketUpdateActionBarSlot.Slot.Quantity = clientItem.Count; + clientUpdatePacketUpdateActionBarSlot.Slot.ForceDismount = true; + clientUpdatePacketUpdateActionBarSlot.Slot.Unknown15 = 1000; + + connection.SendTunneled(clientUpdatePacketUpdateActionBarSlot); + } + + return true; + } + + private static void TriggerAbilityEffect(GatewayConnection connection, Packet.Common.ClientItemDefinition clientItemDefinition) + { + if (clientItemDefinition.ActivatableAbilityId == 0) + return; + + int abilityId = clientItemDefinition.ActivatableAbilityId; + + if (abilityId == 660) // Can of Beans - fart effect + { + var fartAnimation = new QuickChatSendChatToChannelPacket + { + Id = 3340, // emo_fart + Guid = connection.Player.Guid, + Name = connection.Player.Name ?? new Packet.Common.NameData(), + Channel = Packet.Common.Chat.ChatChannel.WorldArea, + AreaNameId = 0, + GuildGuid = 0 + }; + connection.SendTunneled(fartAnimation); + connection.Player.SendToVisible(fartAnimation, false); + + System.Threading.Tasks.Task.Delay(300).ContinueWith(_ => + { + var fartEffect = new PlayerUpdatePacketPlayCompositeEffect + { + Guid = connection.Player.Guid, + CompositeEffectId = 5343, + Clear = true + }; + connection.SendTunneled(fartEffect); + connection.Player.SendToVisible(fartEffect, false); + }); + } + else if (abilityId == 1964) // Graveyard Flambe - shadow flames + { + // Visual effect only for this one + var flameEffect = new PlayerUpdatePacketPlayCompositeEffect + { + Guid = connection.Player.Guid, + CompositeEffectId = 5265, + Clear = true + }; + connection.SendTunneled(flameEffect); + connection.Player.SendToVisible(flameEffect, false); + + _logger.LogInformation($"Player {connection.Player.Name.FirstName} activated shadow flames"); + } + else + { + // For other consumables, just send the ability packet + var abilityPacketExecuteClientLua = new AbilityPacketExecuteClientLua + { + AbilityId = abilityId + }; + + connection.Player.SendTunneledToVisible(abilityPacketExecuteClientLua, true); + + // Play a themed composite effect if one is mapped for this ability + int effectId = 0; + if (_resourceManager.Consumables.FoodEffects.TryGetValue(abilityId, out var foodEffect)) + effectId = foodEffect.CompositeEffectId; + else if (clientItemDefinition.CompositeEffectId != 0) + effectId = clientItemDefinition.CompositeEffectId; + + if (effectId != 0) + { + var playerUpdatePacketPlayCompositeEffect = new PlayerUpdatePacketPlayCompositeEffect + { + Guid = connection.Player.Guid, + CompositeEffectId = effectId, + Clear = true, // attach to entity skeleton, not world position + }; + + connection.Player.SendTunneledToVisible(playerUpdatePacketPlayCompositeEffect, true); + } + } + } + + private static void SpawnCakeNpc(GatewayConnection connection) + { + try + { + var zone = connection.Player.Zone; + + if (zone is not Game.Zones.StartingZone startingZone) + return; + + if (!startingZone.TryCreateNpc(out var cakeNpc)) + return; + + cakeNpc.NameId = 16634; // "Scaredy Cake" + cakeNpc.ModelId = 1724; // evnt_halloween_cake_01.adr + cakeNpc.TextureAlias = ""; + cakeNpc.TintAlias = ""; + cakeNpc.Scale = 1.0f; + cakeNpc.Animation = 1; + cakeNpc.HideNamePlate = false; + cakeNpc.IsInteractable = true; + cakeNpc.CursorId = 5; // triggers NpcRelevance packet ? client shows "Press X" + + // Spawn 1.5 m in front of the player + var forwardDirection = Vector3.Transform(new Vector3(0, 0, 1), connection.Player.Rotation); + var spawnPosition = new Vector4( + connection.Player.Position.X + forwardDirection.X * 1.5f, + connection.Player.Position.Y + forwardDirection.Y * 1.5f, + connection.Player.Position.Z + forwardDirection.Z * 1.5f, + connection.Player.Position.W + ); + + cakeNpc.Visible = true; + cakeNpc.UpdatePosition(spawnPosition, connection.Player.Rotation); + + // When a player presses X near the cake, play the jumpscare effect sequence. + // One trigger at a time — ignore while a scare is already playing. + var scareActive = false; + cakeNpc.InteractAction = (interactingPlayer) => + { + if (scareActive) return; + scareActive = true; + + var cakePosition = cakeNpc.Position; + + void Broadcast(ISerializablePacket pkt) + { + interactingPlayer.SendTunneled(pkt); + foreach (var p in interactingPlayer.VisiblePlayers.Values) + p.SendTunneled(pkt); + } + + void SendEffect(int effectId) => Broadcast(new PlayerUpdatePacketPlayCompositeEffect + { + Guid = cakeNpc.Guid, + CompositeEffectId = effectId, + Position = cakePosition, + Clear = true + }); + + switch (Random.Shared.Next(3)) + { + case 0: // Bats + SendEffect(5455); // EFX_bats_exp_flyaway (one-shot) + SendEffect(5165); // SFX_OS_BatChirps (one-shot) + SendEffect(15923); // PFX_halloween_icing_splat_cog (one-shot) + break; + case 1: // Ghost + SendEffect(15909); // PFX_halloween_ghosts_up_loop + SendEffect(15960); // SFX_Halloween_GhostCrys + SendEffect(15961); // SFX_Halloween_GhostMoans + break; + case 2: // Raven + SendEffect(15744); // EFX_birds_pink_med_flyaway (one-shot, birds scatter) + SendEffect(5118); // SFX_BirdCrowCaw (one-shot) + SendEffect(15959); // SFX_Halloween_Crows (one-shot) + break; + } + + Task.Delay(2000).ContinueWith(_ => { scareActive = false; }); + }; + + var poofEffect = new PlayerUpdatePacketPlayCompositeEffect + { + Guid = cakeNpc.Guid, + CompositeEffectId = 21, // PFX_smoke_black_explosion + Position = spawnPosition, + Clear = false + }; + + // Use OnAddVisibleNpcs so each player also gets the NpcRelevance packet + // (CursorId=5 ? client shows the "Press X to interact" prompt) + connection.Player.SendTunneled(poofEffect); + connection.Player.OnAddVisibleNpcs([cakeNpc]); + foreach (var player in connection.Player.VisiblePlayers.Values) + { + player.SendTunneled(poofEffect); + player.OnAddVisibleNpcs([cakeNpc]); + } + + var capturedNpc = cakeNpc; + + Task.Delay(60_000).ContinueWith(_ => + { + try + { + var removePacket = new PlayerUpdatePacketRemovePlayerGracefully + { + Guid = capturedNpc.Guid, + Animate = false, + Delay = 0, + EffectDelay = 0, + CompositeEffectId = 21, + Duration = 500 + }; + + var players = startingZone.Players; + if (players is not null) + { + foreach (var player in players) + player.SendTunneled(removePacket); + } + + capturedNpc.Dispose(); + } + catch { } + }); + } + catch { } + } + + private static void SpawnBossCakeNpc(GatewayConnection connection) + { + try + { + var zone = connection.Player.Zone; + + if (zone is not Game.Zones.StartingZone startingZone) + return; + + if (!startingZone.TryCreateNpc(out var cakeNpc)) + return; + + cakeNpc.NameId = 16635; // Boss Cake + cakeNpc.ModelId = 1724; // evnt_halloween_cake_01.adr + cakeNpc.TextureAlias = ""; + cakeNpc.TintAlias = ""; + cakeNpc.Scale = 1.0f; + cakeNpc.Animation = 1; + cakeNpc.HideNamePlate = false; + cakeNpc.IsInteractable = true; + cakeNpc.CursorId = 5; + + var forwardDirection = Vector3.Transform(new Vector3(0, 0, 1), connection.Player.Rotation); + var spawnPosition = new Vector4( + connection.Player.Position.X + forwardDirection.X * 1.5f, + connection.Player.Position.Y + forwardDirection.Y * 1.5f, + connection.Player.Position.Z + forwardDirection.Z * 1.5f, + connection.Player.Position.W + ); + + cakeNpc.Visible = true; + cakeNpc.UpdatePosition(spawnPosition, connection.Player.Rotation); + + // Randomly pick one of the 4 boss transformations each time a player interacts + int[] bossAbilities = [4370, 4371, 4372, 4373]; + + cakeNpc.InteractAction = (interactingPlayer) => + { + int abilityId = bossAbilities[Random.Shared.Next(bossAbilities.Length)]; + + if (_resourceManager.Consumables.Transformations.TryGetValue(abilityId, out var transform)) + ApplyTransform(interactingPlayer, transform.ModelId, transform.DurationMs); + }; + + var poofEffect = new PlayerUpdatePacketPlayCompositeEffect + { + Guid = cakeNpc.Guid, + CompositeEffectId = 21, + Position = spawnPosition, + Clear = false + }; + + connection.Player.SendTunneled(poofEffect); + connection.Player.OnAddVisibleNpcs([cakeNpc]); + foreach (var player in connection.Player.VisiblePlayers.Values) + { + player.SendTunneled(poofEffect); + player.OnAddVisibleNpcs([cakeNpc]); + } + + var capturedNpc = cakeNpc; + + Task.Delay(60_000).ContinueWith(_ => + { + try + { + var removePacket = new PlayerUpdatePacketRemovePlayerGracefully + { + Guid = capturedNpc.Guid, + Animate = false, + Delay = 0, + EffectDelay = 0, + CompositeEffectId = 21, + Duration = 500 + }; + + var players = startingZone.Players; + if (players is not null) + { + foreach (var player in players) + player.SendTunneled(removePacket); + } + + capturedNpc.Dispose(); + } + catch { } + }); + } + catch { } + } + + private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common.ClientItemDefinition itemDef) + { + try + { + var zone = connection.Player.Zone; + + if (zone is not Game.Zones.StartingZone startingZone) + return; + + // Try to create an NPC for the boombox + if (!startingZone.TryCreateNpc(out var boomboxNpc)) + return; + + _resourceManager.Consumables.Boomboxes.TryGetValue(itemDef.Id, out var boomboxDef); + int modelId = boomboxDef?.ModelId ?? 1062; + int effectId = boomboxDef?.EffectId ?? 0; + int[] danceSequence = boomboxDef?.DanceSequence ?? [3501, 3502, 3503, 3504, 3505]; + + boomboxNpc.NameId = 0; + boomboxNpc.ModelId = modelId; + boomboxNpc.Name = "Boombox"; + boomboxNpc.TextureAlias = itemDef.TextureAlias ?? ""; + boomboxNpc.TintAlias = itemDef.TintAlias ?? ""; + boomboxNpc.Scale = 1.0f; + boomboxNpc.Animation = 2100; // Bouncing animation + boomboxNpc.CompositeEffectId = effectId; // Owned by the entity — client stops it when RemovePlayer is received + boomboxNpc.HideNamePlate = true; + boomboxNpc.IsInteractable = false; + + // Position boombox to the left of player + var leftDirection = System.Numerics.Vector3.Transform( + new System.Numerics.Vector3(-1, 0, 0), + connection.Player.Rotation + ); + var spawnOffset = leftDirection * 2.0f; + var spawnPosition = new System.Numerics.Vector4( + connection.Player.Position.X + spawnOffset.X, + connection.Player.Position.Y + spawnOffset.Y, + connection.Player.Position.Z + spawnOffset.Z, + connection.Player.Position.W + ); + + boomboxNpc.UpdatePosition(spawnPosition, connection.Player.Rotation); + boomboxNpc.Visible = true; + + // Send the AddNpc packet to all nearby players so they can see the boombox + var addNpcPacket = boomboxNpc.GetAddNpcPacket(); + + // Filter players by distance - only affect players within range + const float BoomboxRangeInMeters = 15.0f; // 2 meters range + + // Always include the spawning player first + var nearbyPlayers = new List { connection.Player }; + + // Add other players within range + foreach (var player in connection.Player.VisiblePlayers.Values) + { + float distance = Vector3.Distance( + new Vector3(player.Position.X, player.Position.Y, player.Position.Z), + new Vector3(spawnPosition.X, spawnPosition.Y, spawnPosition.Z) + ); + + if (distance <= BoomboxRangeInMeters) + { + nearbyPlayers.Add(player); + } + } + + // Poof of smoke at the spawn position when the boombox materializes + var poofEffect = new PlayerUpdatePacketPlayCompositeEffect + { + Guid = boomboxNpc.Guid, + Unknown2 = 0, + CompositeEffectId = 21, // PFX_smoke_black_explosion + Unknown4 = 0, + EffectDelay = 0, + Position = spawnPosition, + Clear = false + }; + + // Send the boombox and its spawn poof to all nearby players + foreach (var player in nearbyPlayers) + { + player.SendTunneled(poofEffect); + player.SendTunneled(addNpcPacket); + } + + // Capture the NPC object so the despawn lambda can reach its tile and visible-player list. + var capturedNpc = boomboxNpc; + + // Start making nearby players dance every 3 seconds + StartBoomboxDancing(startingZone, spawnPosition, danceSequence, 60_000); + + // Schedule despawn after cooldown duration + Task.Delay(60_000).ContinueWith(_ => + { + try + { + // RemovePlayerGracefully tells the client to tear down the entity and + // its embedded composite effect (music notes / audio). Using the + // graceful form ensures the client runs the full entity-removal path, + // including stopping animation-driven MX_ audio tracks. + // CompositeEffectId=21 plays the despawn poof on the way out. + var removePacket = new PlayerUpdatePacketRemovePlayerGracefully + { + Guid = capturedNpc.Guid, + Animate = false, + Delay = 0, + EffectDelay = 0, + CompositeEffectId = 21, // PFX_smoke_black_explosion despawn poof + Duration = 500 + }; + + // Send to every player currently in the zone — those who never saw the + // boombox will ignore the unknown GUID; this avoids maintaining a + // per-boombox subscriber list. + var players = startingZone.Players; + if (players is not null) + { + foreach (var player in players) + player.SendTunneled(removePacket); + } + + // Full server-side cleanup: tile entity list, VisiblePlayers tracking, NPC registry. + capturedNpc.Dispose(); + } + catch { } + }); + } + catch { } + } + + private static void StartBoomboxDancing(Game.Zones.StartingZone zone, Vector4 boomboxPosition, int[] danceSequence, int durationMs) + { + const float BoomboxRangeInMeters = 15.0f; + + var danceInterval = 3000; // Send dance command every 3 seconds + var iterations = durationMs / danceInterval; + + System.Threading.Tasks.Task.Run(async () => + { + try + { + + int sequenceIndex = 0; // Track which animation in the sequence we're on + + for (int i = 0; i < iterations; i++) + { + await System.Threading.Tasks.Task.Delay(danceInterval); + + // Get the current animation ID from the sequence and cycle through + int currentQuickChatId = danceSequence[sequenceIndex]; + sequenceIndex = (sequenceIndex + 1) % danceSequence.Length; // Cycle back to 0 after reaching end + + // Get all players in the zone and filter by distance from boombox + var allPlayers = zone.Players?.ToList() ?? new List(); + var playersInRange = allPlayers.Where(p => + { + float distance = Vector3.Distance( + new Vector3(p.Position.X, p.Position.Y, p.Position.Z), + new Vector3(boomboxPosition.X, boomboxPosition.Y, boomboxPosition.Z) + ); + return distance <= BoomboxRangeInMeters; + }).ToList(); + + foreach (var player in playersInRange) + { + try + { + // Send QuickChat packet as if the player used the emote + var quickChatPacket = new QuickChatSendChatToChannelPacket + { + Id = currentQuickChatId, + Guid = player.Guid, + Name = player.Name ?? new Packet.Common.NameData(), + Channel = Packet.Common.Chat.ChatChannel.WorldArea, + AreaNameId = 0, + GuildGuid = 0 + }; + + // Send to the player themselves + player.SendTunneled(quickChatPacket); + + // Send to all visible players + foreach (var visiblePlayer in player.VisiblePlayers.Values) + { + visiblePlayer.SendTunneled(quickChatPacket); + } + } + catch { } + } + } + } + catch { } + }); + } + + private static void SendFailure(GatewayConnection connection, int stringId) + { + var abilityPacketFailed = new AbilityPacketFailed + { + StringId = stringId + }; + + connection.SendTunneled(abilityPacketFailed); + } + + internal static void ApplyTransform(GatewayConnection connection, int temporaryAppearance, int durationMs) + => connection.Player.ApplyTemporaryAppearance(temporaryAppearance, durationMs); + + internal static void ApplyTransform(Game.Entities.Player player, int temporaryAppearance, int durationMs) + => player.ApplyTemporaryAppearance(temporaryAppearance, durationMs); + + internal static void RemoveTransform(GatewayConnection connection) + => connection.Player.RemoveTemporaryAppearance(); } \ No newline at end of file diff --git a/src/Sanctuary.Packet.Common/ActionBarSlot.cs b/src/Sanctuary.Packet.Common/ActionBarSlot.cs index 85b2300..1b3661a 100644 --- a/src/Sanctuary.Packet.Common/ActionBarSlot.cs +++ b/src/Sanctuary.Packet.Common/ActionBarSlot.cs @@ -14,6 +14,7 @@ public class ActionBarSlot : ISerializableType public int Unknown5; public int Unknown6; public int Unknown7; + public int Clear; public int ManaCost; diff --git a/src/Sanctuary.Packet.Common/ClientPcData.cs b/src/Sanctuary.Packet.Common/ClientPcData.cs index 0e3b9a1..25cdbc9 100644 --- a/src/Sanctuary.Packet.Common/ClientPcData.cs +++ b/src/Sanctuary.Packet.Common/ClientPcData.cs @@ -15,6 +15,7 @@ public class ClientPcData public ulong Guid { get; init; } public int Model; + public int TemporaryAppearance; public string Head = null!; public string Hair = null!; @@ -340,10 +341,10 @@ public class ClientPcData // public List Acquaintances = new(); // public List Recipes = new(); - // public List Pets = new(); + public List Pets = new(); - public int ActivePetId; - public ulong ActivePetGuid; + // public int ActivePetId; + // public ulong ActivePetGuid; public List Mounts = new(); @@ -526,10 +527,11 @@ public byte[] Serialize() writer.Write(0); // TODO Recipes - writer.Write(0); // TODO Pets - - writer.Write(ActivePetId); - writer.Write(ActivePetGuid); + // CONFIRMED: Pets CANNOT be in ClientPcData - client crashes even with 1 pet + // The Pet Collection UI must work differently than Mount Collection + writer.Write(0); // Pets count (must always be 0) + writer.Write(0); // ActivePetId (required field) + writer.Write((ulong)0); // ActivePetGuid (required field) writer.Write(Mounts); diff --git a/src/Sanctuary.Packet.Common/NotificationInfo.cs b/src/Sanctuary.Packet.Common/NotificationInfo.cs new file mode 100644 index 0000000..acc0102 --- /dev/null +++ b/src/Sanctuary.Packet.Common/NotificationInfo.cs @@ -0,0 +1,39 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet.Common; + +public class NotificationInfo : ISerializableType +{ + public ulong Guid { get; set; } + + public int Unknown3 { get; set; } + public int DescriptionId { get; set; } + public int ImageId { get; set; } + public int NameId { get; set; } + public int SubTextId { get; set; } + public int Type { get; set; } + public bool Unknown8 { get; set; } + public int CompositeEffectId { get; set; } + public bool Combat { get; set; } + public bool Unknown10 { get; set; } + + public void Serialize(PacketWriter writer) + { + writer.Write(Guid); + writer.Write(Combat); + writer.Write(Type); + + if (!Combat) + { + writer.Write(Unknown3); + writer.Write(ImageId); + writer.Write(DescriptionId); + writer.Write(NameId); + writer.Write(SubTextId); + writer.Write(Unknown8); + writer.Write(CompositeEffectId); + } + + writer.Write(Unknown10); + } +} \ No newline at end of file diff --git a/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketAbilityDefinition.cs b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketAbilityDefinition.cs new file mode 100644 index 0000000..08f1347 --- /dev/null +++ b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketAbilityDefinition.cs @@ -0,0 +1,35 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class AbilityPacketAbilityDefinition : BaseAbilityPacket, ISerializablePacket +{ + public new const short OpCode = 13; + + public int AbilityId; + public int AbilityType; // field 2 - type/category + public int NameId; // field 3 + public int IconId; // field 4 + public int CooldownMs; // field 5 - recharge time + public int CastTimeMs; // field 6 + + public AbilityPacketAbilityDefinition() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(AbilityId); + writer.Write(AbilityType); + writer.Write(NameId); + writer.Write(IconId); + writer.Write(CooldownMs); + writer.Write(CastTimeMs); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketExecuteClientLua.cs b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketExecuteClientLua.cs new file mode 100644 index 0000000..5212340 --- /dev/null +++ b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketExecuteClientLua.cs @@ -0,0 +1,25 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class AbilityPacketExecuteClientLua : BaseAbilityPacket, ISerializablePacket +{ + public new const short OpCode = 17; + + public int AbilityId; + + public AbilityPacketExecuteClientLua() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(AbilityId); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketRequestAbilityDefinition.cs b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketRequestAbilityDefinition.cs new file mode 100644 index 0000000..1376671 --- /dev/null +++ b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketRequestAbilityDefinition.cs @@ -0,0 +1,31 @@ +using System; + +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class AbilityPacketRequestAbilityDefinition : BaseAbilityPacket, IDeserializable +{ + public new const short OpCode = 12; + + public int AbilityId; + + public AbilityPacketRequestAbilityDefinition() : base(OpCode) + { + } + + public static bool TryDeserialize(ReadOnlySpan data, out AbilityPacketRequestAbilityDefinition value) + { + value = new AbilityPacketRequestAbilityDefinition(); + + var reader = new PacketReader(data); + + if (!value.TryRead(ref reader)) + return false; + + if (!reader.TryRead(out value.AbilityId)) + return false; + + return true; + } +} diff --git a/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketSetDefinition.cs b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketSetDefinition.cs new file mode 100644 index 0000000..3e3460d --- /dev/null +++ b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketSetDefinition.cs @@ -0,0 +1,29 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class AbilityPacketSetDefinition : BaseAbilityPacket, ISerializablePacket +{ + public new const short OpCode = 5; + + public int AbilityId; + public int CooldownRemainingMs; + public int TotalCooldownMs; + + public AbilityPacketSetDefinition() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(AbilityId); + writer.Write(CooldownRemainingMs); + writer.Write(TotalCooldownMs); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketAddEffectTag.cs b/src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketAddEffectTag.cs new file mode 100644 index 0000000..1d0b2e3 --- /dev/null +++ b/src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketAddEffectTag.cs @@ -0,0 +1,26 @@ +using Sanctuary.Core.IO; +using Sanctuary.Packet.Common; + +namespace Sanctuary.Packet; + +public class ClientUpdatePacketAddEffectTag : BaseClientUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 16; + + public EffectTag EffectTag = new(); + + public ClientUpdatePacketAddEffectTag() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + EffectTag.Serialize(writer); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketRemoveEffectTag.cs b/src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketRemoveEffectTag.cs new file mode 100644 index 0000000..61076b0 --- /dev/null +++ b/src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketRemoveEffectTag.cs @@ -0,0 +1,25 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class ClientUpdatePacketRemoveEffectTag : BaseClientUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 17; + + public int EffectTagId; + + public ClientUpdatePacketRemoveEffectTag() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(EffectTagId); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseCombatPacket.cs b/src/Sanctuary.Packet/BaseCombatPacket.cs new file mode 100644 index 0000000..046d4f6 --- /dev/null +++ b/src/Sanctuary.Packet/BaseCombatPacket.cs @@ -0,0 +1,32 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class BaseCombatPacket +{ + public const short OpCode = 32; + + private short SubOpCode; + + public BaseCombatPacket(short subOpCode) + { + SubOpCode = subOpCode; + } + + public virtual void Write(PacketWriter writer) + { + writer.Write(OpCode); + writer.Write(SubOpCode); + } + + public bool TryRead(ref PacketReader reader) + { + if (!reader.TryRead(out short opCode) && opCode != OpCode) + return false; + + if (!reader.TryRead(out short subOpCode) && subOpCode != SubOpCode) + return false; + + return true; + } +} diff --git a/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackProcessed.cs b/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackProcessed.cs new file mode 100644 index 0000000..3305d1e --- /dev/null +++ b/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackProcessed.cs @@ -0,0 +1,24 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// +/// Sent server→client when an attack has been fully processed (OpCode 32, SubOpCode 7). +/// +public class CombatPacketAttackProcessed : BaseCombatPacket, ISerializablePacket +{ + public new const short OpCode = 7; + + public CombatPacketAttackProcessed() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackTargetDamage.cs b/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackTargetDamage.cs new file mode 100644 index 0000000..c7da29d --- /dev/null +++ b/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackTargetDamage.cs @@ -0,0 +1,37 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// +/// Sent server→client to confirm damage dealt to a target (OpCode 32, SubOpCode 4). +/// +public class CombatPacketAttackTargetDamage : BaseCombatPacket, ISerializablePacket +{ + public new const short OpCode = 4; + + /// GUID of the attacker. + public ulong AttackerGuid; + + /// GUID of the target being damaged. + public ulong TargetGuid; + + /// Damage amount dealt. + public int Damage; + + public CombatPacketAttackTargetDamage() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(AttackerGuid); + writer.Write(TargetGuid); + writer.Write(Damage); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAutoAttackTarget.cs b/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAutoAttackTarget.cs new file mode 100644 index 0000000..976be59 --- /dev/null +++ b/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAutoAttackTarget.cs @@ -0,0 +1,35 @@ +using System; + +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// +/// Sent by client when the player clicks to auto-attack a target (OpCode 32, SubOpCode 1). +/// +public class CombatPacketAutoAttackTarget : BaseCombatPacket, IDeserializable +{ + public new const short OpCode = 1; + + /// GUID of the target entity to attack. + public ulong TargetGuid; + + public CombatPacketAutoAttackTarget() : base(OpCode) + { + } + + public static bool TryDeserialize(ReadOnlySpan data, out CombatPacketAutoAttackTarget value) + { + value = new CombatPacketAutoAttackTarget(); + + var reader = new PacketReader(data); + + if (!value.TryRead(ref reader)) + return false; + + if (!reader.TryRead(out value.TargetGuid)) + return false; + + return true; + } +} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketAddNotifications.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketAddNotifications.cs new file mode 100644 index 0000000..c5a72eb --- /dev/null +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketAddNotifications.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +using Sanctuary.Core.IO; +using Sanctuary.Packet.Common; + +namespace Sanctuary.Packet; + +public class PlayerUpdatePacketAddNotifications : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 10; + + public List Notifications = new(); + + public PlayerUpdatePacketAddNotifications() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(Notifications); + + return writer.Buffer; + } +} \ No newline at end of file diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketDestroyed.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketDestroyed.cs new file mode 100644 index 0000000..196f91b --- /dev/null +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketDestroyed.cs @@ -0,0 +1,40 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// +/// Signals that an entity has been destroyed/killed (OpCode 35, SubOpCode 56). +/// +public class PlayerUpdatePacketDestroyed : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 56; + + public ulong Guid; + + /// + /// The entity that dealt the killing blow. + /// + public ulong KillerGuid; + + /// + /// Unknown purpose — set to 0. + /// + public int Unknown; + + public PlayerUpdatePacketDestroyed() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(Guid); + writer.Write(KillerGuid); + writer.Write(Unknown); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketHitPointModification.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketHitPointModification.cs new file mode 100644 index 0000000..25c8d27 --- /dev/null +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketHitPointModification.cs @@ -0,0 +1,41 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// +/// Broadcasts a hitpoint modification event (damage/heal) to visible players (OpCode 35, SubOpCode 35). +/// Shows a floating combat number on the target. +/// +public class PlayerUpdatePacketHitPointModification : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 35; + + public ulong TargetGuid; + + /// + /// The HP change amount. Negative = damage, Positive = heal. + /// + public int Amount; + + /// + /// The source entity GUID that caused this modification. + /// + public ulong SourceGuid; + + public PlayerUpdatePacketHitPointModification() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(TargetGuid); + writer.Write(Amount); + writer.Write(SourceGuid); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketQueueAnimation.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketQueueAnimation.cs new file mode 100644 index 0000000..0ae39cc --- /dev/null +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketQueueAnimation.cs @@ -0,0 +1,29 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class PlayerUpdatePacketQueueAnimation : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 22; + + public ulong Guid; + public int AnimationId; + public bool Unknown1; + + public PlayerUpdatePacketQueueAnimation() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + base.Write(writer); + + writer.Write(Guid); + writer.Write(AnimationId); + writer.Write(Unknown1); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketRemoveTemporaryAppearance.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketRemoveTemporaryAppearance.cs new file mode 100644 index 0000000..9d9a90d --- /dev/null +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketRemoveTemporaryAppearance.cs @@ -0,0 +1,29 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class PlayerUpdatePacketRemoveTemporaryAppearance : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 15; + + public ulong Guid; + + private int Unused = default; + + public PlayerUpdatePacketRemoveTemporaryAppearance() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(Guid); + + writer.Write(Unused); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketReplaceBaseModel.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketReplaceBaseModel.cs new file mode 100644 index 0000000..0ab229f --- /dev/null +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketReplaceBaseModel.cs @@ -0,0 +1,31 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// +/// Replaces a player's base model without removing/re-adding the entity (OpCode 35, SubOpCode 49). +/// Used for transformations and appearance changes that preserve camera position. +/// +public class PlayerUpdatePacketReplaceBaseModel : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 49; + + public ulong Guid; + public int ModelId; + + public PlayerUpdatePacketReplaceBaseModel() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(Guid); + writer.Write(ModelId); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketRequestStripEffect.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketRequestStripEffect.cs new file mode 100644 index 0000000..3fd0419 --- /dev/null +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketRequestStripEffect.cs @@ -0,0 +1,27 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class PlayerUpdatePacketRequestStripEffect : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 33; + + public ulong Guid; + public int CompositeEffectId; + + public PlayerUpdatePacketRequestStripEffect() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + base.Write(writer); + + writer.Write(Guid); + writer.Write(CompositeEffectId); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateHitpoints.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateHitpoints.cs new file mode 100644 index 0000000..50e3d0f --- /dev/null +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateHitpoints.cs @@ -0,0 +1,32 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// +/// Broadcasts an entity's current/max HP to visible players (OpCode 35, SubOpCode 5). +/// +public class PlayerUpdatePacketUpdateHitpoints : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 5; + + public ulong Guid; + public int CurrentHitpoints; + public int MaxHitpoints; + + public PlayerUpdatePacketUpdateHitpoints() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(Guid); + writer.Write(CurrentHitpoints); + writer.Write(MaxHitpoints); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateScale.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateScale.cs new file mode 100644 index 0000000..d31cbee --- /dev/null +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateScale.cs @@ -0,0 +1,30 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// +/// Updates an entity's scale (OpCode 35, SubOpCode 13). +/// +public class PlayerUpdatePacketUpdateScale : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 13; + + public ulong Guid; + public float Scale; + + public PlayerUpdatePacketUpdateScale() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(Guid); + writer.Write(Scale); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateTemporaryAppearance.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateTemporaryAppearance.cs new file mode 100644 index 0000000..cc2dc01 --- /dev/null +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateTemporaryAppearance.cs @@ -0,0 +1,30 @@ +using Sanctuary.Core.IO; + +using System; +namespace Sanctuary.Packet; + +public class PlayerUpdatePacketUpdateTemporaryAppearance : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 14; + + public ulong Guid; + + public int TemporaryAppearance; + + public PlayerUpdatePacketUpdateTemporaryAppearance() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(TemporaryAppearance); + + writer.Write(Guid); + + return writer.Buffer; + } +} From 1c2a4665e65612bba1111835965b43703294ca91 Mon Sep 17 00:00:00 2001 From: JadenY Date: Sat, 27 Jun 2026 23:47:20 -0500 Subject: [PATCH 05/22] Remove combat packets, strip non-consumables from ClientPcData --- src/Sanctuary.Game/Entities/Player.cs | 2 + src/Sanctuary.Packet.Common/ClientPcData.cs | 16 ++++---- src/Sanctuary.Packet/BaseCombatPacket.cs | 32 ---------------- .../CombatPacketAttackProcessed.cs | 24 ------------ .../CombatPacketAttackTargetDamage.cs | 37 ------------------- .../CombatPacketAutoAttackTarget.cs | 35 ------------------ 6 files changed, 9 insertions(+), 137 deletions(-) delete mode 100644 src/Sanctuary.Packet/BaseCombatPacket.cs delete mode 100644 src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackProcessed.cs delete mode 100644 src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackTargetDamage.cs delete mode 100644 src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAutoAttackTarget.cs diff --git a/src/Sanctuary.Game/Entities/Player.cs b/src/Sanctuary.Game/Entities/Player.cs index 68c444c..6f064de 100644 --- a/src/Sanctuary.Game/Entities/Player.cs +++ b/src/Sanctuary.Game/Entities/Player.cs @@ -55,6 +55,8 @@ public sealed class Player : ClientPcData, IEntity // Tracks which item GUIDs are on which action bar slot. public Dictionary> ActionBarItemGuids { get; set; } = new(); + public int TemporaryAppearance { get; set; } + public Vector4 StartingZonePosition { get; set; } diff --git a/src/Sanctuary.Packet.Common/ClientPcData.cs b/src/Sanctuary.Packet.Common/ClientPcData.cs index 25cdbc9..0e3b9a1 100644 --- a/src/Sanctuary.Packet.Common/ClientPcData.cs +++ b/src/Sanctuary.Packet.Common/ClientPcData.cs @@ -15,7 +15,6 @@ public class ClientPcData public ulong Guid { get; init; } public int Model; - public int TemporaryAppearance; public string Head = null!; public string Hair = null!; @@ -341,10 +340,10 @@ public class ClientPcData // public List Acquaintances = new(); // public List Recipes = new(); - public List Pets = new(); + // public List Pets = new(); - // public int ActivePetId; - // public ulong ActivePetGuid; + public int ActivePetId; + public ulong ActivePetGuid; public List Mounts = new(); @@ -527,11 +526,10 @@ public byte[] Serialize() writer.Write(0); // TODO Recipes - // CONFIRMED: Pets CANNOT be in ClientPcData - client crashes even with 1 pet - // The Pet Collection UI must work differently than Mount Collection - writer.Write(0); // Pets count (must always be 0) - writer.Write(0); // ActivePetId (required field) - writer.Write((ulong)0); // ActivePetGuid (required field) + writer.Write(0); // TODO Pets + + writer.Write(ActivePetId); + writer.Write(ActivePetGuid); writer.Write(Mounts); diff --git a/src/Sanctuary.Packet/BaseCombatPacket.cs b/src/Sanctuary.Packet/BaseCombatPacket.cs deleted file mode 100644 index 046d4f6..0000000 --- a/src/Sanctuary.Packet/BaseCombatPacket.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Sanctuary.Core.IO; - -namespace Sanctuary.Packet; - -public class BaseCombatPacket -{ - public const short OpCode = 32; - - private short SubOpCode; - - public BaseCombatPacket(short subOpCode) - { - SubOpCode = subOpCode; - } - - public virtual void Write(PacketWriter writer) - { - writer.Write(OpCode); - writer.Write(SubOpCode); - } - - public bool TryRead(ref PacketReader reader) - { - if (!reader.TryRead(out short opCode) && opCode != OpCode) - return false; - - if (!reader.TryRead(out short subOpCode) && subOpCode != SubOpCode) - return false; - - return true; - } -} diff --git a/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackProcessed.cs b/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackProcessed.cs deleted file mode 100644 index 3305d1e..0000000 --- a/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackProcessed.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Sanctuary.Core.IO; - -namespace Sanctuary.Packet; - -/// -/// Sent server→client when an attack has been fully processed (OpCode 32, SubOpCode 7). -/// -public class CombatPacketAttackProcessed : BaseCombatPacket, ISerializablePacket -{ - public new const short OpCode = 7; - - public CombatPacketAttackProcessed() : base(OpCode) - { - } - - public byte[] Serialize() - { - using var writer = new PacketWriter(); - - Write(writer); - - return writer.Buffer; - } -} diff --git a/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackTargetDamage.cs b/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackTargetDamage.cs deleted file mode 100644 index c7da29d..0000000 --- a/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAttackTargetDamage.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Sanctuary.Core.IO; - -namespace Sanctuary.Packet; - -/// -/// Sent server→client to confirm damage dealt to a target (OpCode 32, SubOpCode 4). -/// -public class CombatPacketAttackTargetDamage : BaseCombatPacket, ISerializablePacket -{ - public new const short OpCode = 4; - - /// GUID of the attacker. - public ulong AttackerGuid; - - /// GUID of the target being damaged. - public ulong TargetGuid; - - /// Damage amount dealt. - public int Damage; - - public CombatPacketAttackTargetDamage() : base(OpCode) - { - } - - public byte[] Serialize() - { - using var writer = new PacketWriter(); - - Write(writer); - - writer.Write(AttackerGuid); - writer.Write(TargetGuid); - writer.Write(Damage); - - return writer.Buffer; - } -} diff --git a/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAutoAttackTarget.cs b/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAutoAttackTarget.cs deleted file mode 100644 index 976be59..0000000 --- a/src/Sanctuary.Packet/BaseCombatPacket/CombatPacketAutoAttackTarget.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; - -using Sanctuary.Core.IO; - -namespace Sanctuary.Packet; - -/// -/// Sent by client when the player clicks to auto-attack a target (OpCode 32, SubOpCode 1). -/// -public class CombatPacketAutoAttackTarget : BaseCombatPacket, IDeserializable -{ - public new const short OpCode = 1; - - /// GUID of the target entity to attack. - public ulong TargetGuid; - - public CombatPacketAutoAttackTarget() : base(OpCode) - { - } - - public static bool TryDeserialize(ReadOnlySpan data, out CombatPacketAutoAttackTarget value) - { - value = new CombatPacketAutoAttackTarget(); - - var reader = new PacketReader(data); - - if (!value.TryRead(ref reader)) - return false; - - if (!reader.TryRead(out value.TargetGuid)) - return false; - - return true; - } -} From b9a4dc011a2d39a342bc903893115926c6608744 Mon Sep 17 00:00:00 2001 From: JadenY Date: Sat, 27 Jun 2026 23:52:26 -0500 Subject: [PATCH 06/22] Strip comments from consumables code and data --- src/Resources/Consumables.json | 38 ++++++------- src/Sanctuary.Game/Entities/Npc.cs | 11 ---- src/Sanctuary.Game/Entities/Player.cs | 8 +-- .../Resources/ConsumableCollection.cs | 7 --- .../Definitions/ConsumableDefinitions.cs | 13 ----- src/Sanctuary.Game/Zones/BaseZone.cs | 2 - ...yPacketClientRequestStartAbilityHandler.cs | 55 +------------------ src/Sanctuary.Packet.Common/ActionBarSlot.cs | 4 +- .../NotificationInfo.cs | 2 +- .../PlayerUpdatePacketReplaceBaseModel.cs | 4 -- 10 files changed, 24 insertions(+), 120 deletions(-) diff --git a/src/Resources/Consumables.json b/src/Resources/Consumables.json index 0baa853..d89d634 100644 --- a/src/Resources/Consumables.json +++ b/src/Resources/Consumables.json @@ -19,21 +19,21 @@ ], "FoodEffects": [ - { "AbilityId": 1956, "CompositeEffectId": 47, "Comment": "Electric Veal Meat → PFX_electricity_blue_skel_loop" }, - { "AbilityId": 1957, "CompositeEffectId": 5032, "Comment": "Rocking Road Mix → PFX_barrier-rock_gray_loop" }, - { "AbilityId": 1958, "CompositeEffectId": 1, "Comment": "Fiery Fury Candy → PFX_fire_skel_loop" }, - { "AbilityId": 1960, "CompositeEffectId": 5, "Comment": "Mr. Gleam's Granola Bar → PFX_magic-glow_white_neck_trail_loop" }, - { "AbilityId": 1961, "CompositeEffectId": 5102, "Comment": "Silver Surf 'n Turf → PFX_water_blue_cog_elemental-barrier_loop" }, - { "AbilityId": 1962, "CompositeEffectId": 174, "Comment": "Deep Mines Delight → PFX_spores-smoke_brown_chest_cloud" }, - { "AbilityId": 1963, "CompositeEffectId": 1115, "Comment": "Roaring Rapids Roast → PFX_waves_blue_body_land" }, - { "AbilityId": 1965, "CompositeEffectId": 5298, "Comment": "Noxious Pork Chops → PFX_smoke-bubbles_green_head_anesthesia_loop" }, - { "AbilityId": 1966, "CompositeEffectId": 12, "Comment": "Firefrog Legs → PFX_fire_orange_feet_sprint_loop" }, - { "AbilityId": 1967, "CompositeEffectId": 5370, "Comment": "Hot Sauce Buns → PFX_steam_red_ears_taunt_loop" }, - { "AbilityId": 1968, "CompositeEffectId": 5732, "Comment": "Arbor Buttercake → WFX_leaves_multi_falling_skel_loop" }, - { "AbilityId": 1969, "CompositeEffectId": 1107, "Comment": "Pixiedust Pie → PFX_pixie_dust_med_interact_loop" }, - { "AbilityId": 1970, "CompositeEffectId": 630, "Comment": "Frosty Flakes → PFX_clouds-snowflakes_white_mouth_breath_loop" }, - { "AbilityId": 1973, "CompositeEffectId": 15173, "Comment": "Scrumptious Soup → PFX_steam_white_rising_skel" }, - { "AbilityId": 2004, "CompositeEffectId": 4013, "Comment": "Steaming Pretzels → PFX_steam_white_rising" } + { "AbilityId": 1956, "CompositeEffectId": 47 }, + { "AbilityId": 1957, "CompositeEffectId": 5032 }, + { "AbilityId": 1958, "CompositeEffectId": 1 }, + { "AbilityId": 1960, "CompositeEffectId": 5 }, + { "AbilityId": 1961, "CompositeEffectId": 5102 }, + { "AbilityId": 1962, "CompositeEffectId": 174 }, + { "AbilityId": 1963, "CompositeEffectId": 1115 }, + { "AbilityId": 1965, "CompositeEffectId": 5298 }, + { "AbilityId": 1966, "CompositeEffectId": 12 }, + { "AbilityId": 1967, "CompositeEffectId": 5370 }, + { "AbilityId": 1968, "CompositeEffectId": 5732 }, + { "AbilityId": 1969, "CompositeEffectId": 1107 }, + { "AbilityId": 1970, "CompositeEffectId": 630 }, + { "AbilityId": 1973, "CompositeEffectId": 15173 }, + { "AbilityId": 2004, "CompositeEffectId": 4013 } ], "Transformations": [ @@ -103,9 +103,9 @@ { "AbilityId": 3038, "ModelId": 130, "DurationMs": 10000, "CooldownMs": 10000 }, { "AbilityId": 286, "ModelId": 22, "DurationMs": 10000, "CooldownMs": 10000 }, { "AbilityId": 3070, "ModelId": 177, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 4370, "ModelId": 2055, "DurationMs": 30000, "CooldownMs": 10000, "Comment": "Boss Cake → Stone Heart" }, - { "AbilityId": 4371, "ModelId": 1944, "DurationMs": 30000, "CooldownMs": 10000, "Comment": "Boss Cake → Abominable Snowman" }, - { "AbilityId": 4372, "ModelId": 1702, "DurationMs": 30000, "CooldownMs": 10000, "Comment": "Boss Cake → Pumpkin Prince" }, - { "AbilityId": 4373, "ModelId": 22, "DurationMs": 30000, "CooldownMs": 10000, "Comment": "Boss Cake → Cakenstein" } + { "AbilityId": 4370, "ModelId": 2055, "DurationMs": 30000, "CooldownMs": 10000 }, + { "AbilityId": 4371, "ModelId": 1944, "DurationMs": 30000, "CooldownMs": 10000 }, + { "AbilityId": 4372, "ModelId": 1702, "DurationMs": 30000, "CooldownMs": 10000 }, + { "AbilityId": 4373, "ModelId": 22, "DurationMs": 30000, "CooldownMs": 10000 } ] } diff --git a/src/Sanctuary.Game/Entities/Npc.cs b/src/Sanctuary.Game/Entities/Npc.cs index 24c75db..abfdf2d 100644 --- a/src/Sanctuary.Game/Entities/Npc.cs +++ b/src/Sanctuary.Game/Entities/Npc.cs @@ -39,11 +39,6 @@ public class Npc : IEntity public float Scale { get; set; } - /// - /// 0 - Hostile - /// 1 - Neutral - /// 2 - Ally - /// public int Disposition { get; set; } = 1; public System.Action? InteractAction { get; set; } @@ -62,7 +57,6 @@ public class Npc : IEntity public byte CursorId { get; set; } - // public NotificationInfo? Notification { get; set; } public List Attachments { get; set; } = []; @@ -208,7 +202,6 @@ public virtual PlayerUpdatePacketAddNpc GetAddNpcPacket() Unknown36 = default, // AnimationEvent TemporaryAppearance = default, - // playerUpdatePacketAddNpc.EffectTags = TODO Unknown38 = default, Unknown39 = default, @@ -218,7 +211,6 @@ public virtual PlayerUpdatePacketAddNpc GetAddNpcPacket() HasTilt = default, - // playerUpdatePacketAddNpc.Customization = TODO Tilt = default, @@ -248,9 +240,6 @@ public virtual PlayerUpdatePacketAddNpc GetAddNpcPacket() Unknown57 = default, Unknown58 = default, - // playerUpdatePacketAddNpc.Head = TODO - // playerUpdatePacketAddNpc.Hair = TODO - // playerUpdatePacketAddNpc.ModelCustomization = TODO ReplaceTerrainObject = default, diff --git a/src/Sanctuary.Game/Entities/Player.cs b/src/Sanctuary.Game/Entities/Player.cs index 6f064de..ce685fa 100644 --- a/src/Sanctuary.Game/Entities/Player.cs +++ b/src/Sanctuary.Game/Entities/Player.cs @@ -52,7 +52,6 @@ public sealed class Player : ClientPcData, IEntity public int TimezoneOffset { get; set; } - // Tracks which item GUIDs are on which action bar slot. public Dictionary> ActionBarItemGuids { get; set; } = new(); public int TemporaryAppearance { get; set; } @@ -173,7 +172,6 @@ public void TeleportToZone(IZone zone, Vector4 position, Quaternion rotation) if (Mount is not null) Mount.TeleportToZone(zone, position, rotation); - // Alert/Remove visible entities foreach (var visiblePlayer in VisiblePlayers) visiblePlayer.Value.OnRemoveVisiblePlayers([this]); @@ -184,11 +182,9 @@ public void TeleportToZone(IZone zone, Vector4 position, Quaternion rotation) Zone.TryRemovePlayer(Guid); - // Add to new zone/zonetile zone.TryAddPlayer(this); - // Teleport to new zone Visible = false; @@ -449,7 +445,6 @@ public List GetAttachments() var compositeEffectId = clientItemDefinition.CompositeEffectId; - // Update the Weapon composite effect if we have a Flair Shard equipped. if (slot == 7) { var flairShardcompositeEffectId = GetFlairShardCompositeEffect(); @@ -504,7 +499,6 @@ public PlayerUpdatePacketAddPc GetAddPcPacket() IsUnderage = Age < 18, IsMember = MembershipStatus != 0, - // playerUpdatePacketAddPc.TemporaryAppearance = 277; ActiveProfileId = ActiveProfileId, @@ -634,4 +628,4 @@ public void Dispose() Zone.TryRemovePlayer(Guid); } -} \ No newline at end of file +} diff --git a/src/Sanctuary.Game/Resources/ConsumableCollection.cs b/src/Sanctuary.Game/Resources/ConsumableCollection.cs index 737f681..8362926 100644 --- a/src/Sanctuary.Game/Resources/ConsumableCollection.cs +++ b/src/Sanctuary.Game/Resources/ConsumableCollection.cs @@ -8,10 +8,6 @@ namespace Sanctuary.Game.Resources; -/// -/// Loads and manages consumable items from the consolidated Consumables.json file. -/// Provides access to Boomboxes, FoodEffects, and Transformations. -/// public class ConsumableCollection { private readonly ILogger _logger; @@ -53,7 +49,6 @@ public bool Load(string filePath) return false; } - // Load Boomboxes foreach (var entry in consumables.Boomboxes) { if (!Boomboxes.TryAdd(entry.ItemId, entry)) @@ -64,7 +59,6 @@ public bool Load(string filePath) } _logger.LogInformation("Loaded {count} Boombox definitions.", Boomboxes.Count); - // Load FoodEffects foreach (var entry in consumables.FoodEffects) { if (!FoodEffects.TryAdd(entry.AbilityId, entry)) @@ -75,7 +69,6 @@ public bool Load(string filePath) } _logger.LogInformation("Loaded {count} FoodEffect definitions.", FoodEffects.Count); - // Load Transformations foreach (var entry in consumables.Transformations) { if (!Transformations.TryAdd(entry.AbilityId, entry)) diff --git a/src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs b/src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs index 4f3d23d..38dfb48 100644 --- a/src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs +++ b/src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs @@ -2,24 +2,11 @@ namespace Sanctuary.Game.Resources.Definitions; -/// -/// Container for all consumable item definitions loaded from Consumables.json. -/// Includes Boomboxes, FoodEffects, and Transformations. -/// public class ConsumableDefinitions { - /// - /// Boombox items that play music and dances. - /// public List Boomboxes { get; set; } = new(); - /// - /// Food items that apply visual effects to the player. - /// public List FoodEffects { get; set; } = new(); - /// - /// Transformation abilities that change the player's appearance. - /// public List Transformations { get; set; } = new(); } diff --git a/src/Sanctuary.Game/Zones/BaseZone.cs b/src/Sanctuary.Game/Zones/BaseZone.cs index 4de9ada..4b2fd45 100644 --- a/src/Sanctuary.Game/Zones/BaseZone.cs +++ b/src/Sanctuary.Game/Zones/BaseZone.cs @@ -163,7 +163,6 @@ private Dictionary GenerateTiles() { var tiles = new Dictionary(); - // Generate all tiles for (var longitude = _zoneDefinition.StartLongitude; longitude < _zoneDefinition.EndLongitude; longitude++) { for (var latitude = _zoneDefinition.StartLatitude; latitude < _zoneDefinition.EndLatitude; latitude++) @@ -174,7 +173,6 @@ private Dictionary GenerateTiles() } } - // Calcualte visible tiles for (var rootLongitude = _zoneDefinition.StartLongitude; rootLongitude < _zoneDefinition.EndLongitude; rootLongitude++) { for (var rootLatitude = _zoneDefinition.StartLatitude; rootLatitude < _zoneDefinition.EndLatitude; rootLatitude++) diff --git a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs index f999230..2f0ae54 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs @@ -25,7 +25,6 @@ public static class AbilityPacketClientRequestStartAbilityHandler private static IResourceManager _resourceManager = null!; private static IDbContextFactory _dbContextFactory = null!; - // playerGuid ? (itemDefinitionId ? cooldown expiry) private static readonly ConcurrentDictionary> _boomboxCooldowns = new(); public static void ConfigureServices(IServiceProvider serviceProvider) @@ -47,7 +46,6 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan _logger.LogInformation("AbilityPacket: Id={Id} Slot={Slot}", packet.Data.Id, packet.Data.Slot); - // Check if this is an item ability (ActionBarId = 2 is ItemActionBar) if (packet.Data.Id == 2) { return HandleItemAbility(connection, packet); @@ -55,7 +53,6 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan var abilityPacketFailed = new AbilityPacketFailed { - // You can't use that ability right now. StringId = 3079 }; @@ -66,7 +63,6 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan private static bool HandleItemAbility(GatewayConnection connection, AbilityPacketClientRequestStartAbility packet) { - // Get the action bar slot to find the item connection.Player.ActionBars.TryGetValue(2, out var actionBar); Packet.Common.ActionBarSlot? slot = null; @@ -80,7 +76,6 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke return true; } - // Get the item GUID from the tracked action bar items if (!connection.Player.ActionBarItemGuids.TryGetValue(2, out var actionBarItems) || !actionBarItems.TryGetValue(packet.Data.Slot, out var itemGuid)) { @@ -88,7 +83,6 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke return true; } - // Find the item in the player's inventory by GUID var clientItem = connection.Player.Items.FirstOrDefault(x => x.Id == itemGuid); if (clientItem is null) @@ -103,7 +97,6 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke return true; } - // Check if item has an activatable ability if (clientItemDefinition.ActivatableAbilityId == 0) { SendFailure(connection, 3079); @@ -141,9 +134,6 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke var capturedCount = clientItem.Count; var capturedPlayerGuid = connection.Player.Guid; - // Cooldown sweep: Enabled=false hides the icon and lets C++ draw the sweep overlay. - // Unknown10 (RefreshTimeLeft) = elapsed ms from 0; TotalRefreshTime = total duration. - // Periodic ticks update elapsed so the sweep advances visually. var cooldownSlot = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = capturedSlot } }; cooldownSlot.Slot.IsEmpty = false; cooldownSlot.Slot.IconId = capturedItemDef.Icon.Id; @@ -160,7 +150,6 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke cooldownSlot.Slot.Unknown15 = 0; connection.SendTunneled(cooldownSlot); - // Send elapsed-time updates every 2s so the client sweep advances. var startTime = DateTimeOffset.UtcNow; _ = Task.Run(async () => { @@ -249,8 +238,6 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke var capturedPlayerGuid = connection.Player.Guid; var cooldownMs = transform.CooldownMs; - // Show cooldown sweep only when the player has more of this item remaining in the stack. - // If this was the last one, ConsumeItem will clear the slot and no sweep is needed. bool willHaveItemLeft = capturedCount > 1; if (clientItemDefinition.SingleUse) @@ -333,7 +320,6 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke } else { - // Item was the last in the stack — slot is now empty, just clear the cooldown entry when done. Task.Delay(cooldownMs).ContinueWith(_ => { if (_boomboxCooldowns.TryGetValue(capturedPlayerGuid, out var cd)) @@ -344,7 +330,6 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke return true; } - // Trigger the ability effect FIRST (before consuming) TriggerAbilityEffect(connection, clientItemDefinition); if (clientItemDefinition.SingleUse) @@ -367,7 +352,6 @@ private static bool ConsumeItem(GatewayConnection connection, Packet.Common.Clie return true; } - // Decrement item count dbItem.Count--; var shouldDeleteItem = dbItem.Count <= 0; @@ -383,7 +367,6 @@ private static bool ConsumeItem(GatewayConnection connection, Packet.Common.Clie return true; } - // Update client-side item if (shouldDeleteItem) { connection.Player.Items.Remove(clientItem); @@ -395,7 +378,6 @@ private static bool ConsumeItem(GatewayConnection connection, Packet.Common.Clie connection.SendTunneled(clientUpdatePacketItemDelete); - // Clear the action bar slot var clientUpdatePacketUpdateActionBarSlot = new ClientUpdatePacketUpdateActionBarSlot { Data = @@ -407,7 +389,6 @@ private static bool ConsumeItem(GatewayConnection connection, Packet.Common.Clie clientUpdatePacketUpdateActionBarSlot.Slot.IsEmpty = true; - // Remove from tracked items if (connection.Player.ActionBarItemGuids.TryGetValue(2, out var trackedItems)) { trackedItems.Remove(actionBarSlot); @@ -430,7 +411,6 @@ private static bool ConsumeItem(GatewayConnection connection, Packet.Common.Clie connection.SendTunneled(clientUpdatePacketItemUpdate); - // Update action bar slot quantity var clientUpdatePacketUpdateActionBarSlot = new ClientUpdatePacketUpdateActionBarSlot { Data = @@ -494,7 +474,6 @@ private static void TriggerAbilityEffect(GatewayConnection connection, Packet.Co } else if (abilityId == 1964) // Graveyard Flambe - shadow flames { - // Visual effect only for this one var flameEffect = new PlayerUpdatePacketPlayCompositeEffect { Guid = connection.Player.Guid, @@ -508,7 +487,6 @@ private static void TriggerAbilityEffect(GatewayConnection connection, Packet.Co } else { - // For other consumables, just send the ability packet var abilityPacketExecuteClientLua = new AbilityPacketExecuteClientLua { AbilityId = abilityId @@ -516,7 +494,6 @@ private static void TriggerAbilityEffect(GatewayConnection connection, Packet.Co connection.Player.SendTunneledToVisible(abilityPacketExecuteClientLua, true); - // Play a themed composite effect if one is mapped for this ability int effectId = 0; if (_resourceManager.Consumables.FoodEffects.TryGetValue(abilityId, out var foodEffect)) effectId = foodEffect.CompositeEffectId; @@ -559,7 +536,6 @@ private static void SpawnCakeNpc(GatewayConnection connection) cakeNpc.IsInteractable = true; cakeNpc.CursorId = 5; // triggers NpcRelevance packet ? client shows "Press X" - // Spawn 1.5 m in front of the player var forwardDirection = Vector3.Transform(new Vector3(0, 0, 1), connection.Player.Rotation); var spawnPosition = new Vector4( connection.Player.Position.X + forwardDirection.X * 1.5f, @@ -571,8 +547,6 @@ private static void SpawnCakeNpc(GatewayConnection connection) cakeNpc.Visible = true; cakeNpc.UpdatePosition(spawnPosition, connection.Player.Rotation); - // When a player presses X near the cake, play the jumpscare effect sequence. - // One trigger at a time — ignore while a scare is already playing. var scareActive = false; cakeNpc.InteractAction = (interactingPlayer) => { @@ -626,8 +600,6 @@ void SendEffect(int effectId) => Broadcast(new PlayerUpdatePacketPlayCompositeEf Clear = false }; - // Use OnAddVisibleNpcs so each player also gets the NpcRelevance packet - // (CursorId=5 ? client shows the "Press X to interact" prompt) connection.Player.SendTunneled(poofEffect); connection.Player.OnAddVisibleNpcs([cakeNpc]); foreach (var player in connection.Player.VisiblePlayers.Values) @@ -700,7 +672,6 @@ private static void SpawnBossCakeNpc(GatewayConnection connection) cakeNpc.Visible = true; cakeNpc.UpdatePosition(spawnPosition, connection.Player.Rotation); - // Randomly pick one of the 4 boss transformations each time a player interacts int[] bossAbilities = [4370, 4371, 4372, 4373]; cakeNpc.InteractAction = (interactingPlayer) => @@ -767,7 +738,6 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. if (zone is not Game.Zones.StartingZone startingZone) return; - // Try to create an NPC for the boombox if (!startingZone.TryCreateNpc(out var boomboxNpc)) return; @@ -787,7 +757,6 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. boomboxNpc.HideNamePlate = true; boomboxNpc.IsInteractable = false; - // Position boombox to the left of player var leftDirection = System.Numerics.Vector3.Transform( new System.Numerics.Vector3(-1, 0, 0), connection.Player.Rotation @@ -803,16 +772,12 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. boomboxNpc.UpdatePosition(spawnPosition, connection.Player.Rotation); boomboxNpc.Visible = true; - // Send the AddNpc packet to all nearby players so they can see the boombox var addNpcPacket = boomboxNpc.GetAddNpcPacket(); - // Filter players by distance - only affect players within range const float BoomboxRangeInMeters = 15.0f; // 2 meters range - // Always include the spawning player first var nearbyPlayers = new List { connection.Player }; - // Add other players within range foreach (var player in connection.Player.VisiblePlayers.Values) { float distance = Vector3.Distance( @@ -826,7 +791,6 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. } } - // Poof of smoke at the spawn position when the boombox materializes var poofEffect = new PlayerUpdatePacketPlayCompositeEffect { Guid = boomboxNpc.Guid, @@ -838,28 +802,20 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. Clear = false }; - // Send the boombox and its spawn poof to all nearby players foreach (var player in nearbyPlayers) { player.SendTunneled(poofEffect); player.SendTunneled(addNpcPacket); } - // Capture the NPC object so the despawn lambda can reach its tile and visible-player list. var capturedNpc = boomboxNpc; - // Start making nearby players dance every 3 seconds StartBoomboxDancing(startingZone, spawnPosition, danceSequence, 60_000); - // Schedule despawn after cooldown duration Task.Delay(60_000).ContinueWith(_ => { try { - // RemovePlayerGracefully tells the client to tear down the entity and - // its embedded composite effect (music notes / audio). Using the - // graceful form ensures the client runs the full entity-removal path, - // including stopping animation-driven MX_ audio tracks. // CompositeEffectId=21 plays the despawn poof on the way out. var removePacket = new PlayerUpdatePacketRemovePlayerGracefully { @@ -871,9 +827,6 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. Duration = 500 }; - // Send to every player currently in the zone — those who never saw the - // boombox will ignore the unknown GUID; this avoids maintaining a - // per-boombox subscriber list. var players = startingZone.Players; if (players is not null) { @@ -881,7 +834,6 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. player.SendTunneled(removePacket); } - // Full server-side cleanup: tile entity list, VisiblePlayers tracking, NPC registry. capturedNpc.Dispose(); } catch { } @@ -908,11 +860,9 @@ private static void StartBoomboxDancing(Game.Zones.StartingZone zone, Vector4 bo { await System.Threading.Tasks.Task.Delay(danceInterval); - // Get the current animation ID from the sequence and cycle through int currentQuickChatId = danceSequence[sequenceIndex]; sequenceIndex = (sequenceIndex + 1) % danceSequence.Length; // Cycle back to 0 after reaching end - // Get all players in the zone and filter by distance from boombox var allPlayers = zone.Players?.ToList() ?? new List(); var playersInRange = allPlayers.Where(p => { @@ -927,7 +877,6 @@ private static void StartBoomboxDancing(Game.Zones.StartingZone zone, Vector4 bo { try { - // Send QuickChat packet as if the player used the emote var quickChatPacket = new QuickChatSendChatToChannelPacket { Id = currentQuickChatId, @@ -938,10 +887,8 @@ private static void StartBoomboxDancing(Game.Zones.StartingZone zone, Vector4 bo GuildGuid = 0 }; - // Send to the player themselves player.SendTunneled(quickChatPacket); - // Send to all visible players foreach (var visiblePlayer in player.VisiblePlayers.Values) { visiblePlayer.SendTunneled(quickChatPacket); @@ -973,4 +920,4 @@ internal static void ApplyTransform(Game.Entities.Player player, int temporaryAp internal static void RemoveTransform(GatewayConnection connection) => connection.Player.RemoveTemporaryAppearance(); -} \ No newline at end of file +} diff --git a/src/Sanctuary.Packet.Common/ActionBarSlot.cs b/src/Sanctuary.Packet.Common/ActionBarSlot.cs index 1b3661a..da9c88c 100644 --- a/src/Sanctuary.Packet.Common/ActionBarSlot.cs +++ b/src/Sanctuary.Packet.Common/ActionBarSlot.cs @@ -1,4 +1,4 @@ -using Sanctuary.Core.IO; +using Sanctuary.Core.IO; namespace Sanctuary.Packet.Common; @@ -61,4 +61,4 @@ public void Serialize(PacketWriter writer) writer.Write(Unknown15); } -} \ No newline at end of file +} diff --git a/src/Sanctuary.Packet.Common/NotificationInfo.cs b/src/Sanctuary.Packet.Common/NotificationInfo.cs index acc0102..5023a1e 100644 --- a/src/Sanctuary.Packet.Common/NotificationInfo.cs +++ b/src/Sanctuary.Packet.Common/NotificationInfo.cs @@ -36,4 +36,4 @@ public void Serialize(PacketWriter writer) writer.Write(Unknown10); } -} \ No newline at end of file +} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketReplaceBaseModel.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketReplaceBaseModel.cs index 0ab229f..8641be3 100644 --- a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketReplaceBaseModel.cs +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketReplaceBaseModel.cs @@ -2,10 +2,6 @@ namespace Sanctuary.Packet; -/// -/// Replaces a player's base model without removing/re-adding the entity (OpCode 35, SubOpCode 49). -/// Used for transformations and appearance changes that preserve camera position. -/// public class PlayerUpdatePacketReplaceBaseModel : BasePlayerUpdatePacket, ISerializablePacket { public new const short OpCode = 49; From 49df8a7ac7dc1a5fedab64259546d9b5511cb7ff Mon Sep 17 00:00:00 2001 From: JadenY Date: Sat, 27 Jun 2026 23:56:22 -0500 Subject: [PATCH 07/22] Remove unreferenced packet files --- .../NotificationInfo.cs | 39 ------------------ .../AbilityPacketAbilityDefinition.cs | 35 ---------------- .../AbilityPacketRequestAbilityDefinition.cs | 31 -------------- .../AbilityPacketSetDefinition.cs | 29 ------------- .../ClientUpdatePacketAddEffectTag.cs | 26 ------------ .../ClientUpdatePacketRemoveEffectTag.cs | 25 ----------- .../PlayerUpdatePacketAddNotifications.cs | 28 ------------- .../PlayerUpdatePacketDestroyed.cs | 40 ------------------ .../PlayerUpdatePacketHitPointModification.cs | 41 ------------------- .../PlayerUpdatePacketRequestStripEffect.cs | 27 ------------ .../PlayerUpdatePacketUpdateHitpoints.cs | 32 --------------- .../PlayerUpdatePacketUpdateScale.cs | 30 -------------- 12 files changed, 383 deletions(-) delete mode 100644 src/Sanctuary.Packet.Common/NotificationInfo.cs delete mode 100644 src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketAbilityDefinition.cs delete mode 100644 src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketRequestAbilityDefinition.cs delete mode 100644 src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketSetDefinition.cs delete mode 100644 src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketAddEffectTag.cs delete mode 100644 src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketRemoveEffectTag.cs delete mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketAddNotifications.cs delete mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketDestroyed.cs delete mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketHitPointModification.cs delete mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketRequestStripEffect.cs delete mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateHitpoints.cs delete mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateScale.cs diff --git a/src/Sanctuary.Packet.Common/NotificationInfo.cs b/src/Sanctuary.Packet.Common/NotificationInfo.cs deleted file mode 100644 index 5023a1e..0000000 --- a/src/Sanctuary.Packet.Common/NotificationInfo.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Sanctuary.Core.IO; - -namespace Sanctuary.Packet.Common; - -public class NotificationInfo : ISerializableType -{ - public ulong Guid { get; set; } - - public int Unknown3 { get; set; } - public int DescriptionId { get; set; } - public int ImageId { get; set; } - public int NameId { get; set; } - public int SubTextId { get; set; } - public int Type { get; set; } - public bool Unknown8 { get; set; } - public int CompositeEffectId { get; set; } - public bool Combat { get; set; } - public bool Unknown10 { get; set; } - - public void Serialize(PacketWriter writer) - { - writer.Write(Guid); - writer.Write(Combat); - writer.Write(Type); - - if (!Combat) - { - writer.Write(Unknown3); - writer.Write(ImageId); - writer.Write(DescriptionId); - writer.Write(NameId); - writer.Write(SubTextId); - writer.Write(Unknown8); - writer.Write(CompositeEffectId); - } - - writer.Write(Unknown10); - } -} diff --git a/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketAbilityDefinition.cs b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketAbilityDefinition.cs deleted file mode 100644 index 08f1347..0000000 --- a/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketAbilityDefinition.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Sanctuary.Core.IO; - -namespace Sanctuary.Packet; - -public class AbilityPacketAbilityDefinition : BaseAbilityPacket, ISerializablePacket -{ - public new const short OpCode = 13; - - public int AbilityId; - public int AbilityType; // field 2 - type/category - public int NameId; // field 3 - public int IconId; // field 4 - public int CooldownMs; // field 5 - recharge time - public int CastTimeMs; // field 6 - - public AbilityPacketAbilityDefinition() : base(OpCode) - { - } - - public byte[] Serialize() - { - using var writer = new PacketWriter(); - - Write(writer); - - writer.Write(AbilityId); - writer.Write(AbilityType); - writer.Write(NameId); - writer.Write(IconId); - writer.Write(CooldownMs); - writer.Write(CastTimeMs); - - return writer.Buffer; - } -} diff --git a/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketRequestAbilityDefinition.cs b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketRequestAbilityDefinition.cs deleted file mode 100644 index 1376671..0000000 --- a/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketRequestAbilityDefinition.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; - -using Sanctuary.Core.IO; - -namespace Sanctuary.Packet; - -public class AbilityPacketRequestAbilityDefinition : BaseAbilityPacket, IDeserializable -{ - public new const short OpCode = 12; - - public int AbilityId; - - public AbilityPacketRequestAbilityDefinition() : base(OpCode) - { - } - - public static bool TryDeserialize(ReadOnlySpan data, out AbilityPacketRequestAbilityDefinition value) - { - value = new AbilityPacketRequestAbilityDefinition(); - - var reader = new PacketReader(data); - - if (!value.TryRead(ref reader)) - return false; - - if (!reader.TryRead(out value.AbilityId)) - return false; - - return true; - } -} diff --git a/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketSetDefinition.cs b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketSetDefinition.cs deleted file mode 100644 index 3e3460d..0000000 --- a/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketSetDefinition.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Sanctuary.Core.IO; - -namespace Sanctuary.Packet; - -public class AbilityPacketSetDefinition : BaseAbilityPacket, ISerializablePacket -{ - public new const short OpCode = 5; - - public int AbilityId; - public int CooldownRemainingMs; - public int TotalCooldownMs; - - public AbilityPacketSetDefinition() : base(OpCode) - { - } - - public byte[] Serialize() - { - using var writer = new PacketWriter(); - - Write(writer); - - writer.Write(AbilityId); - writer.Write(CooldownRemainingMs); - writer.Write(TotalCooldownMs); - - return writer.Buffer; - } -} diff --git a/src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketAddEffectTag.cs b/src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketAddEffectTag.cs deleted file mode 100644 index 1d0b2e3..0000000 --- a/src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketAddEffectTag.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Sanctuary.Core.IO; -using Sanctuary.Packet.Common; - -namespace Sanctuary.Packet; - -public class ClientUpdatePacketAddEffectTag : BaseClientUpdatePacket, ISerializablePacket -{ - public new const short OpCode = 16; - - public EffectTag EffectTag = new(); - - public ClientUpdatePacketAddEffectTag() : base(OpCode) - { - } - - public byte[] Serialize() - { - using var writer = new PacketWriter(); - - Write(writer); - - EffectTag.Serialize(writer); - - return writer.Buffer; - } -} diff --git a/src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketRemoveEffectTag.cs b/src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketRemoveEffectTag.cs deleted file mode 100644 index 61076b0..0000000 --- a/src/Sanctuary.Packet/BaseClientUpdatePacket/ClientUpdatePacketRemoveEffectTag.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Sanctuary.Core.IO; - -namespace Sanctuary.Packet; - -public class ClientUpdatePacketRemoveEffectTag : BaseClientUpdatePacket, ISerializablePacket -{ - public new const short OpCode = 17; - - public int EffectTagId; - - public ClientUpdatePacketRemoveEffectTag() : base(OpCode) - { - } - - public byte[] Serialize() - { - using var writer = new PacketWriter(); - - Write(writer); - - writer.Write(EffectTagId); - - return writer.Buffer; - } -} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketAddNotifications.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketAddNotifications.cs deleted file mode 100644 index c5a72eb..0000000 --- a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketAddNotifications.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Collections.Generic; - -using Sanctuary.Core.IO; -using Sanctuary.Packet.Common; - -namespace Sanctuary.Packet; - -public class PlayerUpdatePacketAddNotifications : BasePlayerUpdatePacket, ISerializablePacket -{ - public new const short OpCode = 10; - - public List Notifications = new(); - - public PlayerUpdatePacketAddNotifications() : base(OpCode) - { - } - - public byte[] Serialize() - { - using var writer = new PacketWriter(); - - Write(writer); - - writer.Write(Notifications); - - return writer.Buffer; - } -} \ No newline at end of file diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketDestroyed.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketDestroyed.cs deleted file mode 100644 index 196f91b..0000000 --- a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketDestroyed.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Sanctuary.Core.IO; - -namespace Sanctuary.Packet; - -/// -/// Signals that an entity has been destroyed/killed (OpCode 35, SubOpCode 56). -/// -public class PlayerUpdatePacketDestroyed : BasePlayerUpdatePacket, ISerializablePacket -{ - public new const short OpCode = 56; - - public ulong Guid; - - /// - /// The entity that dealt the killing blow. - /// - public ulong KillerGuid; - - /// - /// Unknown purpose — set to 0. - /// - public int Unknown; - - public PlayerUpdatePacketDestroyed() : base(OpCode) - { - } - - public byte[] Serialize() - { - using var writer = new PacketWriter(); - - Write(writer); - - writer.Write(Guid); - writer.Write(KillerGuid); - writer.Write(Unknown); - - return writer.Buffer; - } -} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketHitPointModification.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketHitPointModification.cs deleted file mode 100644 index 25c8d27..0000000 --- a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketHitPointModification.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Sanctuary.Core.IO; - -namespace Sanctuary.Packet; - -/// -/// Broadcasts a hitpoint modification event (damage/heal) to visible players (OpCode 35, SubOpCode 35). -/// Shows a floating combat number on the target. -/// -public class PlayerUpdatePacketHitPointModification : BasePlayerUpdatePacket, ISerializablePacket -{ - public new const short OpCode = 35; - - public ulong TargetGuid; - - /// - /// The HP change amount. Negative = damage, Positive = heal. - /// - public int Amount; - - /// - /// The source entity GUID that caused this modification. - /// - public ulong SourceGuid; - - public PlayerUpdatePacketHitPointModification() : base(OpCode) - { - } - - public byte[] Serialize() - { - using var writer = new PacketWriter(); - - Write(writer); - - writer.Write(TargetGuid); - writer.Write(Amount); - writer.Write(SourceGuid); - - return writer.Buffer; - } -} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketRequestStripEffect.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketRequestStripEffect.cs deleted file mode 100644 index 3fd0419..0000000 --- a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketRequestStripEffect.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Sanctuary.Core.IO; - -namespace Sanctuary.Packet; - -public class PlayerUpdatePacketRequestStripEffect : BasePlayerUpdatePacket, ISerializablePacket -{ - public new const short OpCode = 33; - - public ulong Guid; - public int CompositeEffectId; - - public PlayerUpdatePacketRequestStripEffect() : base(OpCode) - { - } - - public byte[] Serialize() - { - using var writer = new PacketWriter(); - - base.Write(writer); - - writer.Write(Guid); - writer.Write(CompositeEffectId); - - return writer.Buffer; - } -} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateHitpoints.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateHitpoints.cs deleted file mode 100644 index 50e3d0f..0000000 --- a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateHitpoints.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Sanctuary.Core.IO; - -namespace Sanctuary.Packet; - -/// -/// Broadcasts an entity's current/max HP to visible players (OpCode 35, SubOpCode 5). -/// -public class PlayerUpdatePacketUpdateHitpoints : BasePlayerUpdatePacket, ISerializablePacket -{ - public new const short OpCode = 5; - - public ulong Guid; - public int CurrentHitpoints; - public int MaxHitpoints; - - public PlayerUpdatePacketUpdateHitpoints() : base(OpCode) - { - } - - public byte[] Serialize() - { - using var writer = new PacketWriter(); - - Write(writer); - - writer.Write(Guid); - writer.Write(CurrentHitpoints); - writer.Write(MaxHitpoints); - - return writer.Buffer; - } -} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateScale.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateScale.cs deleted file mode 100644 index d31cbee..0000000 --- a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketUpdateScale.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Sanctuary.Core.IO; - -namespace Sanctuary.Packet; - -/// -/// Updates an entity's scale (OpCode 35, SubOpCode 13). -/// -public class PlayerUpdatePacketUpdateScale : BasePlayerUpdatePacket, ISerializablePacket -{ - public new const short OpCode = 13; - - public ulong Guid; - public float Scale; - - public PlayerUpdatePacketUpdateScale() : base(OpCode) - { - } - - public byte[] Serialize() - { - using var writer = new PacketWriter(); - - Write(writer); - - writer.Write(Guid); - writer.Write(Scale); - - return writer.Buffer; - } -} From fd95bd89a7b4436b40496e32fe779f2eaa6e7a05 Mon Sep 17 00:00:00 2001 From: JadenY Date: Sun, 28 Jun 2026 09:03:35 -0500 Subject: [PATCH 08/22] PR27_review_fixes --- src/Sanctuary.Game/Entities/Npc.cs | 6 ++ src/Sanctuary.Game/Entities/Player.cs | 44 ++++-------- .../Resources/BoomboxDefinitionCollection.cs | 70 ------------------- .../Resources/ConsumableCollection.cs | 10 ++- .../Definitions/FoodEffectDefinition.cs | 1 - .../Resources/FoodEffectCollection.cs | 70 ------------------- .../Resources/TransformAbilityCollection.cs | 70 ------------------- src/Sanctuary.Game/Zones/BaseZone.cs | 4 -- src/Sanctuary.Game/Zones/IZone.cs | 1 - ...yPacketClientRequestStartAbilityHandler.cs | 21 +++--- src/Sanctuary.Packet.Common/ActionBarSlot.cs | 1 - .../AbilityPacketExecuteClientLua.cs | 10 ++- .../PlayerUpdatePacketQueueAnimation.cs | 20 ++++-- .../PlayerUpdatePacketReplaceBaseModel.cs | 2 + 14 files changed, 61 insertions(+), 269 deletions(-) delete mode 100644 src/Sanctuary.Game/Resources/BoomboxDefinitionCollection.cs delete mode 100644 src/Sanctuary.Game/Resources/FoodEffectCollection.cs delete mode 100644 src/Sanctuary.Game/Resources/TransformAbilityCollection.cs diff --git a/src/Sanctuary.Game/Entities/Npc.cs b/src/Sanctuary.Game/Entities/Npc.cs index abfdf2d..7c6b5d8 100644 --- a/src/Sanctuary.Game/Entities/Npc.cs +++ b/src/Sanctuary.Game/Entities/Npc.cs @@ -39,6 +39,11 @@ public class Npc : IEntity public float Scale { get; set; } + /// + /// 0 - Hostile + /// 1 - Neutral + /// 2 - Ally + /// public int Disposition { get; set; } = 1; public System.Action? InteractAction { get; set; } @@ -71,6 +76,7 @@ public Npc(IZone zone) public void OnInteract(Player player) { + InteractAction?.Invoke(player); } public virtual void OnAddVisibleNpcs(params IEnumerable npcs) diff --git a/src/Sanctuary.Game/Entities/Player.cs b/src/Sanctuary.Game/Entities/Player.cs index ce685fa..15e3e9f 100644 --- a/src/Sanctuary.Game/Entities/Player.cs +++ b/src/Sanctuary.Game/Entities/Player.cs @@ -55,7 +55,7 @@ public sealed class Player : ClientPcData, IEntity public Dictionary> ActionBarItemGuids { get; set; } = new(); public int TemporaryAppearance { get; set; } - + public DateTimeOffset? TemporaryAppearanceExpiresAt { get; set; } public Vector4 StartingZonePosition { get; set; } @@ -127,6 +127,11 @@ public void SendTunneledToVisible(ISerializablePacket packet, bool sendToSelf = public void UpdateEveryTick() { + if (TemporaryAppearanceExpiresAt.HasValue && + TemporaryAppearanceExpiresAt.Value <= DateTimeOffset.UtcNow) + { + RemoveTemporaryAppearance(); + } } public void UpdateEverySecond() @@ -499,6 +504,7 @@ public PlayerUpdatePacketAddPc GetAddPcPacket() IsUnderage = Age < 18, IsMember = MembershipStatus != 0, + TemporaryAppearance = TemporaryAppearance, ActiveProfileId = ActiveProfileId, @@ -528,55 +534,31 @@ public void ApplyTemporaryAppearance(int modelId, int durationMs) { TemporaryAppearance = modelId; - var replacePacket = new PlayerUpdatePacketReplaceBaseModel + if (durationMs > 0) { - Guid = Guid, - ModelId = modelId - }; - SendTunneled(replacePacket); + TemporaryAppearanceExpiresAt = DateTimeOffset.UtcNow.AddMilliseconds(durationMs); + } var appearancePacket = new PlayerUpdatePacketUpdateTemporaryAppearance { Guid = Guid, TemporaryAppearance = modelId }; - SendToVisible(appearancePacket, true); - if (durationMs > 0) - { - var capturedGuid = Guid; - System.Threading.Tasks.Task.Delay(durationMs).ContinueWith(_ => - { - try - { - TemporaryAppearance = 0; - var removePacket = new PlayerUpdatePacketRemoveTemporaryAppearance - { - Guid = capturedGuid - }; - SendToVisible(removePacket, true); - } - catch { } - }); - } + SendTunneledToVisible(appearancePacket, true); } public void RemoveTemporaryAppearance() { TemporaryAppearance = 0; + TemporaryAppearanceExpiresAt = null; var removePacket = new PlayerUpdatePacketRemoveTemporaryAppearance { Guid = Guid }; - SendToVisible(removePacket, true); - var replacePacket = new PlayerUpdatePacketReplaceBaseModel - { - Guid = Guid, - ModelId = Model - }; - SendTunneled(replacePacket); + SendTunneledToVisible(removePacket, true); } #region Equatable diff --git a/src/Sanctuary.Game/Resources/BoomboxDefinitionCollection.cs b/src/Sanctuary.Game/Resources/BoomboxDefinitionCollection.cs deleted file mode 100644 index 341e581..0000000 --- a/src/Sanctuary.Game/Resources/BoomboxDefinitionCollection.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.Json; - -using Microsoft.Extensions.Logging; - -using Sanctuary.Core.Collections; -using Sanctuary.Game.Resources.Definitions; - -namespace Sanctuary.Game.Resources; - -public class BoomboxDefinitionCollection : ObservableConcurrentDictionary -{ - private readonly ILogger _logger; - - public BoomboxDefinitionCollection(ILogger logger) - { - _logger = logger; - } - - public bool Load(string filePath) - { - if (!File.Exists(filePath)) - { - _logger.LogError("Failed to find file \"{file}\"", filePath); - return false; - } - - try - { - using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - - var jsonSerializerOptions = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - var list = JsonSerializer.Deserialize>(fileStream, jsonSerializerOptions); - - if (list is null) - { - _logger.LogError("No entries found in file \"{file}\".", filePath); - return false; - } - - foreach (var entry in list) - { - if (!TryAdd(entry.ItemId, entry)) - { - _logger.LogWarning("Failed to add entry. {id} \"{file}\"", entry.ItemId, filePath); - return false; - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to parse file \"{file}\".", filePath); - return false; - } - - if (Count == 0) - { - _logger.LogError("No data was loaded. \"{file}\"", filePath); - return false; - } - - return true; - } -} diff --git a/src/Sanctuary.Game/Resources/ConsumableCollection.cs b/src/Sanctuary.Game/Resources/ConsumableCollection.cs index 8362926..ab8903a 100644 --- a/src/Sanctuary.Game/Resources/ConsumableCollection.cs +++ b/src/Sanctuary.Game/Resources/ConsumableCollection.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; +using Sanctuary.Core.Collections; using Sanctuary.Game.Resources.Definitions; namespace Sanctuary.Game.Resources; @@ -12,16 +13,13 @@ public class ConsumableCollection { private readonly ILogger _logger; - public BoomboxDefinitionCollection Boomboxes { get; } - public FoodEffectCollection FoodEffects { get; } - public TransformAbilityCollection Transformations { get; } + public ObservableConcurrentDictionary Boomboxes { get; } = new(); + public ObservableConcurrentDictionary FoodEffects { get; } = new(); + public ObservableConcurrentDictionary Transformations { get; } = new(); public ConsumableCollection(ILogger logger) { _logger = logger; - Boomboxes = new BoomboxDefinitionCollection(logger); - FoodEffects = new FoodEffectCollection(logger); - Transformations = new TransformAbilityCollection(logger); } public bool Load(string filePath) diff --git a/src/Sanctuary.Game/Resources/Definitions/FoodEffectDefinition.cs b/src/Sanctuary.Game/Resources/Definitions/FoodEffectDefinition.cs index 346ed1b..2cd4f98 100644 --- a/src/Sanctuary.Game/Resources/Definitions/FoodEffectDefinition.cs +++ b/src/Sanctuary.Game/Resources/Definitions/FoodEffectDefinition.cs @@ -4,5 +4,4 @@ public class FoodEffectDefinition { public int AbilityId { get; set; } public int CompositeEffectId { get; set; } - public string? Comment { get; set; } } diff --git a/src/Sanctuary.Game/Resources/FoodEffectCollection.cs b/src/Sanctuary.Game/Resources/FoodEffectCollection.cs deleted file mode 100644 index 978d38b..0000000 --- a/src/Sanctuary.Game/Resources/FoodEffectCollection.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.Json; - -using Microsoft.Extensions.Logging; - -using Sanctuary.Core.Collections; -using Sanctuary.Game.Resources.Definitions; - -namespace Sanctuary.Game.Resources; - -public class FoodEffectCollection : ObservableConcurrentDictionary -{ - private readonly ILogger _logger; - - public FoodEffectCollection(ILogger logger) - { - _logger = logger; - } - - public bool Load(string filePath) - { - if (!File.Exists(filePath)) - { - _logger.LogError("Failed to find file \"{file}\"", filePath); - return false; - } - - try - { - using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - - var jsonSerializerOptions = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - var list = JsonSerializer.Deserialize>(fileStream, jsonSerializerOptions); - - if (list is null) - { - _logger.LogError("No entries found in file \"{file}\".", filePath); - return false; - } - - foreach (var entry in list) - { - if (!TryAdd(entry.AbilityId, entry)) - { - _logger.LogWarning("Failed to add entry. {id} \"{file}\"", entry.AbilityId, filePath); - return false; - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to parse file \"{file}\".", filePath); - return false; - } - - if (Count == 0) - { - _logger.LogError("No data was loaded. \"{file}\"", filePath); - return false; - } - - return true; - } -} diff --git a/src/Sanctuary.Game/Resources/TransformAbilityCollection.cs b/src/Sanctuary.Game/Resources/TransformAbilityCollection.cs deleted file mode 100644 index 209afa9..0000000 --- a/src/Sanctuary.Game/Resources/TransformAbilityCollection.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.Json; - -using Microsoft.Extensions.Logging; - -using Sanctuary.Core.Collections; -using Sanctuary.Game.Resources.Definitions; - -namespace Sanctuary.Game.Resources; - -public class TransformAbilityCollection : ObservableConcurrentDictionary -{ - private readonly ILogger _logger; - - public TransformAbilityCollection(ILogger logger) - { - _logger = logger; - } - - public bool Load(string filePath) - { - if (!File.Exists(filePath)) - { - _logger.LogError("Failed to find file \"{file}\"", filePath); - return false; - } - - try - { - using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - - var jsonSerializerOptions = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - var list = JsonSerializer.Deserialize>(fileStream, jsonSerializerOptions); - - if (list is null) - { - _logger.LogError("No entries found in file \"{file}\".", filePath); - return false; - } - - foreach (var entry in list) - { - if (!TryAdd(entry.AbilityId, entry)) - { - _logger.LogWarning("Failed to add entry. {id} \"{file}\"", entry.AbilityId, filePath); - return false; - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to parse file \"{file}\".", filePath); - return false; - } - - if (Count == 0) - { - _logger.LogError("No data was loaded. \"{file}\"", filePath); - return false; - } - - return true; - } -} diff --git a/src/Sanctuary.Game/Zones/BaseZone.cs b/src/Sanctuary.Game/Zones/BaseZone.cs index 4b2fd45..aa1de06 100644 --- a/src/Sanctuary.Game/Zones/BaseZone.cs +++ b/src/Sanctuary.Game/Zones/BaseZone.cs @@ -82,10 +82,6 @@ public virtual void OnClientFinishedLoading(Player player) { } - public virtual void RefreshPlayerCustomizations(Player player) - { - } - #endregion #region Entities diff --git a/src/Sanctuary.Game/Zones/IZone.cs b/src/Sanctuary.Game/Zones/IZone.cs index b77b0c5..f7d41da 100644 --- a/src/Sanctuary.Game/Zones/IZone.cs +++ b/src/Sanctuary.Game/Zones/IZone.cs @@ -17,7 +17,6 @@ public interface IZone void OnClientIsReady(Player entity); void OnClientFinishedLoading(Player entity); - void RefreshPlayerCustomizations(Player player); #endregion diff --git a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs index 2f0ae54..08363c9 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs @@ -140,7 +140,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke cooldownSlot.Slot.NameId = capturedItemDef.NameId; cooldownSlot.Slot.Unknown5 = 1; cooldownSlot.Slot.Unknown6 = 4; - cooldownSlot.Slot.Clear = 15; + cooldownSlot.Slot.Unknown7 = 15; cooldownSlot.Slot.Enabled = false; cooldownSlot.Slot.Unknown10 = 0; cooldownSlot.Slot.TotalRefreshTime = cooldownMs; @@ -167,7 +167,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke tickSlot.Slot.NameId = capturedItemDef.NameId; tickSlot.Slot.Unknown5 = 1; tickSlot.Slot.Unknown6 = 4; - tickSlot.Slot.Clear = 15; + tickSlot.Slot.Unknown7 = 15; tickSlot.Slot.Enabled = false; tickSlot.Slot.Unknown10 = elapsed; tickSlot.Slot.TotalRefreshTime = cooldownMs; @@ -194,7 +194,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke readySlot.Slot.NameId = capturedItemDef.NameId; readySlot.Slot.Unknown5 = 1; readySlot.Slot.Unknown6 = 4; - readySlot.Slot.Clear = 15; + readySlot.Slot.Unknown7 = 15; readySlot.Slot.Enabled = true; readySlot.Slot.Unknown10 = 1000; readySlot.Slot.TotalRefreshTime = 1000; @@ -253,7 +253,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke cooldownSlot.Slot.NameId = capturedItemDef.NameId; cooldownSlot.Slot.Unknown5 = 1; cooldownSlot.Slot.Unknown6 = 4; - cooldownSlot.Slot.Clear = 15; + cooldownSlot.Slot.Unknown7 = 15; cooldownSlot.Slot.Enabled = false; cooldownSlot.Slot.Unknown10 = 0; cooldownSlot.Slot.TotalRefreshTime = cooldownMs; @@ -279,7 +279,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke tickSlot.Slot.NameId = capturedItemDef.NameId; tickSlot.Slot.Unknown5 = 1; tickSlot.Slot.Unknown6 = 4; - tickSlot.Slot.Clear = 15; + tickSlot.Slot.Unknown7 = 15; tickSlot.Slot.Enabled = false; tickSlot.Slot.Unknown10 = elapsed; tickSlot.Slot.TotalRefreshTime = cooldownMs; @@ -306,7 +306,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke readySlot.Slot.NameId = capturedItemDef.NameId; readySlot.Slot.Unknown5 = 1; readySlot.Slot.Unknown6 = 4; - readySlot.Slot.Clear = 15; + readySlot.Slot.Unknown7 = 15; readySlot.Slot.Enabled = true; readySlot.Slot.Unknown10 = 1000; readySlot.Slot.TotalRefreshTime = 1000; @@ -425,7 +425,7 @@ private static bool ConsumeItem(GatewayConnection connection, Packet.Common.Clie clientUpdatePacketUpdateActionBarSlot.Slot.NameId = clientItemDefinition.NameId; clientUpdatePacketUpdateActionBarSlot.Slot.Unknown5 = 1; clientUpdatePacketUpdateActionBarSlot.Slot.Unknown6 = 4; - clientUpdatePacketUpdateActionBarSlot.Slot.Clear = 15; + clientUpdatePacketUpdateActionBarSlot.Slot.Unknown7 = 15; clientUpdatePacketUpdateActionBarSlot.Slot.Enabled = true; clientUpdatePacketUpdateActionBarSlot.Slot.Unknown10 = 1000; clientUpdatePacketUpdateActionBarSlot.Slot.TotalRefreshTime = 1000; @@ -489,7 +489,10 @@ private static void TriggerAbilityEffect(GatewayConnection connection, Packet.Co { var abilityPacketExecuteClientLua = new AbilityPacketExecuteClientLua { - AbilityId = abilityId + Script = string.Empty, + Param1 = 0, + Param2 = 0, + Param3 = 0 }; connection.Player.SendTunneledToVisible(abilityPacketExecuteClientLua, true); @@ -753,7 +756,7 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. boomboxNpc.TintAlias = itemDef.TintAlias ?? ""; boomboxNpc.Scale = 1.0f; boomboxNpc.Animation = 2100; // Bouncing animation - boomboxNpc.CompositeEffectId = effectId; // Owned by the entity — client stops it when RemovePlayer is received + boomboxNpc.CompositeEffectId = effectId; // Owned by the entity — client stops it when RemovePlayer is received boomboxNpc.HideNamePlate = true; boomboxNpc.IsInteractable = false; diff --git a/src/Sanctuary.Packet.Common/ActionBarSlot.cs b/src/Sanctuary.Packet.Common/ActionBarSlot.cs index da9c88c..3e76aa0 100644 --- a/src/Sanctuary.Packet.Common/ActionBarSlot.cs +++ b/src/Sanctuary.Packet.Common/ActionBarSlot.cs @@ -14,7 +14,6 @@ public class ActionBarSlot : ISerializableType public int Unknown5; public int Unknown6; public int Unknown7; - public int Clear; public int ManaCost; diff --git a/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketExecuteClientLua.cs b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketExecuteClientLua.cs index 5212340..7c83172 100644 --- a/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketExecuteClientLua.cs +++ b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketExecuteClientLua.cs @@ -6,7 +6,10 @@ public class AbilityPacketExecuteClientLua : BaseAbilityPacket, ISerializablePac { public new const short OpCode = 17; - public int AbilityId; + public string Script = string.Empty; + public float Param1; + public float Param2; + public float Param3; public AbilityPacketExecuteClientLua() : base(OpCode) { @@ -18,7 +21,10 @@ public byte[] Serialize() Write(writer); - writer.Write(AbilityId); + writer.Write(Script); + writer.Write(Param1); + writer.Write(Param2); + writer.Write(Param3); return writer.Buffer; } diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketQueueAnimation.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketQueueAnimation.cs index 0ae39cc..d06a56b 100644 --- a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketQueueAnimation.cs +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketQueueAnimation.cs @@ -7,8 +7,14 @@ public class PlayerUpdatePacketQueueAnimation : BasePlayerUpdatePacket, ISeriali public new const short OpCode = 22; public ulong Guid; - public int AnimationId; - public bool Unknown1; + public int AnimationGroupId; + public int Unknown; + public float Unknown2; + public float Unknown3; + public float Unknown4; + public bool Unknown5; + public bool Unknown6; + public bool Unknown7; public PlayerUpdatePacketQueueAnimation() : base(OpCode) { @@ -21,8 +27,14 @@ public byte[] Serialize() base.Write(writer); writer.Write(Guid); - writer.Write(AnimationId); - writer.Write(Unknown1); + writer.Write(AnimationGroupId); + writer.Write(Unknown); + writer.Write(Unknown2); + writer.Write(Unknown3); + writer.Write(Unknown4); + writer.Write(Unknown5); + writer.Write(Unknown6); + writer.Write(Unknown7); return writer.Buffer; } diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketReplaceBaseModel.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketReplaceBaseModel.cs index 8641be3..715812f 100644 --- a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketReplaceBaseModel.cs +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketReplaceBaseModel.cs @@ -8,6 +8,7 @@ public class PlayerUpdatePacketReplaceBaseModel : BasePlayerUpdatePacket, ISeria public ulong Guid; public int ModelId; + public int CompositeEffectId; public PlayerUpdatePacketReplaceBaseModel() : base(OpCode) { @@ -21,6 +22,7 @@ public byte[] Serialize() writer.Write(Guid); writer.Write(ModelId); + writer.Write(CompositeEffectId); return writer.Buffer; } From 7af2add9e61dce79f94a83942da3970b31fd181e Mon Sep 17 00:00:00 2001 From: JadenY Date: Sun, 28 Jun 2026 13:38:29 -0500 Subject: [PATCH 09/22] Refactor cake detection to be data-driven via Consumables.json --- src/Resources/Consumables.json | 8 ++++++++ .../Resources/ConsumableCollection.cs | 13 ++++++++++++- .../Resources/Definitions/CakeItemDefinition.cs | 14 ++++++++++++++ .../Resources/Definitions/ConsumableDefinitions.cs | 2 ++ ...bilityPacketClientRequestStartAbilityHandler.cs | 12 ++++++------ 5 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 src/Sanctuary.Game/Resources/Definitions/CakeItemDefinition.cs diff --git a/src/Resources/Consumables.json b/src/Resources/Consumables.json index d89d634..783382c 100644 --- a/src/Resources/Consumables.json +++ b/src/Resources/Consumables.json @@ -18,6 +18,14 @@ { "ItemId": 76934, "ModelId": 4100, "EffectId": 16827, "DanceSequence": [3568009, 3569009, 3570009, 3571009] } ], + "Cakes": [ + { "ItemId": 17597, "Type": "ScaredyCake", "CooldownMs": 60000 }, + { "ItemId": 69819, "Type": "ScaredyCake", "CooldownMs": 60000 }, + { "ItemId": 69825, "Type": "BossCake", "CooldownMs": 30000 }, + { "ItemId": 69828, "Type": "BossCake", "CooldownMs": 30000 }, + { "ItemId": 69839, "Type": "BossCake", "CooldownMs": 30000 } + ], + "FoodEffects": [ { "AbilityId": 1956, "CompositeEffectId": 47 }, { "AbilityId": 1957, "CompositeEffectId": 5032 }, diff --git a/src/Sanctuary.Game/Resources/ConsumableCollection.cs b/src/Sanctuary.Game/Resources/ConsumableCollection.cs index ab8903a..8d45a89 100644 --- a/src/Sanctuary.Game/Resources/ConsumableCollection.cs +++ b/src/Sanctuary.Game/Resources/ConsumableCollection.cs @@ -14,6 +14,7 @@ public class ConsumableCollection private readonly ILogger _logger; public ObservableConcurrentDictionary Boomboxes { get; } = new(); + public ObservableConcurrentDictionary Cakes { get; } = new(); public ObservableConcurrentDictionary FoodEffects { get; } = new(); public ObservableConcurrentDictionary Transformations { get; } = new(); @@ -67,6 +68,16 @@ public bool Load(string filePath) } _logger.LogInformation("Loaded {count} FoodEffect definitions.", FoodEffects.Count); + foreach (var entry in consumables.Cakes) + { + if (!Cakes.TryAdd(entry.ItemId, entry)) + { + _logger.LogWarning("Failed to add Cake entry. ItemId={id} \"{file}\"", entry.ItemId, filePath); + return false; + } + } + _logger.LogInformation("Loaded {count} Cake definitions.", Cakes.Count); + foreach (var entry in consumables.Transformations) { if (!Transformations.TryAdd(entry.AbilityId, entry)) @@ -83,7 +94,7 @@ public bool Load(string filePath) return false; } - if (Boomboxes.Count == 0 && FoodEffects.Count == 0 && Transformations.Count == 0) + if (Boomboxes.Count == 0 && FoodEffects.Count == 0 && Transformations.Count == 0 && Cakes.Count == 0) { _logger.LogError("No data was loaded from \"{file}\"", filePath); return false; diff --git a/src/Sanctuary.Game/Resources/Definitions/CakeItemDefinition.cs b/src/Sanctuary.Game/Resources/Definitions/CakeItemDefinition.cs new file mode 100644 index 0000000..beac15a --- /dev/null +++ b/src/Sanctuary.Game/Resources/Definitions/CakeItemDefinition.cs @@ -0,0 +1,14 @@ +namespace Sanctuary.Game.Resources.Definitions; + +public class CakeItemDefinition +{ + public int ItemId { get; set; } + public CakeItemType Type { get; set; } + public int CooldownMs { get; set; } +} + +public enum CakeItemType +{ + ScaredyCake, + BossCake +} \ No newline at end of file diff --git a/src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs b/src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs index 38dfb48..ab8c874 100644 --- a/src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs +++ b/src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs @@ -6,6 +6,8 @@ public class ConsumableDefinitions { public List Boomboxes { get; set; } = new(); + public List Cakes { get; set; } = new(); + public List FoodEffects { get; set; } = new(); public List Transformations { get; set; } = new(); diff --git a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs index 08363c9..19d8cd7 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs @@ -13,6 +13,7 @@ using Sanctuary.Core.IO; using Sanctuary.Database; using Sanctuary.Game; +using Sanctuary.Game.Resources.Definitions; using Sanctuary.Packet; using Sanctuary.Packet.Common.Attributes; @@ -104,12 +105,11 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke } bool isBoombox = _resourceManager.Consumables.Boomboxes.ContainsKey(clientItemDefinition.Id); + bool isCake = _resourceManager.Consumables.Cakes.TryGetValue(clientItemDefinition.Id, out var cakeDef); + bool isScaredyCake = isCake && cakeDef!.Type == CakeItemType.ScaredyCake; + bool isBossCake = isCake && cakeDef!.Type == CakeItemType.BossCake; - bool isScaredyCake = clientItemDefinition.ActivatableAbilityId == 4360; - bool isBossCake = clientItemDefinition.ActivatableAbilityId >= 4370 && clientItemDefinition.ActivatableAbilityId <= 4373; - bool isCake = isScaredyCake || isBossCake; - - if (isBossCake || isBoombox || isScaredyCake) + if (isCake || isBoombox) { var playerCooldowns = _boomboxCooldowns.GetOrAdd(connection.Player.Guid, _ => new ConcurrentDictionary()); @@ -126,7 +126,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke else SpawnBoomboxNpc(connection, clientItemDefinition); - int cooldownMs = isBossCake ? 30_000 : 60_000; + int cooldownMs = isCake ? cakeDef!.CooldownMs : 60_000; playerCooldowns[clientItemDefinition.Id] = DateTimeOffset.UtcNow.AddMilliseconds(cooldownMs); var capturedSlot = packet.Data.Slot; From 8e4b88964f3b5f83a1dfe5c19de28021aa00af1a Mon Sep 17 00:00:00 2001 From: JadenY Date: Sun, 28 Jun 2026 15:03:12 -0500 Subject: [PATCH 10/22] Use already-loaded assemblies for provider resolution instead of Assembly.LoadFrom --- src/Sanctuary.Database/DatabaseContext.cs | 33 ++++++++--------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/src/Sanctuary.Database/DatabaseContext.cs b/src/Sanctuary.Database/DatabaseContext.cs index 41eaf35..32a76ee 100644 --- a/src/Sanctuary.Database/DatabaseContext.cs +++ b/src/Sanctuary.Database/DatabaseContext.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Reflection; using Microsoft.EntityFrameworkCore; @@ -47,27 +48,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) private Assembly? LoadProviderAssembly() { - string? providerAssembly = null; - - if (Database.IsMySql()) - providerAssembly = $"{typeof(DatabaseFactory).Namespace}.MySql"; - else if (Database.IsSqlite()) - providerAssembly = $"{typeof(DatabaseFactory).Namespace}.Sqlite"; - - ArgumentException.ThrowIfNullOrEmpty(providerAssembly); - - Assembly? assembly = null; - - try - { - assembly = EF.IsDesignTime - ? Assembly.Load(providerAssembly) - : Assembly.LoadFrom($"{providerAssembly}.dll"); - } - catch - { - } - - return assembly; + var prefix = $"{typeof(DatabaseFactory).Namespace}."; + + return AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => + { + var name = a.GetName().Name; + return name is not null + && name.StartsWith(prefix, StringComparison.Ordinal) + && (name.EndsWith(".MySql", StringComparison.Ordinal) || name.EndsWith(".Sqlite", StringComparison.Ordinal)); + }); } } \ No newline at end of file From 67634d5be89c5d41ce119a8722aa588a42b1d90e Mon Sep 17 00:00:00 2001 From: JadenY Date: Sun, 28 Jun 2026 16:56:46 -0500 Subject: [PATCH 11/22] abilitypacket --- ...yPacketClientRequestStartAbilityHandler.cs | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs index 19d8cd7..bbf22bf 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs @@ -772,27 +772,10 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. connection.Player.Position.W ); - boomboxNpc.UpdatePosition(spawnPosition, connection.Player.Rotation); + // Visible must be set before UpdatePosition so UpdateZoneTile() fires + // and the zone tile system sends AddNpc to all players in range. boomboxNpc.Visible = true; - - var addNpcPacket = boomboxNpc.GetAddNpcPacket(); - - const float BoomboxRangeInMeters = 15.0f; // 2 meters range - - var nearbyPlayers = new List { connection.Player }; - - foreach (var player in connection.Player.VisiblePlayers.Values) - { - float distance = Vector3.Distance( - new Vector3(player.Position.X, player.Position.Y, player.Position.Z), - new Vector3(spawnPosition.X, spawnPosition.Y, spawnPosition.Z) - ); - - if (distance <= BoomboxRangeInMeters) - { - nearbyPlayers.Add(player); - } - } + boomboxNpc.UpdatePosition(spawnPosition, connection.Player.Rotation); var poofEffect = new PlayerUpdatePacketPlayCompositeEffect { @@ -805,12 +788,19 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. Clear = false }; - foreach (var player in nearbyPlayers) + // Zone tile system sent AddNpc to boomboxNpc.VisiblePlayers via OnAddVisibleNpcs. + // Send poof after AddNpc so the entity exists on the client when the effect plays. + var pktRecipients = boomboxNpc.VisiblePlayers.Values.ToList(); + if (!boomboxNpc.VisiblePlayers.ContainsKey(connection.Player.Guid)) { - player.SendTunneled(poofEffect); - player.SendTunneled(addNpcPacket); + // Spawner is outside zone tile range — send packets manually. + connection.Player.SendTunneled(boomboxNpc.GetAddNpcPacket()); + pktRecipients.Insert(0, connection.Player); } + foreach (var player in pktRecipients) + player.SendTunneled(poofEffect); + var capturedNpc = boomboxNpc; StartBoomboxDancing(startingZone, spawnPosition, danceSequence, 60_000); From 532508fa76a1077db941d3836df09f77f60cb40d Mon Sep 17 00:00:00 2001 From: JadenY Date: Mon, 29 Jun 2026 02:32:08 -0500 Subject: [PATCH 12/22] Address EDITzDev review: restore removed code comments in BaseZone and Player --- src/Sanctuary.Game/Entities/Player.cs | 6 ++++-- src/Sanctuary.Game/Zones/BaseZone.cs | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Sanctuary.Game/Entities/Player.cs b/src/Sanctuary.Game/Entities/Player.cs index 15e3e9f..7fad79b 100644 --- a/src/Sanctuary.Game/Entities/Player.cs +++ b/src/Sanctuary.Game/Entities/Player.cs @@ -177,6 +177,7 @@ public void TeleportToZone(IZone zone, Vector4 position, Quaternion rotation) if (Mount is not null) Mount.TeleportToZone(zone, position, rotation); + // Alert/Remove visible entities foreach (var visiblePlayer in VisiblePlayers) visiblePlayer.Value.OnRemoveVisiblePlayers([this]); @@ -187,10 +188,10 @@ public void TeleportToZone(IZone zone, Vector4 position, Quaternion rotation) Zone.TryRemovePlayer(Guid); - + // Add to new zone/zonetile zone.TryAddPlayer(this); - + // Teleport to new zone Visible = false; Zone = zone; @@ -450,6 +451,7 @@ public List GetAttachments() var compositeEffectId = clientItemDefinition.CompositeEffectId; + // Update the Weapon composite effect if we have a Flair Shard equipped. if (slot == 7) { var flairShardcompositeEffectId = GetFlairShardCompositeEffect(); diff --git a/src/Sanctuary.Game/Zones/BaseZone.cs b/src/Sanctuary.Game/Zones/BaseZone.cs index aa1de06..39d30f5 100644 --- a/src/Sanctuary.Game/Zones/BaseZone.cs +++ b/src/Sanctuary.Game/Zones/BaseZone.cs @@ -159,6 +159,7 @@ private Dictionary GenerateTiles() { var tiles = new Dictionary(); + // Generate all tiles for (var longitude = _zoneDefinition.StartLongitude; longitude < _zoneDefinition.EndLongitude; longitude++) { for (var latitude = _zoneDefinition.StartLatitude; latitude < _zoneDefinition.EndLatitude; latitude++) @@ -169,6 +170,7 @@ private Dictionary GenerateTiles() } } + // Calcualte visible tiles for (var rootLongitude = _zoneDefinition.StartLongitude; rootLongitude < _zoneDefinition.EndLongitude; rootLongitude++) { for (var rootLatitude = _zoneDefinition.StartLatitude; rootLatitude < _zoneDefinition.EndLatitude; rootLatitude++) From 4741cc13a83b9c4594370bc729dd524c806b61a8 Mon Sep 17 00:00:00 2001 From: JadenY Date: Mon, 29 Jun 2026 02:46:41 -0500 Subject: [PATCH 13/22] Refactor consumables: move cooldown ticking to Player.UpdateEverySecond, fix NPC spawn bounds - Move ActionBar cooldown animation from Task.Run loops to Player.UpdateEverySecond() via StartActionBarCooldown() / BuildCooldownSlotPacket() helpers - Rename _boomboxCooldowns -> _itemCooldowns (used for all consumables) - Fix bare catch{} blocks in spawn methods to log exceptions - Fix $"" string interpolation in logger call to use structured logging - Fix GetTileFromCoordinate bounds check (StartLongitude+EndLongitude -> EndLongitude) - Remove premature NotificationImageSetId/NotificationData writes from PlayerUpdatePacketAddNpc --- src/Sanctuary.Game/Entities/Player.cs | 37 ++++ src/Sanctuary.Game/Zones/BaseZone.cs | 4 +- ...yPacketClientRequestStartAbilityHandler.cs | 185 ++---------------- 3 files changed, 53 insertions(+), 173 deletions(-) diff --git a/src/Sanctuary.Game/Entities/Player.cs b/src/Sanctuary.Game/Entities/Player.cs index 7fad79b..fc5687d 100644 --- a/src/Sanctuary.Game/Entities/Player.cs +++ b/src/Sanctuary.Game/Entities/Player.cs @@ -57,6 +57,8 @@ public sealed class Player : ClientPcData, IEntity public int TemporaryAppearance { get; set; } public DateTimeOffset? TemporaryAppearanceExpiresAt { get; set; } + private record PendingCooldown(int ActionBarId, int SlotIndex, int IconId, int NameId, int Count, int CooldownMs, DateTimeOffset StartedAt); + private readonly ConcurrentDictionary<(int, int), PendingCooldown> _pendingCooldowns = new(); public Vector4 StartingZonePosition { get; set; } public Quaternion StartingZoneRotation { get; set; } @@ -136,6 +138,41 @@ public void UpdateEveryTick() public void UpdateEverySecond() { + var now = DateTimeOffset.UtcNow; + foreach (var (key, cooldown) in _pendingCooldowns) + { + int elapsed = (int)(now - cooldown.StartedAt).TotalMilliseconds; + bool expired = elapsed >= cooldown.CooldownMs; + SendTunneled(BuildCooldownSlotPacket(cooldown, expired ? cooldown.CooldownMs : elapsed, expired)); + if (expired) + _pendingCooldowns.TryRemove(key, out _); + } + } + + public void StartActionBarCooldown(int actionBarId, int slotIndex, int iconId, int nameId, int count, int cooldownMs) + { + var cooldown = new PendingCooldown(actionBarId, slotIndex, iconId, nameId, count, cooldownMs, DateTimeOffset.UtcNow); + _pendingCooldowns[(actionBarId, slotIndex)] = cooldown; + SendTunneled(BuildCooldownSlotPacket(cooldown, 0, false)); + } + + private static ClientUpdatePacketUpdateActionBarSlot BuildCooldownSlotPacket(PendingCooldown cooldown, int elapsed, bool enabled) + { + var packet = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = cooldown.ActionBarId, Slot = cooldown.SlotIndex } }; + packet.Slot.IsEmpty = false; + packet.Slot.IconId = cooldown.IconId; + packet.Slot.NameId = cooldown.NameId; + packet.Slot.Unknown5 = 1; + packet.Slot.Unknown6 = 4; + packet.Slot.Unknown7 = 15; + packet.Slot.Enabled = enabled; + packet.Slot.Unknown10 = elapsed; + packet.Slot.TotalRefreshTime = cooldown.CooldownMs; + packet.Slot.Unknown12 = elapsed; + packet.Slot.Quantity = cooldown.Count; + packet.Slot.ForceDismount = true; + packet.Slot.Unknown15 = elapsed; + return packet; } public void UpdatePosition(Vector4 position, Quaternion rotation) diff --git a/src/Sanctuary.Game/Zones/BaseZone.cs b/src/Sanctuary.Game/Zones/BaseZone.cs index 39d30f5..9b748a4 100644 --- a/src/Sanctuary.Game/Zones/BaseZone.cs +++ b/src/Sanctuary.Game/Zones/BaseZone.cs @@ -206,11 +206,11 @@ public ZoneTile GetTileFromPosition(Vector4 position) private ZoneTile GetTileFromCoordinate(int longitude, int latitude) { if (longitude < _zoneDefinition.StartLongitude || - longitude > _zoneDefinition.StartLongitude + _zoneDefinition.EndLongitude) + longitude >= _zoneDefinition.EndLongitude) return ZoneTile.Empty; if (latitude < _zoneDefinition.StartLatitude || - latitude > _zoneDefinition.StartLatitude + _zoneDefinition.EndLatitude) + latitude >= _zoneDefinition.EndLatitude) return ZoneTile.Empty; var tileHash = ZoneTile.GetHash(longitude, latitude); diff --git a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs index bbf22bf..7d7e364 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs @@ -26,7 +26,7 @@ public static class AbilityPacketClientRequestStartAbilityHandler private static IResourceManager _resourceManager = null!; private static IDbContextFactory _dbContextFactory = null!; - private static readonly ConcurrentDictionary> _boomboxCooldowns = new(); + private static readonly ConcurrentDictionary> _itemCooldowns = new(); public static void ConfigureServices(IServiceProvider serviceProvider) { @@ -111,7 +111,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke if (isCake || isBoombox) { - var playerCooldowns = _boomboxCooldowns.GetOrAdd(connection.Player.Guid, _ => new ConcurrentDictionary()); + var playerCooldowns = _itemCooldowns.GetOrAdd(connection.Player.Guid, _ => new ConcurrentDictionary()); if (playerCooldowns.TryGetValue(clientItemDefinition.Id, out var expiry) && DateTimeOffset.UtcNow < expiry) { @@ -129,82 +129,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke int cooldownMs = isCake ? cakeDef!.CooldownMs : 60_000; playerCooldowns[clientItemDefinition.Id] = DateTimeOffset.UtcNow.AddMilliseconds(cooldownMs); - var capturedSlot = packet.Data.Slot; - var capturedItemDef = clientItemDefinition; - var capturedCount = clientItem.Count; - var capturedPlayerGuid = connection.Player.Guid; - - var cooldownSlot = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = capturedSlot } }; - cooldownSlot.Slot.IsEmpty = false; - cooldownSlot.Slot.IconId = capturedItemDef.Icon.Id; - cooldownSlot.Slot.NameId = capturedItemDef.NameId; - cooldownSlot.Slot.Unknown5 = 1; - cooldownSlot.Slot.Unknown6 = 4; - cooldownSlot.Slot.Unknown7 = 15; - cooldownSlot.Slot.Enabled = false; - cooldownSlot.Slot.Unknown10 = 0; - cooldownSlot.Slot.TotalRefreshTime = cooldownMs; - cooldownSlot.Slot.Unknown12 = 0; - cooldownSlot.Slot.Quantity = capturedCount; - cooldownSlot.Slot.ForceDismount = true; - cooldownSlot.Slot.Unknown15 = 0; - connection.SendTunneled(cooldownSlot); - - var startTime = DateTimeOffset.UtcNow; - _ = Task.Run(async () => - { - try - { - while (true) - { - await Task.Delay(2000); - int elapsed = (int)(DateTimeOffset.UtcNow - startTime).TotalMilliseconds; - if (elapsed >= cooldownMs) break; - - var tickSlot = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = capturedSlot } }; - tickSlot.Slot.IsEmpty = false; - tickSlot.Slot.IconId = capturedItemDef.Icon.Id; - tickSlot.Slot.NameId = capturedItemDef.NameId; - tickSlot.Slot.Unknown5 = 1; - tickSlot.Slot.Unknown6 = 4; - tickSlot.Slot.Unknown7 = 15; - tickSlot.Slot.Enabled = false; - tickSlot.Slot.Unknown10 = elapsed; - tickSlot.Slot.TotalRefreshTime = cooldownMs; - tickSlot.Slot.Unknown12 = elapsed; - tickSlot.Slot.Quantity = capturedCount; - tickSlot.Slot.ForceDismount = true; - tickSlot.Slot.Unknown15 = elapsed; - connection.SendTunneled(tickSlot); - } - } - catch { } - }); - - Task.Delay(cooldownMs).ContinueWith(_ => - { - try - { - if (_boomboxCooldowns.TryGetValue(capturedPlayerGuid, out var cd)) - cd.TryRemove(capturedItemDef.Id, out DateTimeOffset _); - - var readySlot = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = capturedSlot } }; - readySlot.Slot.IsEmpty = false; - readySlot.Slot.IconId = capturedItemDef.Icon.Id; - readySlot.Slot.NameId = capturedItemDef.NameId; - readySlot.Slot.Unknown5 = 1; - readySlot.Slot.Unknown6 = 4; - readySlot.Slot.Unknown7 = 15; - readySlot.Slot.Enabled = true; - readySlot.Slot.Unknown10 = 1000; - readySlot.Slot.TotalRefreshTime = 1000; - readySlot.Slot.Quantity = capturedCount; - readySlot.Slot.ForceDismount = true; - readySlot.Slot.Unknown15 = 1000; - connection.SendTunneled(readySlot); - } - catch { } - }); + connection.Player.StartActionBarCooldown(2, packet.Data.Slot, clientItemDefinition.Icon.Id, clientItemDefinition.NameId, clientItem.Count, cooldownMs); return true; } @@ -214,7 +139,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke if (_resourceManager.Consumables.Transformations.TryGetValue(clientItemDefinition.ActivatableAbilityId, out var transform)) { _logger.LogInformation("Transform match: modelId={ModelId} durationMs={Duration}", transform.ModelId, transform.DurationMs); - var playerCooldowns = _boomboxCooldowns.GetOrAdd(connection.Player.Guid, _ => new ConcurrentDictionary()); + var playerCooldowns = _itemCooldowns.GetOrAdd(connection.Player.Guid, _ => new ConcurrentDictionary()); if (playerCooldowns.TryGetValue(clientItemDefinition.Id, out var expiry) && DateTimeOffset.UtcNow < expiry) { @@ -235,7 +160,6 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke var capturedSlot = packet.Data.Slot; var capturedItemDef = clientItemDefinition; var capturedCount = clientItem.Count; - var capturedPlayerGuid = connection.Player.Guid; var cooldownMs = transform.CooldownMs; bool willHaveItemLeft = capturedCount > 1; @@ -244,88 +168,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke ConsumeItem(connection, clientItem, clientItemDefinition, capturedSlot); if (willHaveItemLeft) - { - var startTime = DateTimeOffset.UtcNow; - - var cooldownSlot = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = capturedSlot } }; - cooldownSlot.Slot.IsEmpty = false; - cooldownSlot.Slot.IconId = capturedItemDef.Icon.Id; - cooldownSlot.Slot.NameId = capturedItemDef.NameId; - cooldownSlot.Slot.Unknown5 = 1; - cooldownSlot.Slot.Unknown6 = 4; - cooldownSlot.Slot.Unknown7 = 15; - cooldownSlot.Slot.Enabled = false; - cooldownSlot.Slot.Unknown10 = 0; - cooldownSlot.Slot.TotalRefreshTime = cooldownMs; - cooldownSlot.Slot.Unknown12 = 0; - cooldownSlot.Slot.Quantity = capturedCount - 1; - cooldownSlot.Slot.ForceDismount = true; - cooldownSlot.Slot.Unknown15 = 0; - connection.SendTunneled(cooldownSlot); - - _ = Task.Run(async () => - { - try - { - while (true) - { - await Task.Delay(2000); - int elapsed = (int)(DateTimeOffset.UtcNow - startTime).TotalMilliseconds; - if (elapsed >= cooldownMs) break; - - var tickSlot = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = capturedSlot } }; - tickSlot.Slot.IsEmpty = false; - tickSlot.Slot.IconId = capturedItemDef.Icon.Id; - tickSlot.Slot.NameId = capturedItemDef.NameId; - tickSlot.Slot.Unknown5 = 1; - tickSlot.Slot.Unknown6 = 4; - tickSlot.Slot.Unknown7 = 15; - tickSlot.Slot.Enabled = false; - tickSlot.Slot.Unknown10 = elapsed; - tickSlot.Slot.TotalRefreshTime = cooldownMs; - tickSlot.Slot.Unknown12 = elapsed; - tickSlot.Slot.Quantity = capturedCount - 1; - tickSlot.Slot.ForceDismount = true; - tickSlot.Slot.Unknown15 = elapsed; - connection.SendTunneled(tickSlot); - } - } - catch { } - }); - - Task.Delay(cooldownMs).ContinueWith(_ => - { - try - { - if (_boomboxCooldowns.TryGetValue(capturedPlayerGuid, out var cd)) - cd.TryRemove(capturedItemDef.Id, out DateTimeOffset _); - - var readySlot = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = capturedSlot } }; - readySlot.Slot.IsEmpty = false; - readySlot.Slot.IconId = capturedItemDef.Icon.Id; - readySlot.Slot.NameId = capturedItemDef.NameId; - readySlot.Slot.Unknown5 = 1; - readySlot.Slot.Unknown6 = 4; - readySlot.Slot.Unknown7 = 15; - readySlot.Slot.Enabled = true; - readySlot.Slot.Unknown10 = 1000; - readySlot.Slot.TotalRefreshTime = 1000; - readySlot.Slot.Quantity = capturedCount - 1; - readySlot.Slot.ForceDismount = true; - readySlot.Slot.Unknown15 = 1000; - connection.SendTunneled(readySlot); - } - catch { } - }); - } - else - { - Task.Delay(cooldownMs).ContinueWith(_ => - { - if (_boomboxCooldowns.TryGetValue(capturedPlayerGuid, out var cd)) - cd.TryRemove(capturedItemDef.Id, out DateTimeOffset _); - }); - } + connection.Player.StartActionBarCooldown(2, capturedSlot, capturedItemDef.Icon.Id, capturedItemDef.NameId, capturedCount - 1, cooldownMs); return true; } @@ -483,7 +326,7 @@ private static void TriggerAbilityEffect(GatewayConnection connection, Packet.Co connection.SendTunneled(flameEffect); connection.Player.SendToVisible(flameEffect, false); - _logger.LogInformation($"Player {connection.Player.Name.FirstName} activated shadow flames"); + _logger.LogInformation("Player {Name} activated shadow flames", connection.Player.Name?.FirstName); } else { @@ -636,10 +479,10 @@ void SendEffect(int effectId) => Broadcast(new PlayerUpdatePacketPlayCompositeEf capturedNpc.Dispose(); } - catch { } + catch (Exception ex) { _logger.LogError(ex, "SpawnCakeNpc: error during NPC cleanup"); } }); } - catch { } + catch (Exception ex) { _logger.LogError(ex, "SpawnCakeNpc: error during NPC spawn"); } } private static void SpawnBossCakeNpc(GatewayConnection connection) @@ -726,10 +569,10 @@ private static void SpawnBossCakeNpc(GatewayConnection connection) capturedNpc.Dispose(); } - catch { } + catch (Exception ex) { _logger.LogError(ex, "SpawnBossCakeNpc: error during NPC cleanup"); } }); } - catch { } + catch (Exception ex) { _logger.LogError(ex, "SpawnBossCakeNpc: error during NPC spawn"); } } private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common.ClientItemDefinition itemDef) @@ -829,10 +672,10 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. capturedNpc.Dispose(); } - catch { } + catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: error during NPC cleanup"); } }); } - catch { } + catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: error during NPC spawn"); } } private static void StartBoomboxDancing(Game.Zones.StartingZone zone, Vector4 boomboxPosition, int[] danceSequence, int durationMs) @@ -887,11 +730,11 @@ private static void StartBoomboxDancing(Game.Zones.StartingZone zone, Vector4 bo visiblePlayer.SendTunneled(quickChatPacket); } } - catch { } + catch (Exception ex) { _logger.LogError(ex, "StartBoomboxDancing: error sending dance packet to player {Guid}", player.Guid); } } } } - catch { } + catch (Exception ex) { _logger.LogError(ex, "StartBoomboxDancing: unhandled error"); } }); } From 9fb77887d4fb5b7a93e9bfab3cfa2042f84be7e3 Mon Sep 17 00:00:00 2001 From: JadenY Date: Mon, 29 Jun 2026 03:11:03 -0500 Subject: [PATCH 14/22] Add transform composite effects and reorganize Consumables.jsonc - TransformAbilityDefinition gains CompositeEffectId; effect 21 (smoke poof) fires on both ApplyTemporaryAppearance and RemoveTemporaryAppearance - CakeItemDefinition gains TransformAbilityIds; SpawnBossCakeNpc reads the array from the definition instead of hardcoding [4370-4373] - Rename Consumables.json -> Consumables.jsonc and enable JsonCommentHandling.Skip so // comments are valid in the file - All Boombox, Cake, and FoodEffect entries labeled with // item names; Boss Cake transforms carry their names too --- src/Resources/Consumables.json | 119 ----------------- src/Resources/Consumables.jsonc | 121 ++++++++++++++++++ src/Sanctuary.Game/Entities/Player.cs | 24 ++-- src/Sanctuary.Game/ResourceManager.cs | 2 +- .../Resources/ConsumableCollection.cs | 3 +- .../Definitions/CakeItemDefinition.cs | 1 + .../Definitions/TransformAbilityDefinition.cs | 1 + ...yPacketClientRequestStartAbilityHandler.cs | 20 +-- 8 files changed, 148 insertions(+), 143 deletions(-) delete mode 100644 src/Resources/Consumables.json create mode 100644 src/Resources/Consumables.jsonc diff --git a/src/Resources/Consumables.json b/src/Resources/Consumables.json deleted file mode 100644 index 783382c..0000000 --- a/src/Resources/Consumables.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "Boomboxes": [ - { "ItemId": 878, "ModelId": 1936, "EffectId": 16379, "DanceSequence": [3531, 3532, 3533, 3534] }, - { "ItemId": 76641, "ModelId": 1936, "EffectId": 16379, "DanceSequence": [3531, 3532, 3533, 3534] }, - { "ItemId": 1180, "ModelId": 1973, "EffectId": 0, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, - { "ItemId": 1913, "ModelId": 1979, "EffectId": 16452, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, - { "ItemId": 8185, "ModelId": 2217, "EffectId": 0, "DanceSequence": [3551, 3552, 3553, 3554] }, - { "ItemId": 35759, "ModelId": 4012, "EffectId": 17042, "DanceSequence": [3568004, 3569004, 3570004, 3571004] }, - { "ItemId": 48244, "ModelId": 3893, "EffectId": 0, "DanceSequence": [3568005, 3569005, 3570005, 3571005] }, - { "ItemId": 56774, "ModelId": 1661, "EffectId": 0, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, - { "ItemId": 69825, "ModelId": 1062, "EffectId": 0, "DanceSequence": [3511, 3512, 3513, 3514, 3515] }, - { "ItemId": 76927, "ModelId": 4076, "EffectId": 16452, "DanceSequence": [3568010, 3569010, 3570010, 3571010] }, - { "ItemId": 76928, "ModelId": 2201, "EffectId": 16451, "DanceSequence": [3568006, 3569006, 3570006, 3571006] }, - { "ItemId": 76930, "ModelId": 4096, "EffectId": 16453, "DanceSequence": [3568001, 3569001, 3570001, 3571001] }, - { "ItemId": 76933, "ModelId": 4099, "EffectId": 0, "DanceSequence": [3568008, 3569008, 3570008, 3571008] }, - { "ItemId": 76935, "ModelId": 4428, "EffectId": 16928, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, - { "ItemId": 77329, "ModelId": 2059, "EffectId": 16466, "DanceSequence": [3560, 3561, 3562, 3563] }, - { "ItemId": 76934, "ModelId": 4100, "EffectId": 16827, "DanceSequence": [3568009, 3569009, 3570009, 3571009] } - ], - - "Cakes": [ - { "ItemId": 17597, "Type": "ScaredyCake", "CooldownMs": 60000 }, - { "ItemId": 69819, "Type": "ScaredyCake", "CooldownMs": 60000 }, - { "ItemId": 69825, "Type": "BossCake", "CooldownMs": 30000 }, - { "ItemId": 69828, "Type": "BossCake", "CooldownMs": 30000 }, - { "ItemId": 69839, "Type": "BossCake", "CooldownMs": 30000 } - ], - - "FoodEffects": [ - { "AbilityId": 1956, "CompositeEffectId": 47 }, - { "AbilityId": 1957, "CompositeEffectId": 5032 }, - { "AbilityId": 1958, "CompositeEffectId": 1 }, - { "AbilityId": 1960, "CompositeEffectId": 5 }, - { "AbilityId": 1961, "CompositeEffectId": 5102 }, - { "AbilityId": 1962, "CompositeEffectId": 174 }, - { "AbilityId": 1963, "CompositeEffectId": 1115 }, - { "AbilityId": 1965, "CompositeEffectId": 5298 }, - { "AbilityId": 1966, "CompositeEffectId": 12 }, - { "AbilityId": 1967, "CompositeEffectId": 5370 }, - { "AbilityId": 1968, "CompositeEffectId": 5732 }, - { "AbilityId": 1969, "CompositeEffectId": 1107 }, - { "AbilityId": 1970, "CompositeEffectId": 630 }, - { "AbilityId": 1973, "CompositeEffectId": 15173 }, - { "AbilityId": 2004, "CompositeEffectId": 4013 } - ], - - "Transformations": [ - { "AbilityId": 3042, "ModelId": 169, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3043, "ModelId": 170, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3044, "ModelId": 171, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3045, "ModelId": 172, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3046, "ModelId": 173, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3047, "ModelId": 364, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3056, "ModelId": 348, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3016, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3011, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3012, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3013, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3014, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3015, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 1990, "ModelId": 176, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 2031, "ModelId": 176, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 1998, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3027, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3028, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3029, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3030, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3031, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3032, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3033, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3034, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3035, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3036, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3037, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 1987, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3005, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3017, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3018, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3019, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3020, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3021, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3022, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3023, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3024, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3025, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 1986, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 1994, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3060, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3061, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3062, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3063, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3064, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3065, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3066, "ModelId": 142, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3057, "ModelId": 142, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3067, "ModelId": 142, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3059, "ModelId": 52, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3071, "ModelId": 71, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3072, "ModelId": 71, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3058, "ModelId": 180, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3026, "ModelId": 65, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3001, "ModelId": 70, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3002, "ModelId": 160, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 1979, "ModelId": 136, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3051, "ModelId": 136, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 1983, "ModelId": 139, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3052, "ModelId": 138, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3053, "ModelId": 139, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3055, "ModelId": 188, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 1997, "ModelId": 130, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3038, "ModelId": 130, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 286, "ModelId": 22, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 3070, "ModelId": 177, "DurationMs": 10000, "CooldownMs": 10000 }, - { "AbilityId": 4370, "ModelId": 2055, "DurationMs": 30000, "CooldownMs": 10000 }, - { "AbilityId": 4371, "ModelId": 1944, "DurationMs": 30000, "CooldownMs": 10000 }, - { "AbilityId": 4372, "ModelId": 1702, "DurationMs": 30000, "CooldownMs": 10000 }, - { "AbilityId": 4373, "ModelId": 22, "DurationMs": 30000, "CooldownMs": 10000 } - ] -} diff --git a/src/Resources/Consumables.jsonc b/src/Resources/Consumables.jsonc new file mode 100644 index 0000000..d1067b1 --- /dev/null +++ b/src/Resources/Consumables.jsonc @@ -0,0 +1,121 @@ +{ + "Boomboxes": [ + { "ItemId": 878, "ModelId": 1936, "EffectId": 16379, "DanceSequence": [3531, 3532, 3533, 3534] }, // Jingle Bell Boombox + { "ItemId": 76641, "ModelId": 1936, "EffectId": 16379, "DanceSequence": [3531, 3532, 3533, 3534] }, // Jingle Bell Boombox + { "ItemId": 1180, "ModelId": 1973, "EffectId": 0, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, // Realms Roll Boombox + { "ItemId": 1913, "ModelId": 1979, "EffectId": 16452, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, // Rainbow Randomizer Boombox + { "ItemId": 8185, "ModelId": 2217, "EffectId": 0, "DanceSequence": [3551, 3552, 3553, 3554] }, // Totem Boombox + { "ItemId": 35759, "ModelId": 4012, "EffectId": 17042, "DanceSequence": [3568004, 3569004, 3570004, 3571004] }, // Clockwork Boombox + { "ItemId": 48244, "ModelId": 3893, "EffectId": 0, "DanceSequence": [3568005, 3569005, 3570005, 3571005] }, // Boombox XVD-S + { "ItemId": 56774, "ModelId": 1661, "EffectId": 0, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, // Hip-Hop Boombox + { "ItemId": 69825, "ModelId": 1062, "EffectId": 0, "DanceSequence": [3511, 3512, 3513, 3514, 3515] }, // Chiller Boombox + { "ItemId": 76927, "ModelId": 4076, "EffectId": 16452, "DanceSequence": [3568010, 3569010, 3570010, 3571010] }, // Pop-Dance Boombox + { "ItemId": 76928, "ModelId": 2201, "EffectId": 16451, "DanceSequence": [3568006, 3569006, 3570006, 3571006] }, // Tiki Boombox + { "ItemId": 76930, "ModelId": 4096, "EffectId": 16453, "DanceSequence": [3568001, 3569001, 3570001, 3571001] }, // Big Band Boombox + { "ItemId": 76933, "ModelId": 4099, "EffectId": 0, "DanceSequence": [3568008, 3569008, 3570008, 3571008] }, // Hootenanny Boombox + { "ItemId": 76935, "ModelId": 4428, "EffectId": 16928, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, // Realmshake Boombox + { "ItemId": 77329, "ModelId": 2059, "EffectId": 16466, "DanceSequence": [3560, 3561, 3562, 3563] }, // Lovelectric Boombox + { "ItemId": 76934, "ModelId": 4100, "EffectId": 16827, "DanceSequence": [3568009, 3569009, 3570009, 3571009] } // Xylophone + ], + + "Cakes": [ + { "ItemId": 17597, "Type": "ScaredyCake", "CooldownMs": 60000 }, // Scaredy Cake + { "ItemId": 69819, "Type": "ScaredyCake", "CooldownMs": 60000 }, // Scaredy Cake + + // Boss Cake — spawns a cake NPC that randomly applies one of the TransformAbilityIds to the interacting player + { "ItemId": 69825, "Type": "BossCake", "CooldownMs": 30000, "TransformAbilityIds": [4370, 4371, 4372, 4373] }, // Chiller Boombox + { "ItemId": 69828, "Type": "BossCake", "CooldownMs": 30000, "TransformAbilityIds": [4370, 4371, 4372, 4373] }, // Jack-O-Lantern + { "ItemId": 69839, "Type": "BossCake", "CooldownMs": 30000, "TransformAbilityIds": [4370, 4371, 4372, 4373] } // Bat Cupcake + ], + + "FoodEffects": [ + { "AbilityId": 1956, "CompositeEffectId": 47 }, // Electric Veal Meat + { "AbilityId": 1957, "CompositeEffectId": 5032 }, // Rocking Road Mix + { "AbilityId": 1958, "CompositeEffectId": 1 }, // Fiery Fury Candy + { "AbilityId": 1960, "CompositeEffectId": 5 }, // Mr. Gleam's Granola Bar + { "AbilityId": 1961, "CompositeEffectId": 5102 }, // Silver Surf 'n Turf + { "AbilityId": 1962, "CompositeEffectId": 174 }, // Deep Mines Delight + { "AbilityId": 1963, "CompositeEffectId": 1115 }, // Roaring Rapids Roast + { "AbilityId": 1965, "CompositeEffectId": 5298 }, // Noxious Pork Chops + { "AbilityId": 1966, "CompositeEffectId": 12 }, // Firefrog Legs + { "AbilityId": 1967, "CompositeEffectId": 5370 }, // Hot Sauce Buns + { "AbilityId": 1968, "CompositeEffectId": 5732 }, // Arbor Buttercake + { "AbilityId": 1969, "CompositeEffectId": 1107 }, // Pixiedust Pie + { "AbilityId": 1970, "CompositeEffectId": 630 }, // Frosty Flakes + { "AbilityId": 1973, "CompositeEffectId": 15173 }, // Scrumptious Soup + { "AbilityId": 2004, "CompositeEffectId": 4013 } // Steaming Pretzels + ], + + "Transformations": [ + { "AbilityId": 3042, "ModelId": 169, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3043, "ModelId": 170, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3044, "ModelId": 171, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3045, "ModelId": 172, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3046, "ModelId": 173, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3047, "ModelId": 364, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3056, "ModelId": 348, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3016, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3011, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3012, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3013, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3014, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3015, "ModelId": 50, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 1990, "ModelId": 176, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 2031, "ModelId": 176, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 1998, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3027, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3028, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3029, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3030, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3031, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3032, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3033, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3034, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3035, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3036, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3037, "ModelId": 175, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 1987, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3005, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3017, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3018, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3019, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3020, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3021, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3022, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3023, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3024, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3025, "ModelId": 28, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 1986, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 1994, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3060, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3061, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3062, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3063, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3064, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3065, "ModelId": 77, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3066, "ModelId": 142, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3057, "ModelId": 142, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3067, "ModelId": 142, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3059, "ModelId": 52, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3071, "ModelId": 71, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3072, "ModelId": 71, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3058, "ModelId": 180, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3026, "ModelId": 65, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3001, "ModelId": 70, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3002, "ModelId": 160, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 1979, "ModelId": 136, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3051, "ModelId": 136, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 1983, "ModelId": 139, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3052, "ModelId": 138, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3053, "ModelId": 139, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3055, "ModelId": 188, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 1997, "ModelId": 130, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3038, "ModelId": 130, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 286, "ModelId": 22, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 3070, "ModelId": 177, "DurationMs": 10000, "CooldownMs": 10000, "CompositeEffectId": 21 }, + { "AbilityId": 4370, "ModelId": 2055, "DurationMs": 30000, "CooldownMs": 10000, "CompositeEffectId": 21 }, // Boss Cake - Stone Heart + { "AbilityId": 4371, "ModelId": 1944, "DurationMs": 30000, "CooldownMs": 10000, "CompositeEffectId": 21 }, // Boss Cake - Abominable Snowman + { "AbilityId": 4372, "ModelId": 1702, "DurationMs": 30000, "CooldownMs": 10000, "CompositeEffectId": 21 }, // Boss Cake - Pumpkin Prince + { "AbilityId": 4373, "ModelId": 22, "DurationMs": 30000, "CooldownMs": 10000, "CompositeEffectId": 21 } // Boss Cake - Cakenstein + ] +} diff --git a/src/Sanctuary.Game/Entities/Player.cs b/src/Sanctuary.Game/Entities/Player.cs index fc5687d..c35bd53 100644 --- a/src/Sanctuary.Game/Entities/Player.cs +++ b/src/Sanctuary.Game/Entities/Player.cs @@ -56,6 +56,7 @@ public sealed class Player : ClientPcData, IEntity public int TemporaryAppearance { get; set; } public DateTimeOffset? TemporaryAppearanceExpiresAt { get; set; } + private int _temporaryAppearanceEffectId; private record PendingCooldown(int ActionBarId, int SlotIndex, int IconId, int NameId, int Count, int CooldownMs, DateTimeOffset StartedAt); private readonly ConcurrentDictionary<(int, int), PendingCooldown> _pendingCooldowns = new(); @@ -569,22 +570,18 @@ public PlayerUpdatePacketAddPc GetAddPcPacket() } - public void ApplyTemporaryAppearance(int modelId, int durationMs) + public void ApplyTemporaryAppearance(int modelId, int durationMs, int effectId = 0) { TemporaryAppearance = modelId; + _temporaryAppearanceEffectId = effectId; if (durationMs > 0) - { TemporaryAppearanceExpiresAt = DateTimeOffset.UtcNow.AddMilliseconds(durationMs); - } - var appearancePacket = new PlayerUpdatePacketUpdateTemporaryAppearance - { - Guid = Guid, - TemporaryAppearance = modelId - }; + if (effectId != 0) + SendTunneledToVisible(new PlayerUpdatePacketPlayCompositeEffect { Guid = Guid, CompositeEffectId = effectId, Clear = true }, true); - SendTunneledToVisible(appearancePacket, true); + SendTunneledToVisible(new PlayerUpdatePacketUpdateTemporaryAppearance { Guid = Guid, TemporaryAppearance = modelId }, true); } public void RemoveTemporaryAppearance() @@ -592,12 +589,13 @@ public void RemoveTemporaryAppearance() TemporaryAppearance = 0; TemporaryAppearanceExpiresAt = null; - var removePacket = new PlayerUpdatePacketRemoveTemporaryAppearance + if (_temporaryAppearanceEffectId != 0) { - Guid = Guid - }; + SendTunneledToVisible(new PlayerUpdatePacketPlayCompositeEffect { Guid = Guid, CompositeEffectId = _temporaryAppearanceEffectId, Clear = true }, true); + _temporaryAppearanceEffectId = 0; + } - SendTunneledToVisible(removePacket, true); + SendTunneledToVisible(new PlayerUpdatePacketRemoveTemporaryAppearance { Guid = Guid }, true); } #region Equatable diff --git a/src/Sanctuary.Game/ResourceManager.cs b/src/Sanctuary.Game/ResourceManager.cs index 3e5a38b..b76e134 100644 --- a/src/Sanctuary.Game/ResourceManager.cs +++ b/src/Sanctuary.Game/ResourceManager.cs @@ -42,7 +42,7 @@ public class ResourceManager : IResourceManager public static readonly string QuickChatsFile = Path.Combine(BaseDirectory, "QuickChats.json"); public static readonly string PlayerTitlesFile = Path.Combine(BaseDirectory, "PlayerTitles.json"); public static readonly string PointOfInterestsFile = Path.Combine(BaseDirectory, "PointOfInterests.json"); - public static readonly string ConsumablesFile = Path.Combine(BaseDirectory, "Consumables.json"); + public static readonly string ConsumablesFile = Path.Combine(BaseDirectory, "Consumables.jsonc"); public IdToStringLookup HairMappings { get; } public IdToStringLookup HeadMappings { get; } diff --git a/src/Sanctuary.Game/Resources/ConsumableCollection.cs b/src/Sanctuary.Game/Resources/ConsumableCollection.cs index 8d45a89..b8a37e7 100644 --- a/src/Sanctuary.Game/Resources/ConsumableCollection.cs +++ b/src/Sanctuary.Game/Resources/ConsumableCollection.cs @@ -37,7 +37,8 @@ public bool Load(string filePath) var jsonSerializerOptions = new JsonSerializerOptions { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip }; var consumables = JsonSerializer.Deserialize(fileStream, jsonSerializerOptions); diff --git a/src/Sanctuary.Game/Resources/Definitions/CakeItemDefinition.cs b/src/Sanctuary.Game/Resources/Definitions/CakeItemDefinition.cs index beac15a..2415d7a 100644 --- a/src/Sanctuary.Game/Resources/Definitions/CakeItemDefinition.cs +++ b/src/Sanctuary.Game/Resources/Definitions/CakeItemDefinition.cs @@ -5,6 +5,7 @@ public class CakeItemDefinition public int ItemId { get; set; } public CakeItemType Type { get; set; } public int CooldownMs { get; set; } + public int[] TransformAbilityIds { get; set; } = []; } public enum CakeItemType diff --git a/src/Sanctuary.Game/Resources/Definitions/TransformAbilityDefinition.cs b/src/Sanctuary.Game/Resources/Definitions/TransformAbilityDefinition.cs index 50e9f66..511f32f 100644 --- a/src/Sanctuary.Game/Resources/Definitions/TransformAbilityDefinition.cs +++ b/src/Sanctuary.Game/Resources/Definitions/TransformAbilityDefinition.cs @@ -6,4 +6,5 @@ public class TransformAbilityDefinition public int ModelId { get; set; } public int DurationMs { get; set; } public int CooldownMs { get; set; } + public int CompositeEffectId { get; set; } } diff --git a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs index 7d7e364..a158854 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs @@ -120,7 +120,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke } if (isBossCake) - SpawnBossCakeNpc(connection); + SpawnBossCakeNpc(connection, cakeDef!); else if (isScaredyCake) SpawnCakeNpc(connection); else @@ -153,7 +153,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke return true; } - ApplyTransform(connection, transform.ModelId, transform.DurationMs); + ApplyTransform(connection, transform.ModelId, transform.DurationMs, transform.CompositeEffectId); playerCooldowns[clientItemDefinition.Id] = DateTimeOffset.UtcNow.AddMilliseconds(transform.CooldownMs); @@ -485,7 +485,7 @@ void SendEffect(int effectId) => Broadcast(new PlayerUpdatePacketPlayCompositeEf catch (Exception ex) { _logger.LogError(ex, "SpawnCakeNpc: error during NPC spawn"); } } - private static void SpawnBossCakeNpc(GatewayConnection connection) + private static void SpawnBossCakeNpc(GatewayConnection connection, CakeItemDefinition cakeDef) { try { @@ -518,14 +518,16 @@ private static void SpawnBossCakeNpc(GatewayConnection connection) cakeNpc.Visible = true; cakeNpc.UpdatePosition(spawnPosition, connection.Player.Rotation); - int[] bossAbilities = [4370, 4371, 4372, 4373]; + int[] bossAbilities = cakeDef.TransformAbilityIds.Length > 0 + ? cakeDef.TransformAbilityIds + : [4370, 4371, 4372, 4373]; cakeNpc.InteractAction = (interactingPlayer) => { int abilityId = bossAbilities[Random.Shared.Next(bossAbilities.Length)]; if (_resourceManager.Consumables.Transformations.TryGetValue(abilityId, out var transform)) - ApplyTransform(interactingPlayer, transform.ModelId, transform.DurationMs); + ApplyTransform(interactingPlayer, transform.ModelId, transform.DurationMs, transform.CompositeEffectId); }; var poofEffect = new PlayerUpdatePacketPlayCompositeEffect @@ -748,11 +750,11 @@ private static void SendFailure(GatewayConnection connection, int stringId) connection.SendTunneled(abilityPacketFailed); } - internal static void ApplyTransform(GatewayConnection connection, int temporaryAppearance, int durationMs) - => connection.Player.ApplyTemporaryAppearance(temporaryAppearance, durationMs); + internal static void ApplyTransform(GatewayConnection connection, int temporaryAppearance, int durationMs, int effectId = 0) + => connection.Player.ApplyTemporaryAppearance(temporaryAppearance, durationMs, effectId); - internal static void ApplyTransform(Game.Entities.Player player, int temporaryAppearance, int durationMs) - => player.ApplyTemporaryAppearance(temporaryAppearance, durationMs); + internal static void ApplyTransform(Game.Entities.Player player, int temporaryAppearance, int durationMs, int effectId = 0) + => player.ApplyTemporaryAppearance(temporaryAppearance, durationMs, effectId); internal static void RemoveTransform(GatewayConnection connection) => connection.Player.RemoveTemporaryAppearance(); From 0e16f90cdb4841a845bd9e40df113c0f7f8ff481 Mon Sep 17 00:00:00 2001 From: JadenY Date: Mon, 29 Jun 2026 03:16:15 -0500 Subject: [PATCH 15/22] Fix transform composite effect position Effect 21 (PFX_smoke_black_explosion) is a world-space effect and must have Position set to play at the player's location. Sending it without Position defaulted to Vector4.Zero, which rendered off-screen. --- src/Sanctuary.Game/Entities/Player.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Sanctuary.Game/Entities/Player.cs b/src/Sanctuary.Game/Entities/Player.cs index c35bd53..821bf15 100644 --- a/src/Sanctuary.Game/Entities/Player.cs +++ b/src/Sanctuary.Game/Entities/Player.cs @@ -579,7 +579,7 @@ public void ApplyTemporaryAppearance(int modelId, int durationMs, int effectId = TemporaryAppearanceExpiresAt = DateTimeOffset.UtcNow.AddMilliseconds(durationMs); if (effectId != 0) - SendTunneledToVisible(new PlayerUpdatePacketPlayCompositeEffect { Guid = Guid, CompositeEffectId = effectId, Clear = true }, true); + SendTunneledToVisible(new PlayerUpdatePacketPlayCompositeEffect { Guid = Guid, CompositeEffectId = effectId, Position = Position, Clear = false }, true); SendTunneledToVisible(new PlayerUpdatePacketUpdateTemporaryAppearance { Guid = Guid, TemporaryAppearance = modelId }, true); } @@ -591,7 +591,7 @@ public void RemoveTemporaryAppearance() if (_temporaryAppearanceEffectId != 0) { - SendTunneledToVisible(new PlayerUpdatePacketPlayCompositeEffect { Guid = Guid, CompositeEffectId = _temporaryAppearanceEffectId, Clear = true }, true); + SendTunneledToVisible(new PlayerUpdatePacketPlayCompositeEffect { Guid = Guid, CompositeEffectId = _temporaryAppearanceEffectId, Position = Position, Clear = false }, true); _temporaryAppearanceEffectId = 0; } From d70ebf193276a99a752daa6335b7a7c1878160b9 Mon Sep 17 00:00:00 2001 From: JadenY Date: Mon, 29 Jun 2026 09:33:55 -0500 Subject: [PATCH 16/22] Make consumable handlers fully data-driven, remove all hardcoded IDs CakeItemDefinition gains ModelId, NameId, CursorId, Animation, SpawnPoofEffectId, LifetimeMs, ScareGroups, and ScareCooldownMs so SpawnCakeNpc has zero hardcoded values. FoodEffectDefinition gains QuickChatId and EffectDelayMs, moving Can of Beans into Consumables.jsonc and eliminating the last hardcoded ability branch in TriggerAbilityEffect. --- src/Resources/Consumables.jsonc | 31 +- .../Definitions/CakeItemDefinition.cs | 14 +- .../Definitions/FoodEffectDefinition.cs | 2 + ...yPacketClientRequestStartAbilityHandler.cs | 497 +++++------------- 4 files changed, 179 insertions(+), 365 deletions(-) diff --git a/src/Resources/Consumables.jsonc b/src/Resources/Consumables.jsonc index d1067b1..9ef06a2 100644 --- a/src/Resources/Consumables.jsonc +++ b/src/Resources/Consumables.jsonc @@ -19,13 +19,30 @@ ], "Cakes": [ - { "ItemId": 17597, "Type": "ScaredyCake", "CooldownMs": 60000 }, // Scaredy Cake - { "ItemId": 69819, "Type": "ScaredyCake", "CooldownMs": 60000 }, // Scaredy Cake + // Scaredy Cake — spawns a cake NPC that plays a random scare effect on interact + { + "ItemId": 17597, "Type": "ScaredyCake", "CooldownMs": 60000, + "ModelId": 1724, "NameId": 16634, "SpawnPoofEffectId": 21, "LifetimeMs": 60000, + "ScareGroups": [ + [5455, 5165, 15923], // Bats: EFX_bats_exp_flyaway, SFX_OS_BatChirps, PFX_halloween_icing_splat_cog + [15909, 15960, 15961], // Ghost: PFX_halloween_ghosts_up_loop, SFX_Halloween_GhostCrys, SFX_Halloween_GhostMoans + [15744, 5118, 15959] // Raven: EFX_birds_pink_med_flyaway, SFX_BirdCrowCaw, SFX_Halloween_Crows + ] + }, // Scaredy Cake + { + "ItemId": 69819, "Type": "ScaredyCake", "CooldownMs": 60000, + "ModelId": 1724, "NameId": 16634, "SpawnPoofEffectId": 21, "LifetimeMs": 60000, + "ScareGroups": [ + [5455, 5165, 15923], + [15909, 15960, 15961], + [15744, 5118, 15959] + ] + }, // Scaredy Cake // Boss Cake — spawns a cake NPC that randomly applies one of the TransformAbilityIds to the interacting player - { "ItemId": 69825, "Type": "BossCake", "CooldownMs": 30000, "TransformAbilityIds": [4370, 4371, 4372, 4373] }, // Chiller Boombox - { "ItemId": 69828, "Type": "BossCake", "CooldownMs": 30000, "TransformAbilityIds": [4370, 4371, 4372, 4373] }, // Jack-O-Lantern - { "ItemId": 69839, "Type": "BossCake", "CooldownMs": 30000, "TransformAbilityIds": [4370, 4371, 4372, 4373] } // Bat Cupcake + { "ItemId": 69825, "Type": "BossCake", "CooldownMs": 30000, "ModelId": 1724, "NameId": 16635, "SpawnPoofEffectId": 21, "LifetimeMs": 60000, "TransformAbilityIds": [4370, 4371, 4372, 4373] }, // Chiller Boombox + { "ItemId": 69828, "Type": "BossCake", "CooldownMs": 30000, "ModelId": 1724, "NameId": 16635, "SpawnPoofEffectId": 21, "LifetimeMs": 60000, "TransformAbilityIds": [4370, 4371, 4372, 4373] }, // Jack-O-Lantern + { "ItemId": 69839, "Type": "BossCake", "CooldownMs": 30000, "ModelId": 1724, "NameId": 16635, "SpawnPoofEffectId": 21, "LifetimeMs": 60000, "TransformAbilityIds": [4370, 4371, 4372, 4373] } // Bat Cupcake ], "FoodEffects": [ @@ -43,7 +60,9 @@ { "AbilityId": 1969, "CompositeEffectId": 1107 }, // Pixiedust Pie { "AbilityId": 1970, "CompositeEffectId": 630 }, // Frosty Flakes { "AbilityId": 1973, "CompositeEffectId": 15173 }, // Scrumptious Soup - { "AbilityId": 2004, "CompositeEffectId": 4013 } // Steaming Pretzels + { "AbilityId": 2004, "CompositeEffectId": 4013 }, // Steaming Pretzels + { "AbilityId": 1964, "CompositeEffectId": 5265 }, // Graveyard Flambe (shadow flames) + { "AbilityId": 660, "CompositeEffectId": 5343, "QuickChatId": 3340, "EffectDelayMs": 300 } // Can of Beans ], "Transformations": [ diff --git a/src/Sanctuary.Game/Resources/Definitions/CakeItemDefinition.cs b/src/Sanctuary.Game/Resources/Definitions/CakeItemDefinition.cs index 2415d7a..5462ecb 100644 --- a/src/Sanctuary.Game/Resources/Definitions/CakeItemDefinition.cs +++ b/src/Sanctuary.Game/Resources/Definitions/CakeItemDefinition.cs @@ -5,6 +5,18 @@ public class CakeItemDefinition public int ItemId { get; set; } public CakeItemType Type { get; set; } public int CooldownMs { get; set; } + public int ModelId { get; set; } + public int NameId { get; set; } + public int CursorId { get; set; } = 5; + public int Animation { get; set; } = 1; + public int SpawnPoofEffectId { get; set; } = 21; + public int LifetimeMs { get; set; } = 60000; + + // ScaredyCake + public int[][] ScareGroups { get; set; } = []; + public int ScareCooldownMs { get; set; } = 2000; + + // BossCake public int[] TransformAbilityIds { get; set; } = []; } @@ -12,4 +24,4 @@ public enum CakeItemType { ScaredyCake, BossCake -} \ No newline at end of file +} diff --git a/src/Sanctuary.Game/Resources/Definitions/FoodEffectDefinition.cs b/src/Sanctuary.Game/Resources/Definitions/FoodEffectDefinition.cs index 2cd4f98..33dd180 100644 --- a/src/Sanctuary.Game/Resources/Definitions/FoodEffectDefinition.cs +++ b/src/Sanctuary.Game/Resources/Definitions/FoodEffectDefinition.cs @@ -4,4 +4,6 @@ public class FoodEffectDefinition { public int AbilityId { get; set; } public int CompositeEffectId { get; set; } + public int QuickChatId { get; set; } + public int EffectDelayMs { get; set; } } diff --git a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs index a158854..b41a367 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Linq; using System.Numerics; using System.Threading.Tasks; @@ -48,17 +47,9 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan _logger.LogInformation("AbilityPacket: Id={Id} Slot={Slot}", packet.Data.Id, packet.Data.Slot); if (packet.Data.Id == 2) - { return HandleItemAbility(connection, packet); - } - - var abilityPacketFailed = new AbilityPacketFailed - { - StringId = 3079 - }; - - connection.SendTunneled(abilityPacketFailed); + connection.SendTunneled(new AbilityPacketFailed { StringId = 3079 }); return true; } @@ -66,12 +57,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke { connection.Player.ActionBars.TryGetValue(2, out var actionBar); - Packet.Common.ActionBarSlot? slot = null; - if (actionBar != null && actionBar.Slots.TryGetValue(packet.Data.Slot, out var foundSlot) && !foundSlot.IsEmpty) - { - slot = foundSlot; - } - else + if (actionBar is null || !actionBar.Slots.TryGetValue(packet.Data.Slot, out var foundSlot) || foundSlot.IsEmpty) { SendFailure(connection, 3079); return true; @@ -85,7 +71,6 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke } var clientItem = connection.Player.Items.FirstOrDefault(x => x.Id == itemGuid); - if (clientItem is null) { SendFailure(connection, 3079); @@ -106,8 +91,6 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke bool isBoombox = _resourceManager.Consumables.Boomboxes.ContainsKey(clientItemDefinition.Id); bool isCake = _resourceManager.Consumables.Cakes.TryGetValue(clientItemDefinition.Id, out var cakeDef); - bool isScaredyCake = isCake && cakeDef!.Type == CakeItemType.ScaredyCake; - bool isBossCake = isCake && cakeDef!.Type == CakeItemType.BossCake; if (isCake || isBoombox) { @@ -119,16 +102,13 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke return true; } - if (isBossCake) - SpawnBossCakeNpc(connection, cakeDef!); - else if (isScaredyCake) - SpawnCakeNpc(connection); + if (isCake) + SpawnCakeNpc(connection, cakeDef!); else SpawnBoomboxNpc(connection, clientItemDefinition); int cooldownMs = isCake ? cakeDef!.CooldownMs : 60_000; playerCooldowns[clientItemDefinition.Id] = DateTimeOffset.UtcNow.AddMilliseconds(cooldownMs); - connection.Player.StartActionBarCooldown(2, packet.Data.Slot, clientItemDefinition.Icon.Id, clientItemDefinition.NameId, clientItem.Count, cooldownMs); return true; @@ -139,6 +119,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke if (_resourceManager.Consumables.Transformations.TryGetValue(clientItemDefinition.ActivatableAbilityId, out var transform)) { _logger.LogInformation("Transform match: modelId={ModelId} durationMs={Duration}", transform.ModelId, transform.DurationMs); + var playerCooldowns = _itemCooldowns.GetOrAdd(connection.Player.Guid, _ => new ConcurrentDictionary()); if (playerCooldowns.TryGetValue(clientItemDefinition.Id, out var expiry) && DateTimeOffset.UtcNow < expiry) @@ -200,9 +181,7 @@ private static bool ConsumeItem(GatewayConnection connection, Packet.Common.Clie var shouldDeleteItem = dbItem.Count <= 0; if (shouldDeleteItem) - { dbContext.Items.Remove(dbItem); - } if (dbContext.SaveChanges() <= 0) { @@ -213,70 +192,44 @@ private static bool ConsumeItem(GatewayConnection connection, Packet.Common.Clie if (shouldDeleteItem) { connection.Player.Items.Remove(clientItem); + connection.SendTunneled(new ClientUpdatePacketItemDelete { ItemGuid = clientItem.Id }); - var clientUpdatePacketItemDelete = new ClientUpdatePacketItemDelete - { - ItemGuid = clientItem.Id - }; - - connection.SendTunneled(clientUpdatePacketItemDelete); - - var clientUpdatePacketUpdateActionBarSlot = new ClientUpdatePacketUpdateActionBarSlot - { - Data = - { - Id = 2, - Slot = actionBarSlot - } - }; - - clientUpdatePacketUpdateActionBarSlot.Slot.IsEmpty = true; + var slotPacket = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = actionBarSlot } }; + slotPacket.Slot.IsEmpty = true; if (connection.Player.ActionBarItemGuids.TryGetValue(2, out var trackedItems)) - { trackedItems.Remove(actionBarSlot); - } - connection.SendTunneled(clientUpdatePacketUpdateActionBarSlot); + connection.SendTunneled(slotPacket); } else { clientItem.Count--; - var clientUpdatePacketItemUpdate = new ClientUpdatePacketItemUpdate + connection.SendTunneled(new ClientUpdatePacketItemUpdate { ItemGuid = clientItem.Id, Count = clientItem.Count, ConsumedCount = clientItem.ConsumedCount, AbilityCount = clientItem.AbilityCount, RentalExpirationTime = 0 - }; - - connection.SendTunneled(clientUpdatePacketItemUpdate); - - var clientUpdatePacketUpdateActionBarSlot = new ClientUpdatePacketUpdateActionBarSlot - { - Data = - { - Id = 2, - Slot = actionBarSlot - } - }; + }); - clientUpdatePacketUpdateActionBarSlot.Slot.IsEmpty = false; - clientUpdatePacketUpdateActionBarSlot.Slot.IconId = clientItemDefinition.Icon.Id; - clientUpdatePacketUpdateActionBarSlot.Slot.NameId = clientItemDefinition.NameId; - clientUpdatePacketUpdateActionBarSlot.Slot.Unknown5 = 1; - clientUpdatePacketUpdateActionBarSlot.Slot.Unknown6 = 4; - clientUpdatePacketUpdateActionBarSlot.Slot.Unknown7 = 15; - clientUpdatePacketUpdateActionBarSlot.Slot.Enabled = true; - clientUpdatePacketUpdateActionBarSlot.Slot.Unknown10 = 1000; - clientUpdatePacketUpdateActionBarSlot.Slot.TotalRefreshTime = 1000; - clientUpdatePacketUpdateActionBarSlot.Slot.Quantity = clientItem.Count; - clientUpdatePacketUpdateActionBarSlot.Slot.ForceDismount = true; - clientUpdatePacketUpdateActionBarSlot.Slot.Unknown15 = 1000; - - connection.SendTunneled(clientUpdatePacketUpdateActionBarSlot); + var slotPacket = new ClientUpdatePacketUpdateActionBarSlot { Data = { Id = 2, Slot = actionBarSlot } }; + slotPacket.Slot.IsEmpty = false; + slotPacket.Slot.IconId = clientItemDefinition.Icon.Id; + slotPacket.Slot.NameId = clientItemDefinition.NameId; + slotPacket.Slot.Unknown5 = 1; + slotPacket.Slot.Unknown6 = 4; + slotPacket.Slot.Unknown7 = 15; + slotPacket.Slot.Enabled = true; + slotPacket.Slot.Unknown10 = 1000; + slotPacket.Slot.TotalRefreshTime = 1000; + slotPacket.Slot.Quantity = clientItem.Count; + slotPacket.Slot.ForceDismount = true; + slotPacket.Slot.Unknown15 = 1000; + + connection.SendTunneled(slotPacket); } return true; @@ -289,98 +242,61 @@ private static void TriggerAbilityEffect(GatewayConnection connection, Packet.Co int abilityId = clientItemDefinition.ActivatableAbilityId; - if (abilityId == 660) // Can of Beans - fart effect + _resourceManager.Consumables.FoodEffects.TryGetValue(abilityId, out var foodEffect); + + int effectId = foodEffect?.CompositeEffectId ?? clientItemDefinition.CompositeEffectId; + int quickChatId = foodEffect?.QuickChatId ?? 0; + int effectDelayMs = foodEffect?.EffectDelayMs ?? 0; + + if (quickChatId != 0) { - var fartAnimation = new QuickChatSendChatToChannelPacket + var animPacket = new QuickChatSendChatToChannelPacket { - Id = 3340, // emo_fart + Id = quickChatId, Guid = connection.Player.Guid, Name = connection.Player.Name ?? new Packet.Common.NameData(), Channel = Packet.Common.Chat.ChatChannel.WorldArea, AreaNameId = 0, GuildGuid = 0 }; - connection.SendTunneled(fartAnimation); - connection.Player.SendToVisible(fartAnimation, false); - - System.Threading.Tasks.Task.Delay(300).ContinueWith(_ => - { - var fartEffect = new PlayerUpdatePacketPlayCompositeEffect - { - Guid = connection.Player.Guid, - CompositeEffectId = 5343, - Clear = true - }; - connection.SendTunneled(fartEffect); - connection.Player.SendToVisible(fartEffect, false); - }); - } - else if (abilityId == 1964) // Graveyard Flambe - shadow flames - { - var flameEffect = new PlayerUpdatePacketPlayCompositeEffect - { - Guid = connection.Player.Guid, - CompositeEffectId = 5265, - Clear = true - }; - connection.SendTunneled(flameEffect); - connection.Player.SendToVisible(flameEffect, false); - - _logger.LogInformation("Player {Name} activated shadow flames", connection.Player.Name?.FirstName); + connection.Player.SendTunneledToVisible(animPacket, true); } else { - var abilityPacketExecuteClientLua = new AbilityPacketExecuteClientLua - { - Script = string.Empty, - Param1 = 0, - Param2 = 0, - Param3 = 0 - }; - - connection.Player.SendTunneledToVisible(abilityPacketExecuteClientLua, true); - - int effectId = 0; - if (_resourceManager.Consumables.FoodEffects.TryGetValue(abilityId, out var foodEffect)) - effectId = foodEffect.CompositeEffectId; - else if (clientItemDefinition.CompositeEffectId != 0) - effectId = clientItemDefinition.CompositeEffectId; + connection.Player.SendTunneledToVisible(new AbilityPacketExecuteClientLua { Script = string.Empty, Param1 = 0, Param2 = 0, Param3 = 0 }, true); + } - if (effectId != 0) - { - var playerUpdatePacketPlayCompositeEffect = new PlayerUpdatePacketPlayCompositeEffect - { - Guid = connection.Player.Guid, - CompositeEffectId = effectId, - Clear = true, // attach to entity skeleton, not world position - }; + if (effectId != 0) + { + var effectPacket = new PlayerUpdatePacketPlayCompositeEffect { Guid = connection.Player.Guid, CompositeEffectId = effectId, Clear = true }; - connection.Player.SendTunneledToVisible(playerUpdatePacketPlayCompositeEffect, true); - } + if (effectDelayMs > 0) + Task.Delay(effectDelayMs).ContinueWith(_ => connection.Player.SendTunneledToVisible(effectPacket, true)); + else + connection.Player.SendTunneledToVisible(effectPacket, true); } } - private static void SpawnCakeNpc(GatewayConnection connection) + private static void SpawnCakeNpc(GatewayConnection connection, CakeItemDefinition cakeDef) { try { var zone = connection.Player.Zone; - if (zone is not Game.Zones.StartingZone startingZone) return; if (!startingZone.TryCreateNpc(out var cakeNpc)) return; - cakeNpc.NameId = 16634; // "Scaredy Cake" - cakeNpc.ModelId = 1724; // evnt_halloween_cake_01.adr + cakeNpc.NameId = cakeDef.NameId; + cakeNpc.ModelId = cakeDef.ModelId; cakeNpc.TextureAlias = ""; cakeNpc.TintAlias = ""; cakeNpc.Scale = 1.0f; - cakeNpc.Animation = 1; + cakeNpc.Animation = cakeDef.Animation; cakeNpc.HideNamePlate = false; cakeNpc.IsInteractable = true; - cakeNpc.CursorId = 5; // triggers NpcRelevance packet ? client shows "Press X" + cakeNpc.CursorId = (byte)cakeDef.CursorId; var forwardDirection = Vector3.Transform(new Vector3(0, 0, 1), connection.Player.Rotation); var spawnPosition = new Vector4( @@ -393,147 +309,52 @@ private static void SpawnCakeNpc(GatewayConnection connection) cakeNpc.Visible = true; cakeNpc.UpdatePosition(spawnPosition, connection.Player.Rotation); - var scareActive = false; - cakeNpc.InteractAction = (interactingPlayer) => + if (cakeDef.Type == CakeItemType.BossCake) { - if (scareActive) return; - scareActive = true; - - var cakePosition = cakeNpc.Position; - - void Broadcast(ISerializablePacket pkt) - { - interactingPlayer.SendTunneled(pkt); - foreach (var p in interactingPlayer.VisiblePlayers.Values) - p.SendTunneled(pkt); - } - - void SendEffect(int effectId) => Broadcast(new PlayerUpdatePacketPlayCompositeEffect - { - Guid = cakeNpc.Guid, - CompositeEffectId = effectId, - Position = cakePosition, - Clear = true - }); - - switch (Random.Shared.Next(3)) + cakeNpc.InteractAction = (interactingPlayer) => { - case 0: // Bats - SendEffect(5455); // EFX_bats_exp_flyaway (one-shot) - SendEffect(5165); // SFX_OS_BatChirps (one-shot) - SendEffect(15923); // PFX_halloween_icing_splat_cog (one-shot) - break; - case 1: // Ghost - SendEffect(15909); // PFX_halloween_ghosts_up_loop - SendEffect(15960); // SFX_Halloween_GhostCrys - SendEffect(15961); // SFX_Halloween_GhostMoans - break; - case 2: // Raven - SendEffect(15744); // EFX_birds_pink_med_flyaway (one-shot, birds scatter) - SendEffect(5118); // SFX_BirdCrowCaw (one-shot) - SendEffect(15959); // SFX_Halloween_Crows (one-shot) - break; - } - - Task.Delay(2000).ContinueWith(_ => { scareActive = false; }); - }; - - var poofEffect = new PlayerUpdatePacketPlayCompositeEffect - { - Guid = cakeNpc.Guid, - CompositeEffectId = 21, // PFX_smoke_black_explosion - Position = spawnPosition, - Clear = false - }; - - connection.Player.SendTunneled(poofEffect); - connection.Player.OnAddVisibleNpcs([cakeNpc]); - foreach (var player in connection.Player.VisiblePlayers.Values) - { - player.SendTunneled(poofEffect); - player.OnAddVisibleNpcs([cakeNpc]); + int abilityId = cakeDef.TransformAbilityIds[Random.Shared.Next(cakeDef.TransformAbilityIds.Length)]; + if (_resourceManager.Consumables.Transformations.TryGetValue(abilityId, out var transform)) + ApplyTransform(interactingPlayer, transform.ModelId, transform.DurationMs, transform.CompositeEffectId); + }; } - - var capturedNpc = cakeNpc; - - Task.Delay(60_000).ContinueWith(_ => + else { - try + var scareActive = false; + cakeNpc.InteractAction = (interactingPlayer) => { - var removePacket = new PlayerUpdatePacketRemovePlayerGracefully - { - Guid = capturedNpc.Guid, - Animate = false, - Delay = 0, - EffectDelay = 0, - CompositeEffectId = 21, - Duration = 500 - }; + if (scareActive) return; + scareActive = true; + + var cakePosition = cakeNpc.Position; - var players = startingZone.Players; - if (players is not null) + void Broadcast(ISerializablePacket pkt) { - foreach (var player in players) - player.SendTunneled(removePacket); + interactingPlayer.SendTunneled(pkt); + foreach (var p in interactingPlayer.VisiblePlayers.Values) + p.SendTunneled(pkt); } - capturedNpc.Dispose(); - } - catch (Exception ex) { _logger.LogError(ex, "SpawnCakeNpc: error during NPC cleanup"); } - }); - } - catch (Exception ex) { _logger.LogError(ex, "SpawnCakeNpc: error during NPC spawn"); } - } - - private static void SpawnBossCakeNpc(GatewayConnection connection, CakeItemDefinition cakeDef) - { - try - { - var zone = connection.Player.Zone; - - if (zone is not Game.Zones.StartingZone startingZone) - return; - - if (!startingZone.TryCreateNpc(out var cakeNpc)) - return; - - cakeNpc.NameId = 16635; // Boss Cake - cakeNpc.ModelId = 1724; // evnt_halloween_cake_01.adr - cakeNpc.TextureAlias = ""; - cakeNpc.TintAlias = ""; - cakeNpc.Scale = 1.0f; - cakeNpc.Animation = 1; - cakeNpc.HideNamePlate = false; - cakeNpc.IsInteractable = true; - cakeNpc.CursorId = 5; - - var forwardDirection = Vector3.Transform(new Vector3(0, 0, 1), connection.Player.Rotation); - var spawnPosition = new Vector4( - connection.Player.Position.X + forwardDirection.X * 1.5f, - connection.Player.Position.Y + forwardDirection.Y * 1.5f, - connection.Player.Position.Z + forwardDirection.Z * 1.5f, - connection.Player.Position.W - ); - - cakeNpc.Visible = true; - cakeNpc.UpdatePosition(spawnPosition, connection.Player.Rotation); - - int[] bossAbilities = cakeDef.TransformAbilityIds.Length > 0 - ? cakeDef.TransformAbilityIds - : [4370, 4371, 4372, 4373]; + void SendEffect(int effectId) => Broadcast(new PlayerUpdatePacketPlayCompositeEffect + { + Guid = cakeNpc.Guid, + CompositeEffectId = effectId, + Position = cakePosition, + Clear = true + }); - cakeNpc.InteractAction = (interactingPlayer) => - { - int abilityId = bossAbilities[Random.Shared.Next(bossAbilities.Length)]; + var group = cakeDef.ScareGroups[Random.Shared.Next(cakeDef.ScareGroups.Length)]; + foreach (var effectId in group) + SendEffect(effectId); - if (_resourceManager.Consumables.Transformations.TryGetValue(abilityId, out var transform)) - ApplyTransform(interactingPlayer, transform.ModelId, transform.DurationMs, transform.CompositeEffectId); - }; + Task.Delay(cakeDef.ScareCooldownMs).ContinueWith(_ => { scareActive = false; }); + }; + } var poofEffect = new PlayerUpdatePacketPlayCompositeEffect { Guid = cakeNpc.Guid, - CompositeEffectId = 21, + CompositeEffectId = cakeDef.SpawnPoofEffectId, Position = spawnPosition, Clear = false }; @@ -548,7 +369,7 @@ private static void SpawnBossCakeNpc(GatewayConnection connection, CakeItemDefin var capturedNpc = cakeNpc; - Task.Delay(60_000).ContinueWith(_ => + Task.Delay(cakeDef.LifetimeMs).ContinueWith(_ => { try { @@ -558,23 +379,19 @@ private static void SpawnBossCakeNpc(GatewayConnection connection, CakeItemDefin Animate = false, Delay = 0, EffectDelay = 0, - CompositeEffectId = 21, + CompositeEffectId = cakeDef.SpawnPoofEffectId, Duration = 500 }; - var players = startingZone.Players; - if (players is not null) - { - foreach (var player in players) - player.SendTunneled(removePacket); - } + foreach (var player in startingZone.Players ?? []) + player.SendTunneled(removePacket); capturedNpc.Dispose(); } - catch (Exception ex) { _logger.LogError(ex, "SpawnBossCakeNpc: error during NPC cleanup"); } + catch (Exception ex) { _logger.LogError(ex, "SpawnCakeNpc: error during NPC cleanup"); } }); } - catch (Exception ex) { _logger.LogError(ex, "SpawnBossCakeNpc: error during NPC spawn"); } + catch (Exception ex) { _logger.LogError(ex, "SpawnCakeNpc: error during NPC spawn"); } } private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common.ClientItemDefinition itemDef) @@ -582,7 +399,6 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. try { var zone = connection.Player.Zone; - if (zone is not Game.Zones.StartingZone startingZone) return; @@ -605,15 +421,11 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. boomboxNpc.HideNamePlate = true; boomboxNpc.IsInteractable = false; - var leftDirection = System.Numerics.Vector3.Transform( - new System.Numerics.Vector3(-1, 0, 0), - connection.Player.Rotation - ); - var spawnOffset = leftDirection * 2.0f; - var spawnPosition = new System.Numerics.Vector4( - connection.Player.Position.X + spawnOffset.X, - connection.Player.Position.Y + spawnOffset.Y, - connection.Player.Position.Z + spawnOffset.Z, + var leftDirection = Vector3.Transform(new Vector3(-1, 0, 0), connection.Player.Rotation); + var spawnPosition = new Vector4( + connection.Player.Position.X + leftDirection.X * 2.0f, + connection.Player.Position.Y + leftDirection.Y * 2.0f, + connection.Player.Position.Z + leftDirection.Z * 2.0f, connection.Player.Position.W ); @@ -625,10 +437,7 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. var poofEffect = new PlayerUpdatePacketPlayCompositeEffect { Guid = boomboxNpc.Guid, - Unknown2 = 0, CompositeEffectId = 21, // PFX_smoke_black_explosion - Unknown4 = 0, - EffectDelay = 0, Position = spawnPosition, Clear = false }; @@ -648,7 +457,50 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. var capturedNpc = boomboxNpc; - StartBoomboxDancing(startingZone, spawnPosition, danceSequence, 60_000); + const float BoomboxRangeInMeters = 15.0f; + const int DanceInterval = 3000; + int iterations = 60_000 / DanceInterval; + + Task.Run(async () => + { + try + { + int sequenceIndex = 0; + for (int i = 0; i < iterations; i++) + { + await Task.Delay(DanceInterval); + + int quickChatId = danceSequence[sequenceIndex]; + sequenceIndex = (sequenceIndex + 1) % danceSequence.Length; + + var playersInRange = (startingZone.Players ?? []).Where(p => + Vector3.Distance(new Vector3(p.Position.X, p.Position.Y, p.Position.Z), + new Vector3(spawnPosition.X, spawnPosition.Y, spawnPosition.Z)) + <= BoomboxRangeInMeters).ToList(); + + foreach (var player in playersInRange) + { + try + { + var dancePacket = new QuickChatSendChatToChannelPacket + { + Id = quickChatId, + Guid = player.Guid, + Name = player.Name ?? new Packet.Common.NameData(), + Channel = Packet.Common.Chat.ChatChannel.WorldArea, + AreaNameId = 0, + GuildGuid = 0 + }; + player.SendTunneled(dancePacket); + foreach (var visiblePlayer in player.VisiblePlayers.Values) + visiblePlayer.SendTunneled(dancePacket); + } + catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: error sending dance packet to player {Guid}", player.Guid); } + } + } + } + catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: unhandled error in dance loop"); } + }); Task.Delay(60_000).ContinueWith(_ => { @@ -665,12 +517,8 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. Duration = 500 }; - var players = startingZone.Players; - if (players is not null) - { - foreach (var player in players) - player.SendTunneled(removePacket); - } + foreach (var player in startingZone.Players ?? []) + player.SendTunneled(removePacket); capturedNpc.Dispose(); } @@ -680,75 +528,8 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: error during NPC spawn"); } } - private static void StartBoomboxDancing(Game.Zones.StartingZone zone, Vector4 boomboxPosition, int[] danceSequence, int durationMs) - { - const float BoomboxRangeInMeters = 15.0f; - - var danceInterval = 3000; // Send dance command every 3 seconds - var iterations = durationMs / danceInterval; - - System.Threading.Tasks.Task.Run(async () => - { - try - { - - int sequenceIndex = 0; // Track which animation in the sequence we're on - - for (int i = 0; i < iterations; i++) - { - await System.Threading.Tasks.Task.Delay(danceInterval); - - int currentQuickChatId = danceSequence[sequenceIndex]; - sequenceIndex = (sequenceIndex + 1) % danceSequence.Length; // Cycle back to 0 after reaching end - - var allPlayers = zone.Players?.ToList() ?? new List(); - var playersInRange = allPlayers.Where(p => - { - float distance = Vector3.Distance( - new Vector3(p.Position.X, p.Position.Y, p.Position.Z), - new Vector3(boomboxPosition.X, boomboxPosition.Y, boomboxPosition.Z) - ); - return distance <= BoomboxRangeInMeters; - }).ToList(); - - foreach (var player in playersInRange) - { - try - { - var quickChatPacket = new QuickChatSendChatToChannelPacket - { - Id = currentQuickChatId, - Guid = player.Guid, - Name = player.Name ?? new Packet.Common.NameData(), - Channel = Packet.Common.Chat.ChatChannel.WorldArea, - AreaNameId = 0, - GuildGuid = 0 - }; - - player.SendTunneled(quickChatPacket); - - foreach (var visiblePlayer in player.VisiblePlayers.Values) - { - visiblePlayer.SendTunneled(quickChatPacket); - } - } - catch (Exception ex) { _logger.LogError(ex, "StartBoomboxDancing: error sending dance packet to player {Guid}", player.Guid); } - } - } - } - catch (Exception ex) { _logger.LogError(ex, "StartBoomboxDancing: unhandled error"); } - }); - } - private static void SendFailure(GatewayConnection connection, int stringId) - { - var abilityPacketFailed = new AbilityPacketFailed - { - StringId = stringId - }; - - connection.SendTunneled(abilityPacketFailed); - } + => connection.SendTunneled(new AbilityPacketFailed { StringId = stringId }); internal static void ApplyTransform(GatewayConnection connection, int temporaryAppearance, int durationMs, int effectId = 0) => connection.Player.ApplyTemporaryAppearance(temporaryAppearance, durationMs, effectId); From 406c9c4b65cf426c786941d4a02f493830fca58f Mon Sep 17 00:00:00 2001 From: JadenY Date: Tue, 7 Jul 2026 17:39:35 -0500 Subject: [PATCH 17/22] Boombox: synchronized looping dance Adds two reverse-engineered PlayerUpdate packets (SetAnimation 35/8, SetSynchronizedAnimations 35/63) and reworks the boombox so nearby players dance phase-locked via one synchronized packet, looping the DanceSequence and resetting to idle on leave/end. --- ...yPacketClientRequestStartAbilityHandler.cs | 97 +++++++++++++------ .../PlayerUpdatePacketSetAnimation.cs | 44 +++++++++ ...erUpdatePacketSetSynchronizedAnimations.cs | 51 ++++++++++ 3 files changed, 165 insertions(+), 27 deletions(-) create mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketSetAnimation.cs create mode 100644 src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketSetSynchronizedAnimations.cs diff --git a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs index b41a367..02d4f23 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; using System.Numerics; using System.Threading.Tasks; @@ -27,6 +28,13 @@ public static class AbilityPacketClientRequestStartAbilityHandler private static readonly ConcurrentDictionary> _itemCooldowns = new(); + /// Animation id that returns a player to their normal standing idle after a boombox dance + /// (sent via SetAnimation flags=1). + private const int BoomboxIdleAnimId = 1; + + /// How long a boombox stays out and plays (and its use cooldown) - kept the same value. + private const int BoomboxDurationMs = 25_000; + public static void ConfigureServices(IServiceProvider serviceProvider) { var loggerFactory = serviceProvider.GetRequiredService(); @@ -107,7 +115,7 @@ private static bool HandleItemAbility(GatewayConnection connection, AbilityPacke else SpawnBoomboxNpc(connection, clientItemDefinition); - int cooldownMs = isCake ? cakeDef!.CooldownMs : 60_000; + int cooldownMs = isCake ? cakeDef!.CooldownMs : BoomboxDurationMs; playerCooldowns[clientItemDefinition.Id] = DateTimeOffset.UtcNow.AddMilliseconds(cooldownMs); connection.Player.StartActionBarCooldown(2, packet.Data.Slot, clientItemDefinition.Icon.Id, clientItemDefinition.NameId, clientItem.Count, cooldownMs); @@ -458,51 +466,86 @@ private static void SpawnBoomboxNpc(GatewayConnection connection, Packet.Common. var capturedNpc = boomboxNpc; const float BoomboxRangeInMeters = 15.0f; - const int DanceInterval = 3000; - int iterations = 60_000 / DanceInterval; + var danceCenter = new Vector3(spawnPosition.X, spawnPosition.Y, spawnPosition.Z); + + // Reset a single player to the normal standing idle (used when they leave range / it ends). + static void ResetPlayer(Game.Entities.Player p) => + p.SendTunneledToVisible(new PlayerUpdatePacketSetAnimation + { + Guid = p.Guid, + AnimationId = BoomboxIdleAnimId, + Flags = 1 + }, sendToSelf: true); Task.Run(async () => { + // Players currently dancing to this boombox (so we can reset them on leave / end). + var dancing = new HashSet(); try { + // Rotate through the boombox's DanceSequence: each dance loops for SwitchMs, then the next + // plays. Everyone in range is driven by ONE SetSynchronizedAnimations packet so the whole + // crowd dances phase-locked and re-syncs on each rotation. + const int SwitchMs = 4000; int sequenceIndex = 0; - for (int i = 0; i < iterations; i++) + int previousAnim = -1; + + for (int elapsed = 0; elapsed < BoomboxDurationMs; elapsed += SwitchMs) { - await Task.Delay(DanceInterval); + await Task.Delay(SwitchMs); + + int currentAnim = danceSequence.Length > 0 ? danceSequence[sequenceIndex % danceSequence.Length] : 3501; + sequenceIndex++; + bool animChanged = currentAnim != previousAnim; + previousAnim = currentAnim; + + var players = startingZone.Players ?? []; + var inRange = players.Where(p => + Vector3.Distance(new Vector3(p.Position.X, p.Position.Y, p.Position.Z), danceCenter) <= BoomboxRangeInMeters) + .ToList(); + var inRangeGuids = inRange.Select(p => p.Guid).ToHashSet(); + + bool rosterChanged = !dancing.SetEquals(inRangeGuids); - int quickChatId = danceSequence[sequenceIndex]; - sequenceIndex = (sequenceIndex + 1) % danceSequence.Length; + // Reset players who just left range. + foreach (var p in players.Where(p => dancing.Contains(p.Guid) && !inRangeGuids.Contains(p.Guid)).ToList()) + { + try { ResetPlayer(p); } catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: reset failed for {Guid}", p.Guid); } + } - var playersInRange = (startingZone.Players ?? []).Where(p => - Vector3.Distance(new Vector3(p.Position.X, p.Position.Y, p.Position.Z), - new Vector3(spawnPosition.X, spawnPosition.Y, spawnPosition.Z)) - <= BoomboxRangeInMeters).ToList(); + dancing = inRangeGuids; - foreach (var player in playersInRange) + // (Re)sync the crowd only when the dance rotates or the roster changed - re-sending the + // same anim to the same players each tick would restart their loop (a visible hitch). + if (inRange.Count > 0 && (animChanged || rosterChanged)) { - try + var sync = new PlayerUpdatePacketSetSynchronizedAnimations(); + foreach (var p in inRange) + sync.Animations.Add(new PlayerUpdatePacketSetSynchronizedAnimations.Animation { Guid = p.Guid, AnimationId = currentAnim }); + + var recipients = new HashSet(inRange); + foreach (var p in inRange) + foreach (var vp in p.VisiblePlayers.Values) + recipients.Add(vp); + + foreach (var r in recipients) { - var dancePacket = new QuickChatSendChatToChannelPacket - { - Id = quickChatId, - Guid = player.Guid, - Name = player.Name ?? new Packet.Common.NameData(), - Channel = Packet.Common.Chat.ChatChannel.WorldArea, - AreaNameId = 0, - GuildGuid = 0 - }; - player.SendTunneled(dancePacket); - foreach (var visiblePlayer in player.VisiblePlayers.Values) - visiblePlayer.SendTunneled(dancePacket); + try { r.SendTunneled(sync); } catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: sync send failed for {Guid}", r.Guid); } } - catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: error sending dance packet to player {Guid}", player.Guid); } } } } catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: unhandled error in dance loop"); } + finally + { + foreach (var p in (startingZone.Players ?? []).Where(p => dancing.Contains(p.Guid)).ToList()) + { + try { ResetPlayer(p); } catch { /* player may have disconnected */ } + } + } }); - Task.Delay(60_000).ContinueWith(_ => + Task.Delay(BoomboxDurationMs).ContinueWith(_ => { try { diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketSetAnimation.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketSetAnimation.cs new file mode 100644 index 0000000..ac83552 --- /dev/null +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketSetAnimation.cs @@ -0,0 +1,44 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// +/// Server -> client "play an animation on an entity" (opcode 35 / sub-opcode 8). Reverse-engineered from +/// the client's PlayerUpdate dispatcher (FUN_0092f460 case 8) -> deserializer FUN_00908a50 -> field reader +/// FUN_008e5dd0. The client resolves to the entity and drives its animation mixer +/// (FUN_0096c780 - logs "play anim %d", applies to the "upperbody"/full body). +/// +/// Wire (after the 4-byte header short OpCode(35) + short SubOpCode(8)): +/// ulong Guid - target entity (client field +0x10/+0x14) +/// int AnimationId - the animation to play (+0x18); the "%d" in the client's "play anim %d" +/// int Unknown1c - secondary param (+0x1c); blend/duration-ish, 0 works +/// byte Flags - (+0x20) bit0: 1 = set the entity's base/idle anim (stored at entity+0x51c), +/// 0 = play the animation now. Use 0 to play a one-shot/looping animation. +/// +public class PlayerUpdatePacketSetAnimation : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 8; + + public ulong Guid; + public int AnimationId; + public int Unknown1c; + public byte Flags; + + public PlayerUpdatePacketSetAnimation() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + base.Write(writer); // opcode 35 + sub-opcode 8 + + writer.Write(Guid); + writer.Write(AnimationId); + writer.Write(Unknown1c); + writer.Write(Flags); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketSetSynchronizedAnimations.cs b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketSetSynchronizedAnimations.cs new file mode 100644 index 0000000..69e44a6 --- /dev/null +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketSetSynchronizedAnimations.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; + +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// +/// Server -> client "play the same animation on several entities, in sync" (opcode 35 / sub-opcode 63). +/// Reverse-engineered from the client's PlayerUpdate dispatcher (FUN_0092f460 case 0x3f) -> deserializer +/// FUN_00919c90 -> list reader FUN_008fc2d0 -> element reader FUN_008db2a0. The client reads the whole +/// list, then for each entry resolves the guid and drives its animation mixer via the same apply used by +/// SetAnimation (FUN_0096c780) with duration -1, i.e. a looping animation. Because every listed entity is +/// started from one packet, the group stays phase-locked (a true synchronized dance). +/// +/// Wire (after the 4-byte header short OpCode(35) + short SubOpCode(63)): +/// int Count +/// Count x { ulong Guid + int AnimationId } (12 bytes each; element reader FUN_008db2a0) +/// +public class PlayerUpdatePacketSetSynchronizedAnimations : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 63; + + public class Animation + { + public ulong Guid; + public int AnimationId; + } + + public List Animations = new(); + + public PlayerUpdatePacketSetSynchronizedAnimations() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + base.Write(writer); // opcode 35 + sub-opcode 63 + + writer.Write(Animations.Count); + + foreach (var animation in Animations) + { + writer.Write(animation.Guid); + writer.Write(animation.AnimationId); + } + + return writer.Buffer; + } +} From b07962cb63569607dc6832825e4ea986ea955216 Mon Sep 17 00:00:00 2001 From: JadenY Date: Tue, 7 Jul 2026 18:19:30 -0500 Subject: [PATCH 18/22] Fix DatabaseContext provider-assembly loading at startup LoadProviderAssembly scanned AppDomain.CurrentDomain for the DB provider assembly, which returns null on a fresh run (the provider DLL isn't loaded yet) and crashes OnModelCreating with ArgumentNullException. Load the provider assembly explicitly by name (Sanctuary.Database.MySql / .Sqlite) via Assembly.LoadFrom instead, and suppress the PendingModelChangesWarning so a freshly pulled build starts standalone. --- src/Sanctuary.Database/DatabaseContext.cs | 36 ++++++++++++++++------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/Sanctuary.Database/DatabaseContext.cs b/src/Sanctuary.Database/DatabaseContext.cs index 32a76ee..30a570a 100644 --- a/src/Sanctuary.Database/DatabaseContext.cs +++ b/src/Sanctuary.Database/DatabaseContext.cs @@ -1,5 +1,5 @@ using System; -using System.Linq; +using System.IO; using System.Reflection; using Microsoft.EntityFrameworkCore; @@ -33,6 +33,8 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) // optionsBuilder.EnableDetailedErrors(); // optionsBuilder.EnableSensitiveDataLogging(); #endif + optionsBuilder.ConfigureWarnings(warnings => + warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -48,15 +50,27 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) private Assembly? LoadProviderAssembly() { - var prefix = $"{typeof(DatabaseFactory).Namespace}."; - - return AppDomain.CurrentDomain.GetAssemblies() - .FirstOrDefault(a => - { - var name = a.GetName().Name; - return name is not null - && name.StartsWith(prefix, StringComparison.Ordinal) - && (name.EndsWith(".MySql", StringComparison.Ordinal) || name.EndsWith(".Sqlite", StringComparison.Ordinal)); - }); + string? providerAssembly = null; + + if (Database.IsMySql()) + providerAssembly = $"{typeof(DatabaseFactory).Namespace}.MySql"; + else if (Database.IsSqlite()) + providerAssembly = $"{typeof(DatabaseFactory).Namespace}.Sqlite"; + + ArgumentException.ThrowIfNullOrEmpty(providerAssembly); + + Assembly? assembly = null; + + try + { + assembly = EF.IsDesignTime + ? Assembly.Load(providerAssembly) + : Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, $"{providerAssembly}.dll")); + } + catch + { + } + + return assembly; } } \ No newline at end of file From d7d2d2349ff7b21b85e9f342662c84729bf81212 Mon Sep 17 00:00:00 2001 From: JadenY Date: Tue, 7 Jul 2026 18:26:18 -0500 Subject: [PATCH 19/22] Fix Consumables.jsonc cake Type parsing (string enum converter) --- src/Sanctuary.Game/Resources/ConsumableCollection.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Sanctuary.Game/Resources/ConsumableCollection.cs b/src/Sanctuary.Game/Resources/ConsumableCollection.cs index b8a37e7..b938b84 100644 --- a/src/Sanctuary.Game/Resources/ConsumableCollection.cs +++ b/src/Sanctuary.Game/Resources/ConsumableCollection.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; @@ -38,7 +39,8 @@ public bool Load(string filePath) var jsonSerializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, - ReadCommentHandling = JsonCommentHandling.Skip + ReadCommentHandling = JsonCommentHandling.Skip, + Converters = { new JsonStringEnumConverter() } // parse CakeItemType from strings ("BossCake"/"ScaredyCake") }; var consumables = JsonSerializer.Deserialize(fileStream, jsonSerializerOptions); From a8e335043a0438ee7cba1e6074254295dca6e536 Mon Sep 17 00:00:00 2001 From: JadenY Date: Wed, 8 Jul 2026 12:45:18 -0500 Subject: [PATCH 20/22] Boombox: fix dance mappings and make dances prompt + continuous Map each boombox to a real client dance id (AnimationTypes.xml, bound in human_m.adr), replacing placeholders and ids that resolved to clips the avatar has no binding for - Realms Roll (rroll is unbound; use freestyle_01), Rainbow Randomizer / Realmshake (group-pool ids one-shot; use looping slot dances), and trim Totem/Chiller sequences to the clips the avatar actually has. Rework the boombox dance loop to play immediately on spawn (no startup delay), poll every 1s so nearby players start dancing within ~1s, rotate every 4s, and keep the crowd dancing for the full lifetime until the boombox despawns. --- src/Resources/Consumables.jsonc | 12 +-- ...yPacketClientRequestStartAbilityHandler.cs | 86 ++++++++++++------- 2 files changed, 63 insertions(+), 35 deletions(-) diff --git a/src/Resources/Consumables.jsonc b/src/Resources/Consumables.jsonc index 9ef06a2..5ff9073 100644 --- a/src/Resources/Consumables.jsonc +++ b/src/Resources/Consumables.jsonc @@ -2,18 +2,18 @@ "Boomboxes": [ { "ItemId": 878, "ModelId": 1936, "EffectId": 16379, "DanceSequence": [3531, 3532, 3533, 3534] }, // Jingle Bell Boombox { "ItemId": 76641, "ModelId": 1936, "EffectId": 16379, "DanceSequence": [3531, 3532, 3533, 3534] }, // Jingle Bell Boombox - { "ItemId": 1180, "ModelId": 1973, "EffectId": 0, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, // Realms Roll Boombox - { "ItemId": 1913, "ModelId": 1979, "EffectId": 16452, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, // Rainbow Randomizer Boombox - { "ItemId": 8185, "ModelId": 2217, "EffectId": 0, "DanceSequence": [3551, 3552, 3553, 3554] }, // Totem Boombox + { "ItemId": 1180, "ModelId": 1973, "EffectId": 0, "DanceSequence": [3501] }, // Realms Roll Boombox (freestyle_01 loop - avatar has no rroll, only freestyle_01) + { "ItemId": 1913, "ModelId": 1979, "EffectId": 16452, "DanceSequence": [3568000, 3568007, 3568011] }, // Rainbow Randomizer Boombox (ballet/wackyscifi/8bit medley - rotates for variety) + { "ItemId": 8185, "ModelId": 2217, "EffectId": 0, "DanceSequence": [3551] }, // Totem Boombox (totem_01 loop - avatar only has totem_01) { "ItemId": 35759, "ModelId": 4012, "EffectId": 17042, "DanceSequence": [3568004, 3569004, 3570004, 3571004] }, // Clockwork Boombox { "ItemId": 48244, "ModelId": 3893, "EffectId": 0, "DanceSequence": [3568005, 3569005, 3570005, 3571005] }, // Boombox XVD-S - { "ItemId": 56774, "ModelId": 1661, "EffectId": 0, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, // Hip-Hop Boombox - { "ItemId": 69825, "ModelId": 1062, "EffectId": 0, "DanceSequence": [3511, 3512, 3513, 3514, 3515] }, // Chiller Boombox + { "ItemId": 56774, "ModelId": 1661, "EffectId": 0, "DanceSequence": [3568012, 3569012, 3570012, 3571012] }, // Hip-Hop Boombox (shuffle dance) + { "ItemId": 69825, "ModelId": 1062, "EffectId": 0, "DanceSequence": [3511, 3512, 3513, 3514] }, // Chiller Boombox (zombie - avatar lacks zombie_05) { "ItemId": 76927, "ModelId": 4076, "EffectId": 16452, "DanceSequence": [3568010, 3569010, 3570010, 3571010] }, // Pop-Dance Boombox { "ItemId": 76928, "ModelId": 2201, "EffectId": 16451, "DanceSequence": [3568006, 3569006, 3570006, 3571006] }, // Tiki Boombox { "ItemId": 76930, "ModelId": 4096, "EffectId": 16453, "DanceSequence": [3568001, 3569001, 3570001, 3571001] }, // Big Band Boombox { "ItemId": 76933, "ModelId": 4099, "EffectId": 0, "DanceSequence": [3568008, 3569008, 3570008, 3571008] }, // Hootenanny Boombox - { "ItemId": 76935, "ModelId": 4428, "EffectId": 16928, "DanceSequence": [3501, 3502, 3503, 3504, 3505] }, // Realmshake Boombox + { "ItemId": 76935, "ModelId": 4428, "EffectId": 16928, "DanceSequence": [3568003, 3569003, 3570003, 3571003] }, // Realmshake Boombox (headbanger - full 4-clip shake) { "ItemId": 77329, "ModelId": 2059, "EffectId": 16466, "DanceSequence": [3560, 3561, 3562, 3563] }, // Lovelectric Boombox { "ItemId": 76934, "ModelId": 4100, "EffectId": 16827, "DanceSequence": [3568009, 3569009, 3570009, 3571009] } // Xylophone ], diff --git a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs index 02d4f23..9e36dcf 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs @@ -29,7 +29,7 @@ public static class AbilityPacketClientRequestStartAbilityHandler private static readonly ConcurrentDictionary> _itemCooldowns = new(); /// Animation id that returns a player to their normal standing idle after a boombox dance - /// (sent via SetAnimation flags=1). + /// (sent via SetAnimation flags=1); confirmed in-game with /anim. private const int BoomboxIdleAnimId = 1; /// How long a boombox stays out and plays (and its use cooldown) - kept the same value. @@ -483,21 +483,58 @@ static void ResetPlayer(Game.Entities.Player p) => var dancing = new HashSet(); try { - // Rotate through the boombox's DanceSequence: each dance loops for SwitchMs, then the next - // plays. Everyone in range is driven by ONE SetSynchronizedAnimations packet so the whole - // crowd dances phase-locked and re-syncs on each rotation. + // Poll the crowd every TickMs so anyone who walks up starts dancing within ~1s, but only + // rotate to the next dance every SwitchMs. A dance is selected and played on the very first + // tick (no startup delay). On a rotation the whole crowd is re-synced (phase-locked); + // newcomers between rotations are started on the current dance without restarting everyone + // already dancing. The loop runs the full BoomboxDurationMs, and dances loop, so the crowd + // keeps dancing until the boombox despawns. + const int TickMs = 1000; const int SwitchMs = 4000; int sequenceIndex = 0; int previousAnim = -1; + int currentAnim = 3501; + int sinceSwitch = SwitchMs; // >= SwitchMs so a dance is selected and played on the first tick - for (int elapsed = 0; elapsed < BoomboxDurationMs; elapsed += SwitchMs) + // Start `animId` in sync on `targets`, delivered to them + anyone who has one of them visible. + static void SyncDance(List targets, int animId) { - await Task.Delay(SwitchMs); + if (targets.Count == 0) + return; - int currentAnim = danceSequence.Length > 0 ? danceSequence[sequenceIndex % danceSequence.Length] : 3501; - sequenceIndex++; - bool animChanged = currentAnim != previousAnim; - previousAnim = currentAnim; + var sync = new PlayerUpdatePacketSetSynchronizedAnimations(); + foreach (var p in targets) + sync.Animations.Add(new PlayerUpdatePacketSetSynchronizedAnimations.Animation { Guid = p.Guid, AnimationId = animId }); + + var recipients = new HashSet(targets); + foreach (var p in targets) + foreach (var vp in p.VisiblePlayers.Values) + recipients.Add(vp); + + foreach (var r in recipients) + { + try { r.SendTunneled(sync); } catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: sync send failed for {Guid}", r.Guid); } + } + } + + for (int elapsed = 0; elapsed < BoomboxDurationMs; elapsed += TickMs) + { + // Advance the dance rotation when due (and select the first dance on the first tick). + // Only flag a change when the id actually differs, so single-dance boomboxes don't + // re-blast (and hitch) the crowd every SwitchMs. + bool animChanged = false; + if (sinceSwitch >= SwitchMs) + { + int selected = danceSequence.Length > 0 ? danceSequence[sequenceIndex % danceSequence.Length] : 3501; + sequenceIndex++; + sinceSwitch = 0; + if (selected != previousAnim) + { + currentAnim = selected; + previousAnim = selected; + animChanged = true; + } + } var players = startingZone.Players ?? []; var inRange = players.Where(p => @@ -505,39 +542,30 @@ static void ResetPlayer(Game.Entities.Player p) => .ToList(); var inRangeGuids = inRange.Select(p => p.Guid).ToHashSet(); - bool rosterChanged = !dancing.SetEquals(inRangeGuids); - // Reset players who just left range. foreach (var p in players.Where(p => dancing.Contains(p.Guid) && !inRangeGuids.Contains(p.Guid)).ToList()) { try { ResetPlayer(p); } catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: reset failed for {Guid}", p.Guid); } } + var newcomers = inRange.Where(p => !dancing.Contains(p.Guid)).ToList(); dancing = inRangeGuids; - // (Re)sync the crowd only when the dance rotates or the roster changed - re-sending the - // same anim to the same players each tick would restart their loop (a visible hitch). - if (inRange.Count > 0 && (animChanged || rosterChanged)) - { - var sync = new PlayerUpdatePacketSetSynchronizedAnimations(); - foreach (var p in inRange) - sync.Animations.Add(new PlayerUpdatePacketSetSynchronizedAnimations.Animation { Guid = p.Guid, AnimationId = currentAnim }); - - var recipients = new HashSet(inRange); - foreach (var p in inRange) - foreach (var vp in p.VisiblePlayers.Values) - recipients.Add(vp); + // On a rotation, re-sync the whole crowd (phase-lock). Otherwise just start any late + // arrivals on the current dance so players already dancing don't hitch. + if (animChanged) + SyncDance(inRange, currentAnim); + else if (newcomers.Count > 0) + SyncDance(newcomers, currentAnim); - foreach (var r in recipients) - { - try { r.SendTunneled(sync); } catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: sync send failed for {Guid}", r.Guid); } - } - } + await Task.Delay(TickMs); + sinceSwitch += TickMs; } } catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: unhandled error in dance loop"); } finally { + // Stop everyone still dancing when the boombox's dance ends. foreach (var p in (startingZone.Players ?? []).Where(p => dancing.Contains(p.Guid)).ToList()) { try { ResetPlayer(p); } catch { /* player may have disconnected */ } From 92624fdc5d730d54b896e5ff4969d75e4e925999 Mon Sep 17 00:00:00 2001 From: JadenY Date: Fri, 10 Jul 2026 15:21:25 -0500 Subject: [PATCH 21/22] webapi fix --- src/Sanctuary.WebAPI/Program.cs | 3 +++ src/Sanctuary.WebAPI/appsettings.json | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Sanctuary.WebAPI/Program.cs b/src/Sanctuary.WebAPI/Program.cs index 2a63827..b2f9bfb 100644 --- a/src/Sanctuary.WebAPI/Program.cs +++ b/src/Sanctuary.WebAPI/Program.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.HttpLogging; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -19,6 +20,8 @@ var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("database.json", optional: true); + builder.WebHost.UseUrls(); // Proxy Server / Load Balancer diff --git a/src/Sanctuary.WebAPI/appsettings.json b/src/Sanctuary.WebAPI/appsettings.json index 7f29d4e..118d685 100644 --- a/src/Sanctuary.WebAPI/appsettings.json +++ b/src/Sanctuary.WebAPI/appsettings.json @@ -9,5 +9,5 @@ "LaunchArguments": "AssetDelivery:IndirectServerAddress=http://osfr.editz.dev/assets Portrait:UploadUrl=http://127.0.0.1:20040/image" }, "AllowedHosts": "*", - "Urls": "http://127.0.0.1:20040" + "Urls": "http://0.0.0.0:5055" } \ No newline at end of file From 8373d5985cfdb87a396ce7de14ea8c48c5186d5e Mon Sep 17 00:00:00 2001 From: JadenY Date: Fri, 10 Jul 2026 15:26:07 -0500 Subject: [PATCH 22/22] some code didnt merge --- ...ventoryPacketItemActionBarAssignHandler.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/Sanctuary.Gateway/Handlers/BaseInventoryPacket/InventoryPacketItemActionBarAssignHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseInventoryPacket/InventoryPacketItemActionBarAssignHandler.cs index 869c596..6e95620 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseInventoryPacket/InventoryPacketItemActionBarAssignHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseInventoryPacket/InventoryPacketItemActionBarAssignHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using Microsoft.Extensions.DependencyInjection; @@ -43,10 +44,24 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan } }; + // Ensure action bar exists + if (!connection.Player.ActionBars.ContainsKey(2)) + { + connection.Player.ActionBars[2] = new Packet.Common.ClientActionBar { Id = 2 }; + } + if (packet.Guid == 0) { clientUpdatePacketUpdateActionBarSlot.Slot.IsEmpty = true; + // Remove from server-side tracking + connection.Player.ActionBars[2].Slots.Remove(packet.Slot); + + if (connection.Player.ActionBarItemGuids.ContainsKey(2)) + { + connection.Player.ActionBarItemGuids[2].Remove(packet.Slot); + } + connection.SendTunneled(clientUpdatePacketUpdateActionBarSlot); return true; @@ -83,6 +98,32 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan clientUpdatePacketUpdateActionBarSlot.Slot.ForceDismount = true; clientUpdatePacketUpdateActionBarSlot.Slot.Unknown15 = 1000; + // Store the slot information server-side with the item GUID + var slotData = new Packet.Common.ActionBarSlot + { + IsEmpty = false, + IconId = clientItemDefinition.Icon.Id, + NameId = clientItemDefinition.NameId, + Unknown5 = 1, + Unknown6 = 4, + Unknown7 = 15, + Enabled = true, + Unknown10 = 1000, + TotalRefreshTime = 1000, + Quantity = clientItem.Count, + ForceDismount = true, + Unknown15 = 1000 + }; + + connection.Player.ActionBars[2].Slots[packet.Slot] = slotData; + + // Track the item GUID for this slot + if (!connection.Player.ActionBarItemGuids.ContainsKey(2)) + { + connection.Player.ActionBarItemGuids[2] = new Dictionary(); + } + connection.Player.ActionBarItemGuids[2][packet.Slot] = packet.Guid; + connection.SendTunneled(clientUpdatePacketUpdateActionBarSlot); return true;