diff --git a/src/Resources/CoinStoreItems.json b/src/Resources/CoinStoreItems.json index 7f35bcc..f6ae42c 100644 --- a/src/Resources/CoinStoreItems.json +++ b/src/Resources/CoinStoreItems.json @@ -19858,6 +19858,10 @@ "CategoryId": 107, "TintGroupId": 2 }, + { + "Id": 17597, + "CategoryId": 3 + }, { "Id": 17615, "CategoryId": 102 @@ -67314,6 +67318,10 @@ "Id": 69825, "CategoryId": 3 }, + { + "Id": 69828, + "CategoryId": 3 + }, { "Id": 69830, "CategoryId": 201 diff --git a/src/Resources/Consumables.jsonc b/src/Resources/Consumables.jsonc new file mode 100644 index 0000000..fc6f3e4 --- /dev/null +++ b/src/Resources/Consumables.jsonc @@ -0,0 +1,145 @@ +{ + "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] }, // Realms Roll Boombox (freestyle_01 loop - avatar has no rroll, only freestyle_01) + { "ItemId": 1913, "ModelId": 1979, "EffectId": 15917, "DanceSequence": [3568000, 3568007, 3568011] }, // Rainbow Randomizer Boombox (ballet/wackyscifi/8bit medley - rotates for variety; notes PFX only - 16452 carried a 2nd song) + { "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": [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": 5122, "DanceSequence": [3568010, 3569010, 3570010, 3571010] }, // Pop-Dance Boombox (notes PFX only - 16452 carried a 2nd song) + { "ItemId": 76928, "ModelId": 2201, "EffectId": 5120, "DanceSequence": [3568006, 3569006, 3570006, 3571006] }, // Tiki Boombox (notes PFX only - 16451 carried a 2nd song) + { "ItemId": 76930, "ModelId": 4096, "EffectId": 15918, "DanceSequence": [3568001, 3569001, 3570001, 3571001] }, // Big Band Boombox (notes PFX only - 16453 carried a 2nd song) + { "ItemId": 76933, "ModelId": 4099, "EffectId": 0, "DanceSequence": [3568008, 3569008, 3570008, 3571008] }, // Hootenanny Boombox + { "ItemId": 76935, "ModelId": 4428, "EffectId": 16928, "DanceSequence": [3568003, 3569003, 3570003, 3571003] }, // Realmshake Boombox (headbanger - full 4-clip shake) + { "ItemId": 77329, "ModelId": 2059, "EffectId": 16826, "DanceSequence": [3560, 3561, 3562, 3563] }, // Lovelectric Boombox (heart-notes PFX only - 16466 carried 2 extra songs) + { "ItemId": 76934, "ModelId": 4100, "EffectId": 16827, "DanceSequence": [3568009, 3569009, 3570009, 3571009] } // Xylophone + ], + + "Cakes": [ + // Scaredy Cake — spawns a cake NPC that plays a random scare effect on interact + { + "ItemId": 17597, "Type": "ScaredyCake", "CooldownMs": 60000, "ScareCooldownMs": 5000, + "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 + [15744, 5118, 15959] // Raven: EFX_birds_pink_med_flyaway, SFX_BirdCrowCaw, SFX_Halloween_Crows + ], + "TransformAbilityIds": [4372] // Pumpkin Prince transform as a third random outcome (ghost PFX loops forever, removed) + }, // Scaredy Cake + { + "ItemId": 69819, "Type": "ScaredyCake", "CooldownMs": 60000, "ScareCooldownMs": 5000, + "ModelId": 1724, "NameId": 16634, "SpawnPoofEffectId": 21, "LifetimeMs": 60000, + "ScareGroups": [ + [5455, 5165, 15923], + [15744, 5118, 15959] + ], + "TransformAbilityIds": [4372] + }, // Scaredy Cake + + // Boss Cake — spawns a cake NPC that randomly applies one of the TransformAbilityIds to the interacting player + // (69825 is the Chiller Boombox and 69828 the Jack-O-Lantern — a consume-to-transform item. + // Both were wrongly listed here; listing an item as a cake shadows its boombox/transform behavior.) + { "ItemId": 69839, "Type": "BossCake", "CooldownMs": 30000, "ModelId": 1724, "NameId": 16635, "SpawnPoofEffectId": 21, "LifetimeMs": 60000, "TransformAbilityIds": [4370, 4371, 4372, 4373] } // Bat Cupcake + ], + + // Consumed directly from the hotbar — rolls a random transformation from the list on each use + "RandomTransformFoods": [ + { "ItemId": 69828, "TransformAbilityIds": [4370, 4371, 4372, 4373] } // Jack-O-Lantern - random boss transformation + ], + + "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 + { "AbilityId": 1964, "CompositeEffectId": 5265 }, // Graveyard Flambe (shadow flames) + { "AbilityId": 660, "CompositeEffectId": 5343, "QuickChatId": 3340, "EffectDelayMs": 300 } // Can of Beans + ], + + "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/Resources/StoreBundles.json b/src/Resources/StoreBundles.json index f227126..a96a583 100644 --- a/src/Resources/StoreBundles.json +++ b/src/Resources/StoreBundles.json @@ -39064,6 +39064,62 @@ "MemberDiscount": 10, "MembersOnlyPrice": 1 }, + { + "Comment": "Jack-O-Lantern", + "LaunchTime": "2009-05-01T07:00:00+00:00", + "ExpireTime": "2090-01-01T08:00:00+00:00", + "Image": { + "Image": "4562" + }, + "Entries": [ + { + "Quantity": 1, + "GameItemId": 69828, + "MarketingItemId": 69828 + } + ], + "Id": 100081, + "NameId": 16635, + "DescriptionId": 16648, + "Status": 1, + "CurrencyType": 1, + "Price": 1, + "AltCurrencyPrice": 1, + "StoreId": 1, + "CategoryGroupId": 42, + "DiscountType": 100, + "SkuId": 1, + "MemberDiscount": 10, + "MembersOnlyPrice": 1 + }, + { + "Comment": "Scaredy Cake", + "LaunchTime": "2009-05-01T07:00:00+00:00", + "ExpireTime": "2090-01-01T08:00:00+00:00", + "Image": { + "Image": "2869" + }, + "Entries": [ + { + "Quantity": 1, + "GameItemId": 17597, + "MarketingItemId": 17597 + } + ], + "Id": 100082, + "NameId": 16634, + "DescriptionId": 16647, + "Status": 1, + "CurrencyType": 1, + "Price": 1, + "AltCurrencyPrice": 1, + "StoreId": 1, + "CategoryGroupId": 42, + "DiscountType": 100, + "SkuId": 1, + "MemberDiscount": 10, + "MembersOnlyPrice": 1 + }, { "Comment": "Lovelectric Boombox", "LaunchTime": "2009-05-01T07:00:00+00:00", diff --git a/src/Sanctuary.Database/DatabaseContext.cs b/src/Sanctuary.Database/DatabaseContext.cs index 41eaf35..30a570a 100644 --- a/src/Sanctuary.Database/DatabaseContext.cs +++ b/src/Sanctuary.Database/DatabaseContext.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Reflection; using Microsoft.EntityFrameworkCore; @@ -32,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) @@ -62,7 +65,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { assembly = EF.IsDesignTime ? Assembly.Load(providerAssembly) - : Assembly.LoadFrom($"{providerAssembly}.dll"); + : Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, $"{providerAssembly}.dll")); } catch { diff --git a/src/Sanctuary.Game/Entities/Npc.cs b/src/Sanctuary.Game/Entities/Npc.cs index fd78202..aaade01 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; @@ -76,6 +77,7 @@ public Npc(IZone zone) public void OnInteract(Player player) { + InteractAction?.Invoke(player); } public virtual void OnAddVisibleNpcs(params IEnumerable npcs) @@ -207,7 +209,6 @@ public virtual PlayerUpdatePacketAddNpc GetAddNpcPacket() Unknown36 = default, // AnimationEvent TemporaryAppearance = default, - // playerUpdatePacketAddNpc.EffectTags = TODO Unknown38 = default, Unknown39 = default, @@ -217,7 +218,6 @@ public virtual PlayerUpdatePacketAddNpc GetAddNpcPacket() HasTilt = default, - // playerUpdatePacketAddNpc.Customization = TODO Tilt = default, @@ -247,9 +247,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 1395f02..40608da 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; @@ -53,6 +53,15 @@ public sealed class Player : ClientPcData, IEntity public int TimezoneOffset { get; set; } + public Dictionary> ActionBarItemGuids { get; set; } = new(); + + 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(); + public Vector4 StartingZonePosition { get; set; } public Quaternion StartingZoneRotation { get; set; } @@ -122,10 +131,50 @@ public void SendTunneledToVisible(ISerializablePacket packet, bool sendToSelf = public void UpdateEveryTick() { + if (TemporaryAppearanceExpiresAt.HasValue && + TemporaryAppearanceExpiresAt.Value <= DateTimeOffset.UtcNow) + { + RemoveTemporaryAppearance(); + } } 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, bool updateZoneArea = true) @@ -180,11 +229,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; Zone = zone; @@ -502,7 +549,7 @@ public PlayerUpdatePacketAddPc GetAddPcPacket() IsUnderage = Age < 18, IsMember = MembershipStatus != 0, - // playerUpdatePacketAddPc.TemporaryAppearance = 277; + TemporaryAppearance = TemporaryAppearance, ActiveProfileId = ActiveProfileId, @@ -527,6 +574,35 @@ public PlayerUpdatePacketAddPc GetAddPcPacket() return packet; } + + public void ApplyTemporaryAppearance(int modelId, int durationMs, int effectId = 0) + { + TemporaryAppearance = modelId; + _temporaryAppearanceEffectId = effectId; + + if (durationMs > 0) + TemporaryAppearanceExpiresAt = DateTimeOffset.UtcNow.AddMilliseconds(durationMs); + + if (effectId != 0) + SendTunneledToVisible(new PlayerUpdatePacketPlayCompositeEffect { Guid = Guid, CompositeEffectId = effectId, Position = Position, Clear = false }, true); + + SendTunneledToVisible(new PlayerUpdatePacketUpdateTemporaryAppearance { Guid = Guid, TemporaryAppearance = modelId }, true); + } + + public void RemoveTemporaryAppearance() + { + TemporaryAppearance = 0; + TemporaryAppearanceExpiresAt = null; + + if (_temporaryAppearanceEffectId != 0) + { + SendTunneledToVisible(new PlayerUpdatePacketPlayCompositeEffect { Guid = Guid, CompositeEffectId = _temporaryAppearanceEffectId, Position = Position, Clear = false }, true); + _temporaryAppearanceEffectId = 0; + } + + SendTunneledToVisible(new PlayerUpdatePacketRemoveTemporaryAppearance { Guid = Guid }, true); + } + #region Equatable public bool Equals(IEntity? other) diff --git a/src/Sanctuary.Game/IResourceManager.cs b/src/Sanctuary.Game/IResourceManager.cs index f7a8e93..647e348 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,8 +31,9 @@ public interface IResourceManager ProfileDefinitionCollection Profiles { get; } QuickChatDefinitionCollection QuickChats { get; } PointOfInterestDefinitionCollection PointOfInterests { get; } + ConsumableCollection Consumables { get; } NpcDefinitionCollection Npcs { get; } NameFilterCollection NameFilter { get; } bool Load(); -} \ No newline at end of file +} diff --git a/src/Sanctuary.Game/ResourceManager.cs b/src/Sanctuary.Game/ResourceManager.cs index 568a0f7..e5ab4ba 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.jsonc"); public static readonly string NpcsFile = Path.Combine(BaseDirectory, "Npcs.json"); public static readonly string NameFilterFile = Path.Combine(BaseDirectory, "NameFilter.txt"); @@ -73,6 +74,7 @@ public class ResourceManager : IResourceManager public ProfileDefinitionCollection Profiles { get; } public QuickChatDefinitionCollection QuickChats { get; } public PointOfInterestDefinitionCollection PointOfInterests { get; } + public ConsumableCollection Consumables { get; } public NpcDefinitionCollection Npcs { get; } public NameFilterCollection NameFilter { get; } @@ -112,6 +114,7 @@ public ResourceManager(ILogger logger) QuickChats = new(_logger); PlayerTitles = new(_logger); PointOfInterests = new(_logger); + Consumables = new(_logger); Npcs = new(_logger); NameFilter = new(_logger); } @@ -187,6 +190,9 @@ public bool Load() if (!PointOfInterests.Load(PointOfInterestsFile)) return false; + if (!Consumables.Load(ConsumablesFile)) + return false; + if (!Npcs.Load(NpcsFile)) return false; @@ -261,4 +267,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/ConsumableCollection.cs b/src/Sanctuary.Game/Resources/ConsumableCollection.cs new file mode 100644 index 0000000..2e08bc5 --- /dev/null +++ b/src/Sanctuary.Game/Resources/ConsumableCollection.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Microsoft.Extensions.Logging; + +using Sanctuary.Core.Collections; +using Sanctuary.Game.Resources.Definitions; + +namespace Sanctuary.Game.Resources; + +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(); + public ObservableConcurrentDictionary RandomTransformFoods { get; } = new(); + + public ConsumableCollection(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, + ReadCommentHandling = JsonCommentHandling.Skip, + Converters = { new JsonStringEnumConverter() } // parse CakeItemType from strings ("BossCake"/"ScaredyCake") + }; + + var consumables = JsonSerializer.Deserialize(fileStream, jsonSerializerOptions); + + if (consumables is null) + { + _logger.LogError("No entries found in file \"{file}\".", filePath); + return false; + } + + 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); + + 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); + + 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)) + { + _logger.LogWarning("Failed to add Transformation entry. AbilityId={id} \"{file}\"", entry.AbilityId, filePath); + return false; + } + } + _logger.LogInformation("Loaded {count} Transformation definitions.", Transformations.Count); + + foreach (var entry in consumables.RandomTransformFoods) + { + if (!RandomTransformFoods.TryAdd(entry.ItemId, entry)) + { + _logger.LogWarning("Failed to add RandomTransformFood entry. ItemId={id} \"{file}\"", entry.ItemId, filePath); + return false; + } + } + _logger.LogInformation("Loaded {count} RandomTransformFood definitions.", RandomTransformFoods.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 && Cakes.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/CakeItemDefinition.cs b/src/Sanctuary.Game/Resources/Definitions/CakeItemDefinition.cs new file mode 100644 index 0000000..5462ecb --- /dev/null +++ b/src/Sanctuary.Game/Resources/Definitions/CakeItemDefinition.cs @@ -0,0 +1,27 @@ +namespace Sanctuary.Game.Resources.Definitions; + +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; } = []; +} + +public enum CakeItemType +{ + ScaredyCake, + BossCake +} diff --git a/src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs b/src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs new file mode 100644 index 0000000..5696345 --- /dev/null +++ b/src/Sanctuary.Game/Resources/Definitions/ConsumableDefinitions.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace Sanctuary.Game.Resources.Definitions; + +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(); + + public List RandomTransformFoods { 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..33dd180 --- /dev/null +++ b/src/Sanctuary.Game/Resources/Definitions/FoodEffectDefinition.cs @@ -0,0 +1,9 @@ +namespace Sanctuary.Game.Resources.Definitions; + +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.Game/Resources/Definitions/RandomTransformFoodDefinition.cs b/src/Sanctuary.Game/Resources/Definitions/RandomTransformFoodDefinition.cs new file mode 100644 index 0000000..04f7070 --- /dev/null +++ b/src/Sanctuary.Game/Resources/Definitions/RandomTransformFoodDefinition.cs @@ -0,0 +1,11 @@ +namespace Sanctuary.Game.Resources.Definitions; + +/// +/// A consumable that applies a random transformation from +/// when used (e.g. the Jack-O-Lantern rolls one of the boss transformations). +/// +public class RandomTransformFoodDefinition +{ + public int ItemId { get; set; } + public int[] TransformAbilityIds { 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..511f32f --- /dev/null +++ b/src/Sanctuary.Game/Resources/Definitions/TransformAbilityDefinition.cs @@ -0,0 +1,10 @@ +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; } + public int CompositeEffectId { get; set; } +} diff --git a/src/Sanctuary.Game/Zones/BaseZone.cs b/src/Sanctuary.Game/Zones/BaseZone.cs index f403e6c..ebff0c3 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; @@ -468,4 +468,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 f5de25e..78d8199 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; @@ -48,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..0d41bdb 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs @@ -1,8 +1,19 @@ -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.Game.Resources.Definitions; using Sanctuary.Packet; using Sanctuary.Packet.Common.Attributes; @@ -12,11 +23,28 @@ namespace Sanctuary.Gateway.Handlers; public static class AbilityPacketClientRequestStartAbilityHandler { private static ILogger _logger = null!; + private static IResourceManager _resourceManager = null!; + private static IDbContextFactory _dbContextFactory = null!; + + 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); confirmed in-game with /anim. + private const int BoomboxIdleAnimId = 1; + + /// How long a boombox stays out and plays (and its use cooldown). + private const int BoomboxDurationMs = 120_000; + + /// Use cooldown for food-effect consumables (matches the boombox duration). + private const int FoodEffectCooldownMs = 120_000; 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,16 +55,610 @@ 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); + + if (packet.Data.Id == 2) + return HandleItemAbility(connection, packet); + + connection.SendTunneled(new AbilityPacketFailed { StringId = 3079 }); + return true; + } + + private static bool HandleItemAbility(GatewayConnection connection, AbilityPacketClientRequestStartAbility packet) + { + connection.Player.ActionBars.TryGetValue(2, out var actionBar); + + if (actionBar is null || !actionBar.Slots.TryGetValue(packet.Data.Slot, out var foundSlot) || foundSlot.IsEmpty) + { + SendFailure(connection, 3079); + return true; + } + + if (!connection.Player.ActionBarItemGuids.TryGetValue(2, out var actionBarItems) || + !actionBarItems.TryGetValue(packet.Data.Slot, out var itemGuid)) + { + SendFailure(connection, 3079); + return true; + } + + 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; + } + + if (clientItemDefinition.ActivatableAbilityId == 0) + { + SendFailure(connection, 3079); + return true; + } + + bool isBoombox = _resourceManager.Consumables.Boomboxes.ContainsKey(clientItemDefinition.Id); + bool isCake = _resourceManager.Consumables.Cakes.TryGetValue(clientItemDefinition.Id, out var cakeDef); + + if (isCake || isBoombox) + { + var playerCooldowns = _itemCooldowns.GetOrAdd(connection.Player.Guid, _ => new ConcurrentDictionary()); + + if (playerCooldowns.TryGetValue(clientItemDefinition.Id, out var expiry) && DateTimeOffset.UtcNow < expiry) + { + SendFailure(connection, 3079); + return true; + } + + if (isCake) + SpawnCakeNpc(connection, cakeDef!); + else + SpawnBoomboxNpc(connection, clientItemDefinition); + + 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); + + return true; + } + + _logger.LogInformation("HandleItemAbility: itemDef={ItemDefId} abilityId={AbilityId}", clientItemDefinition.Id, clientItemDefinition.ActivatableAbilityId); - var abilityPacketFailed = new AbilityPacketFailed + // Random-transform foods (e.g. Jack-O-Lantern) roll one of their listed transformations + // instead of using the item's fixed ActivatableAbilityId. + var transformAbilityId = clientItemDefinition.ActivatableAbilityId; + + if (_resourceManager.Consumables.RandomTransformFoods.TryGetValue(clientItemDefinition.Id, out var randomFood) && randomFood.TransformAbilityIds.Length > 0) + transformAbilityId = randomFood.TransformAbilityIds[Random.Shared.Next(randomFood.TransformAbilityIds.Length)]; + + if (_resourceManager.Consumables.Transformations.TryGetValue(transformAbilityId, out var transform)) { - // You can't use that ability right now. - StringId = 3079 - }; + _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) + { + SendFailure(connection, 3079); + return true; + } + + if (connection.Player.TemporaryAppearance != 0) + { + SendFailure(connection, 3079); + return true; + } - connection.SendTunneled(abilityPacketFailed); + ApplyTransform(connection, transform.ModelId, transform.DurationMs, transform.CompositeEffectId); + + playerCooldowns[clientItemDefinition.Id] = DateTimeOffset.UtcNow.AddMilliseconds(transform.CooldownMs); + + var capturedSlot = packet.Data.Slot; + var capturedItemDef = clientItemDefinition; + var capturedCount = clientItem.Count; + var cooldownMs = transform.CooldownMs; + + bool willHaveItemLeft = capturedCount > 1; + + if (clientItemDefinition.SingleUse) + ConsumeItem(connection, clientItem, clientItemDefinition, capturedSlot); + + if (willHaveItemLeft) + connection.Player.StartActionBarCooldown(2, capturedSlot, capturedItemDef.Icon.Id, capturedItemDef.NameId, capturedCount - 1, cooldownMs); + + return true; + } + + // Food-effect consumables share the boombox-style item cooldown so they can't be spammed. + if (_resourceManager.Consumables.FoodEffects.ContainsKey(clientItemDefinition.ActivatableAbilityId)) + { + var playerCooldowns = _itemCooldowns.GetOrAdd(connection.Player.Guid, _ => new ConcurrentDictionary()); + + if (playerCooldowns.TryGetValue(clientItemDefinition.Id, out var expiry) && DateTimeOffset.UtcNow < expiry) + { + SendFailure(connection, 3079); + return true; + } + + playerCooldowns[clientItemDefinition.Id] = DateTimeOffset.UtcNow.AddMilliseconds(FoodEffectCooldownMs); + + TriggerAbilityEffect(connection, clientItemDefinition); + + var capturedSlot = packet.Data.Slot; + var capturedCount = clientItem.Count; + + bool willHaveItemLeft = !clientItemDefinition.SingleUse || capturedCount > 1; + + if (clientItemDefinition.SingleUse) + ConsumeItem(connection, clientItem, clientItemDefinition, capturedSlot); + + if (willHaveItemLeft) + connection.Player.StartActionBarCooldown(2, capturedSlot, clientItemDefinition.Icon.Id, clientItemDefinition.NameId, + clientItemDefinition.SingleUse ? capturedCount - 1 : capturedCount, FoodEffectCooldownMs); + + return true; + } + + TriggerAbilityEffect(connection, clientItemDefinition); + + if (clientItemDefinition.SingleUse) + return ConsumeItem(connection, clientItem, clientItemDefinition, packet.Data.Slot); return true; } -} \ No newline at end of file + + 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; + } + + dbItem.Count--; + + var shouldDeleteItem = dbItem.Count <= 0; + + if (shouldDeleteItem) + dbContext.Items.Remove(dbItem); + + if (dbContext.SaveChanges() <= 0) + { + SendFailure(connection, 3079); + return true; + } + + if (shouldDeleteItem) + { + connection.Player.Items.Remove(clientItem); + connection.SendTunneled(new ClientUpdatePacketItemDelete { ItemGuid = clientItem.Id }); + + 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(slotPacket); + } + else + { + clientItem.Count--; + + connection.SendTunneled(new ClientUpdatePacketItemUpdate + { + ItemGuid = clientItem.Id, + Count = clientItem.Count, + ConsumedCount = clientItem.ConsumedCount, + AbilityCount = clientItem.AbilityCount, + RentalExpirationTime = 0 + }); + + 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; + } + + private static void TriggerAbilityEffect(GatewayConnection connection, Packet.Common.ClientItemDefinition clientItemDefinition) + { + if (clientItemDefinition.ActivatableAbilityId == 0) + return; + + int abilityId = clientItemDefinition.ActivatableAbilityId; + + _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 animPacket = new QuickChatSendChatToChannelPacket + { + 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.Player.SendTunneledToVisible(animPacket, true); + } + else + { + connection.Player.SendTunneledToVisible(new AbilityPacketExecuteClientLua { Script = string.Empty, Param1 = 0, Param2 = 0, Param3 = 0 }, true); + } + + if (effectId != 0) + { + var effectPacket = new PlayerUpdatePacketPlayCompositeEffect { Guid = connection.Player.Guid, CompositeEffectId = effectId, Clear = 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, 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 = cakeDef.NameId; + cakeNpc.ModelId = cakeDef.ModelId; + cakeNpc.TextureAlias = ""; + cakeNpc.TintAlias = ""; + cakeNpc.Scale = 1.0f; + cakeNpc.Animation = cakeDef.Animation; + cakeNpc.HideNamePlate = false; + cakeNpc.IsInteractable = true; + cakeNpc.CursorId = (byte)cakeDef.CursorId; + + 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); + + if (cakeDef.Type == CakeItemType.BossCake) + { + cakeNpc.InteractAction = (interactingPlayer) => + { + 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); + }; + } + else + { + 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 + }); + + // Roll across all outcomes: each scare group and each transform is equally likely. + var roll = Random.Shared.Next(cakeDef.ScareGroups.Length + cakeDef.TransformAbilityIds.Length); + + if (roll < cakeDef.ScareGroups.Length) + { + foreach (var effectId in cakeDef.ScareGroups[roll]) + SendEffect(effectId); + } + else + { + var abilityId = cakeDef.TransformAbilityIds[roll - cakeDef.ScareGroups.Length]; + if (_resourceManager.Consumables.Transformations.TryGetValue(abilityId, out var scareTransform)) + ApplyTransform(interactingPlayer, scareTransform.ModelId, scareTransform.DurationMs, scareTransform.CompositeEffectId); + } + + Task.Delay(cakeDef.ScareCooldownMs).ContinueWith(_ => { scareActive = false; }); + }; + } + + var poofEffect = new PlayerUpdatePacketPlayCompositeEffect + { + Guid = cakeNpc.Guid, + CompositeEffectId = cakeDef.SpawnPoofEffectId, + 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(cakeDef.LifetimeMs).ContinueWith(_ => + { + try + { + var removePacket = new PlayerUpdatePacketRemovePlayerGracefully + { + Guid = capturedNpc.Guid, + Animate = false, + Delay = 0, + EffectDelay = 0, + CompositeEffectId = cakeDef.SpawnPoofEffectId, + Duration = 500 + }; + + foreach (var player in startingZone.Players ?? []) + player.SendTunneled(removePacket); + + 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 SpawnBoomboxNpc(GatewayConnection connection, Packet.Common.ClientItemDefinition itemDef) + { + try + { + var zone = connection.Player.Zone; + if (zone is not Game.Zones.StartingZone startingZone) + return; + + 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; + + 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 + ); + + // Visible must be set before UpdatePosition so UpdateZoneTile() fires + // and the zone tile system sends AddNpc to all players in range. + boomboxNpc.Visible = true; + boomboxNpc.UpdatePosition(spawnPosition, connection.Player.Rotation); + + var poofEffect = new PlayerUpdatePacketPlayCompositeEffect + { + Guid = boomboxNpc.Guid, + CompositeEffectId = 21, // PFX_smoke_black_explosion + Position = spawnPosition, + Clear = false + }; + + // 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)) + { + // 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; + + const float BoomboxRangeInMeters = 15.0f; + 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 + { + // 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 + + // Start `animId` in sync on `targets`, delivered to them + anyone who has one of them visible. + static void SyncDance(List targets, int animId) + { + if (targets.Count == 0) + return; + + 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 => + Vector3.Distance(new Vector3(p.Position.X, p.Position.Y, p.Position.Z), danceCenter) <= BoomboxRangeInMeters) + .ToList(); + var inRangeGuids = inRange.Select(p => p.Guid).ToHashSet(); + + // 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; + + // 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); + + 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 */ } + } + } + }); + + Task.Delay(BoomboxDurationMs).ContinueWith(_ => + { + try + { + // 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 + }; + + foreach (var player in startingZone.Players ?? []) + player.SendTunneled(removePacket); + + capturedNpc.Dispose(); + } + catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: error during NPC cleanup"); } + }); + } + catch (Exception ex) { _logger.LogError(ex, "SpawnBoomboxNpc: error during NPC spawn"); } + } + + private static void SendFailure(GatewayConnection connection, int stringId) + => 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); + + 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(); +} 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; diff --git a/src/Sanctuary.Packet.Common/ActionBarSlot.cs b/src/Sanctuary.Packet.Common/ActionBarSlot.cs index 85b2300..3e76aa0 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; @@ -60,4 +60,4 @@ public void Serialize(PacketWriter writer) writer.Write(Unknown15); } -} \ No newline at end of file +} diff --git a/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketExecuteClientLua.cs b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketExecuteClientLua.cs new file mode 100644 index 0000000..7c83172 --- /dev/null +++ b/src/Sanctuary.Packet/BaseAbilityPacket/AbilityPacketExecuteClientLua.cs @@ -0,0 +1,31 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class AbilityPacketExecuteClientLua : BaseAbilityPacket, ISerializablePacket +{ + public new const short OpCode = 17; + + public string Script = string.Empty; + public float Param1; + public float Param2; + public float Param3; + + public AbilityPacketExecuteClientLua() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + 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 new file mode 100644 index 0000000..d06a56b --- /dev/null +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketQueueAnimation.cs @@ -0,0 +1,41 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class PlayerUpdatePacketQueueAnimation : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 22; + + public ulong Guid; + 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) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + base.Write(writer); + + writer.Write(Guid); + 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/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..715812f --- /dev/null +++ b/src/Sanctuary.Packet/BasePlayerUpdatePacket/PlayerUpdatePacketReplaceBaseModel.cs @@ -0,0 +1,29 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class PlayerUpdatePacketReplaceBaseModel : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 49; + + public ulong Guid; + public int ModelId; + public int CompositeEffectId; + + public PlayerUpdatePacketReplaceBaseModel() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(Guid); + writer.Write(ModelId); + writer.Write(CompositeEffectId); + + return writer.Buffer; + } +} 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; + } +} 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; + } +} 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