diff --git a/CHANGELOG.md b/CHANGELOG.md index 121cb25a..2b0c0914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ This is the rolling changelog for TShock for Terraria. Use past tense when adding new entries; sign your name off when you add or change something. This should primarily be things like user changes, not necessarily codebase changes unless it's really relevant or large. ## Upcoming Changes -* Your change goes here! +* New permission `tshock.tp.pylon` to enable teleporting via Teleportation Pylons (@QuiCM) +* New permission `tshock.journey.research` to enable sharing research via item sacrifice (@QuiCM) ## TShock 4.4.0 (Pre-release 10) * Fix all rope coils. (@Olink) diff --git a/TShockAPI/Bouncer.cs b/TShockAPI/Bouncer.cs index fda85267..8bf5a11a 100644 --- a/TShockAPI/Bouncer.cs +++ b/TShockAPI/Bouncer.cs @@ -37,6 +37,7 @@ namespace TShockAPI internal sealed class Bouncer { internal Handlers.SendTileSquareHandler STSHandler { get; set; } + internal Handlers.NetModules.NetModulePacketHandler NetModuleHandler { get; set; } /// Constructor call initializes Bouncer and related functionality. /// A new Bouncer. @@ -45,6 +46,9 @@ namespace TShockAPI STSHandler = new Handlers.SendTileSquareHandler(); GetDataHandlers.SendTileSquare += STSHandler.OnReceive; + NetModuleHandler = new Handlers.NetModules.NetModulePacketHandler(); + GetDataHandlers.ReadNetModule += NetModuleHandler.OnReceive; + // Setup hooks GetDataHandlers.GetSection += OnGetSection; GetDataHandlers.PlayerUpdate += OnPlayerUpdate; diff --git a/TShockAPI/GetDataHandlers.cs b/TShockAPI/GetDataHandlers.cs index 65fe622f..a6c08a50 100644 --- a/TShockAPI/GetDataHandlers.cs +++ b/TShockAPI/GetDataHandlers.cs @@ -1951,6 +1951,40 @@ namespace TShockAPI return args.Handled; } + /// + /// Used when a net module is loaded + /// + public class ReadNetModuleEventArgs : GetDataHandledEventArgs + { + /// + /// The type of net module being loaded + /// + public NetModuleType ModuleType { get; set; } + } + + /// + /// Called when a net module is received + /// + public static HandlerList ReadNetModule = new HandlerList(); + + private static bool OnReadNetModule(TSPlayer player, MemoryStream data, NetModuleType moduleType) + { + if (ReadNetModule == null) + { + return false; + } + + var args = new ReadNetModuleEventArgs + { + Player = player, + Data = data, + ModuleType = moduleType + }; + + ReadNetModule.Invoke(null, args); + return args.Handled; + } + #endregion private static bool HandlePlayerInfo(GetDataHandlerArgs args) @@ -2208,11 +2242,10 @@ namespace TShockAPI else if ((Main.ServerSideCharacter) && (args.Player.sX > 0) && (args.Player.sY > 0) && (args.TPlayer.SpawnX > 0) && ((args.TPlayer.SpawnX != args.Player.sX) && (args.TPlayer.SpawnY != args.Player.sY))) { - args.Player.sX = args.TPlayer.SpawnX; args.Player.sY = args.TPlayer.SpawnY; - if (((Main.tile[args.Player.sX, args.Player.sY - 1].active() && Main.tile[args.Player.sX, args.Player.sY - 1].type == 79)) && (WorldGen.StartRoomCheck(args.Player.sX, args.Player.sY - 1))) + if (((Main.tile[args.Player.sX, args.Player.sY - 1].active() && Main.tile[args.Player.sX, args.Player.sY - 1].type == TileID.Beds)) && (WorldGen.StartRoomCheck(args.Player.sX, args.Player.sY - 1))) { args.Player.Teleport(args.Player.sX * 16, (args.Player.sY * 16) - 48); TShock.Log.ConsoleDebug("GetDataHandlers / HandleSpawn force teleport phase 1 {0}", args.Player.Name); @@ -2221,7 +2254,7 @@ namespace TShockAPI else if ((Main.ServerSideCharacter) && (args.Player.sX > 0) && (args.Player.sY > 0)) { - if (((Main.tile[args.Player.sX, args.Player.sY - 1].active() && Main.tile[args.Player.sX, args.Player.sY - 1].type == 79)) && (WorldGen.StartRoomCheck(args.Player.sX, args.Player.sY - 1))) + if (((Main.tile[args.Player.sX, args.Player.sY - 1].active() && Main.tile[args.Player.sX, args.Player.sY - 1].type == TileID.Beds)) && (WorldGen.StartRoomCheck(args.Player.sX, args.Player.sY - 1))) { args.Player.Teleport(args.Player.sX * 16, (args.Player.sY * 16) - 48); TShock.Log.ConsoleDebug("GetDataHandlers / HandleSpawn force teleport phase 2 {0}", args.Player.Name); @@ -3225,160 +3258,12 @@ namespace TShockAPI private static bool HandleLoadNetModule(GetDataHandlerArgs args) { short moduleId = args.Data.ReadInt16(); - if (moduleId == (int)NetModulesTypes.CreativePowers) + + if (OnReadNetModule(args.Player, args.Data, (NetModuleType)moduleId)) { - CreativePowerTypes powerId = (CreativePowerTypes)args.Data.ReadInt16(); - switch (powerId) - { - case CreativePowerTypes.FreezeTime: - { - if (!args.Player.HasPermission(Permissions.journey_timefreeze)) - { - args.Player.SendErrorMessage("You do not have permission to freeze the time of the server."); - return true; - } - break; - } - case CreativePowerTypes.SetDawn: - case CreativePowerTypes.SetNoon: - case CreativePowerTypes.SetDusk: - case CreativePowerTypes.SetMidnight: - { - if (!args.Player.HasPermission(Permissions.journey_timeset)) - { - args.Player.SendErrorMessage("You do not have permission to modify the time of the server."); - return true; - } - break; - } - case CreativePowerTypes.Godmode: - { - if (!args.Player.HasPermission(Permissions.journey_godmode)) - { - args.Player.SendErrorMessage("You do not have permission to toggle godmode."); - return true; - } - break; - } - case CreativePowerTypes.WindStrength: - { - if (!args.Player.HasPermission(Permissions.journey_windstrength)) - { - args.Player.SendErrorMessage("You do not have permission to modify the wind strength of the server."); - return true; - } - break; - } - case CreativePowerTypes.RainStrength: - { - if (!args.Player.HasPermission(Permissions.journey_rainstrength)) - { - args.Player.SendErrorMessage("You do not have permission to modify the rain strength of the server."); - return true; - } - break; - } - case CreativePowerTypes.TimeSpeed: - { - if (!args.Player.HasPermission(Permissions.journey_timespeed)) - { - args.Player.SendErrorMessage("You do not have permission to modify the time speed of the server."); - return true; - } - break; - } - case CreativePowerTypes.RainFreeze: - { - if (!args.Player.HasPermission(Permissions.journey_rainfreeze)) - { - args.Player.SendErrorMessage("You do not have permission to freeze the rain strength of the server."); - return true; - } - break; - } - case CreativePowerTypes.WindFreeze: - { - if (!args.Player.HasPermission(Permissions.journey_windfreeze)) - { - args.Player.SendErrorMessage("You do not have permission to freeze the wind strength of the server."); - return true; - } - break; - } - case CreativePowerTypes.IncreasePlacementRange: - { - if (!args.Player.HasPermission(Permissions.journey_placementrange)) - { - args.Player.SendErrorMessage("You do not have permission to modify the tile placement range of your character."); - return true; - } - break; - } - case CreativePowerTypes.WorldDifficulty: - { - if (!args.Player.HasPermission(Permissions.journey_setdifficulty)) - { - args.Player.SendErrorMessage("You do not have permission to modify the world difficulty of the server."); - return true; - } - break; - } - case CreativePowerTypes.BiomeSpreadFreeze: - { - if (!args.Player.HasPermission(Permissions.journey_biomespreadfreeze)) - { - args.Player.SendErrorMessage("You do not have permission to freeze the biome spread of the server."); - return true; - } - break; - } - case CreativePowerTypes.SetSpawnRate: - { - // This is a monkeypatch because the 1.4.0.4 seemingly at random sends NPC spawn rate changes even outside of journey mode - // (with SSC on) -- particles, May 25, 2 Reiwa - if (!Main.GameModeInfo.IsJourneyMode) - { - return true; - } - if (!args.Player.HasPermission(Permissions.journey_setspawnrate)) - { - args.Player.SendErrorMessage("You do not have permission to modify the NPC spawn rate of the server."); - return true; - } - break; - } - default: - { - return true; - } - } - } else if (moduleId == (int)NetModulesTypes.CreativeUnlocksPlayerReport && Main.GameModeInfo.IsJourneyMode) - { - var unknownField = args.Data.ReadByte(); - - if (unknownField == 0) //this is required or something??? - { - var itemId = args.Data.ReadUInt16(); - var amount = args.Data.ReadUInt16(); - - var totalSacrificed = TShock.ResearchDatastore.SacrificeItem(itemId, amount, args.Player); - - var response = NetCreativeUnlocksModule.SerializeItemSacrifice(itemId, totalSacrificed); - NetManager.Instance.Broadcast(response); - } + return true; } - // As of 1.4.x.x, this is now used for more things: - // NetCreativePowersModule - // NetCreativePowerPermissionsModule - // NetLiquidModule - // NetParticlesModule - // NetPingModule - // NetTeleportPylonModule - // NetTextModule - // I (particles) have disabled the original return here, which means that we need to - // handle this more. In the interm, this unbreaks parts of vanilla. Originally - // we just blocked this because it was a liquid exploit. return false; } @@ -3893,7 +3778,7 @@ namespace TShockAPI public bool Killed { get; internal set; } } - public enum NetModulesTypes + public enum NetModuleType { Liquid, Text, diff --git a/TShockAPI/Handlers/NetModules/AmbienceHandler.cs b/TShockAPI/Handlers/NetModules/AmbienceHandler.cs new file mode 100644 index 00000000..0252c0c3 --- /dev/null +++ b/TShockAPI/Handlers/NetModules/AmbienceHandler.cs @@ -0,0 +1,28 @@ +using System.IO; + +namespace TShockAPI.Handlers.NetModules +{ + /// + /// Rejects ambience new modules from clients + /// + public class AmbienceHandler : INetModuleHandler + { + /// + /// No deserialization needed. This should never be received by the server + /// + /// + public void Deserialize(MemoryStream data) + { + } + + /// + /// This should never be received by the server + /// + /// + /// + public void HandlePacket(TSPlayer player, out bool rejectPacket) + { + rejectPacket = true; + } + } +} diff --git a/TShockAPI/Handlers/NetModules/BestiaryHandler.cs b/TShockAPI/Handlers/NetModules/BestiaryHandler.cs new file mode 100644 index 00000000..6338b1ce --- /dev/null +++ b/TShockAPI/Handlers/NetModules/BestiaryHandler.cs @@ -0,0 +1,28 @@ +using System.IO; + +namespace TShockAPI.Handlers.NetModules +{ + /// + /// Rejects client->server bestiary net modules as the client should never send this to the server + /// + public class BestiaryHandler : INetModuleHandler + { + /// + /// No deserialization needed. This should never be received by the server + /// + /// + public void Deserialize(MemoryStream data) + { + } + + /// + /// This should never be received by the server + /// + /// + /// + public void HandlePacket(TSPlayer player, out bool rejectPacket) + { + rejectPacket = true; + } + } +} diff --git a/TShockAPI/Handlers/NetModules/CreativePowerHandler.cs b/TShockAPI/Handlers/NetModules/CreativePowerHandler.cs new file mode 100644 index 00000000..470890d1 --- /dev/null +++ b/TShockAPI/Handlers/NetModules/CreativePowerHandler.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; +using System.IO; +using System.IO.Streams; +using static TShockAPI.GetDataHandlers; + +namespace TShockAPI.Handlers.NetModules +{ + /// + /// Provides handling for the Creative Power net module. Checks permissions on all creative powers + /// + public class CreativePowerHandler : INetModuleHandler + { + /// + /// The power type being activated + /// + public CreativePowerTypes PowerType { get; set; } + + /// + /// Reads the power type from the stream + /// + /// + public void Deserialize(MemoryStream data) + { + PowerType = (CreativePowerTypes)data.ReadInt16(); + } + + /// + /// Determines if the player has permission to use the power type + /// + /// + /// + public void HandlePacket(TSPlayer player, out bool rejectPacket) + { + if (!HasPermission(PowerType, player)) + { + rejectPacket = true; + return; + } + + rejectPacket = false; + } + + /// + /// Determines if a player has permission to use a specific creative power + /// + /// + /// + /// + public static bool HasPermission(CreativePowerTypes powerType, TSPlayer player) + { + if (!PowerToPermissionMap.ContainsKey(powerType)) + { + TShock.Log.ConsoleDebug("CreativePowerHandler received permission check request for unknown creative power"); + return false; + } + + string permission = PowerToPermissionMap[powerType]; + + if (!player.HasPermission(permission)) + { + player.SendErrorMessage("You do not have permission to {0}.", PermissionToDescriptionMap[permission]); + return false; + } + + return true; + } + + + /// + /// Maps creative powers to permission nodes + /// + public static Dictionary PowerToPermissionMap = new Dictionary + { + { CreativePowerTypes.FreezeTime, Permissions.journey_timefreeze }, + { CreativePowerTypes.SetDawn, Permissions.journey_timeset }, + { CreativePowerTypes.SetNoon, Permissions.journey_timeset }, + { CreativePowerTypes.SetDusk, Permissions.journey_timeset }, + { CreativePowerTypes.SetMidnight, Permissions.journey_timeset }, + { CreativePowerTypes.Godmode, Permissions.journey_godmode }, + { CreativePowerTypes.WindStrength, Permissions.journey_windstrength }, + { CreativePowerTypes.RainStrength, Permissions.journey_rainstrength }, + { CreativePowerTypes.TimeSpeed, Permissions.journey_timespeed }, + { CreativePowerTypes.RainFreeze, Permissions.journey_rainfreeze }, + { CreativePowerTypes.WindFreeze, Permissions.journey_windfreeze }, + { CreativePowerTypes.IncreasePlacementRange, Permissions.journey_placementrange }, + { CreativePowerTypes.WorldDifficulty, Permissions.journey_setdifficulty }, + { CreativePowerTypes.BiomeSpreadFreeze, Permissions.journey_biomespreadfreeze }, + { CreativePowerTypes.SetSpawnRate, Permissions.journey_setspawnrate }, + }; + + /// + /// Maps journey mode permission nodes to descriptions of what the permission allows + /// + public static Dictionary PermissionToDescriptionMap = new Dictionary + { + { Permissions.journey_timefreeze, "freeze the time of the server" }, + { Permissions.journey_timeset, "modify the time of the server" }, + { Permissions.journey_timeset, "modify the time of the server" }, + { Permissions.journey_timeset, "modify the time of the server" }, + { Permissions.journey_timeset, "modify the time of the server" }, + { Permissions.journey_godmode, "toggle godmode" }, + { Permissions.journey_windstrength, "modify the wind strength of the server" }, + { Permissions.journey_rainstrength, "modify the rain strength of the server" }, + { Permissions.journey_timespeed, "modify the time speed of the server" }, + { Permissions.journey_rainfreeze, "freeze the rain strength of the server" }, + { Permissions.journey_windfreeze, "freeze the wind strength of the server" }, + { Permissions.journey_placementrange, "modify the tile placement range of your character" }, + { Permissions.journey_setdifficulty, "modify the world difficulty of the server" }, + { Permissions.journey_biomespreadfreeze, "freeze the biome spread of the server" }, + { Permissions.journey_setspawnrate, "modify the NPC spawn rate of the server" }, + }; + } +} diff --git a/TShockAPI/Handlers/NetModules/CreativeUnlocksHandler.cs b/TShockAPI/Handlers/NetModules/CreativeUnlocksHandler.cs new file mode 100644 index 00000000..68fe4f1a --- /dev/null +++ b/TShockAPI/Handlers/NetModules/CreativeUnlocksHandler.cs @@ -0,0 +1,90 @@ +using System.IO; +using System.IO.Streams; +using Terraria; +using Terraria.GameContent.NetModules; +using Terraria.Net; + +namespace TShockAPI.Handlers.NetModules +{ + /// + /// Handles creative unlock requests + /// + public class CreativeUnlocksHandler : INetModuleHandler + { + /// + /// An unknown field. If this does not have a value of '0' the packet should be rejected. + /// + public byte UnknownField { get; set; } + /// + /// ID of the item being sacrificed + /// + public ushort ItemId { get; set; } + /// + /// Stack size of the item being sacrificed + /// + public ushort Amount { get; set; } + + /// + /// Reads the unlock data from the stream + /// + /// + public void Deserialize(MemoryStream data) + { + // For whatever reason Terraria writes '0' to the stream at the beginning of this packet. + // If this value is not 0 then its been crafted by a non-vanilla client. + // We don't actually know why the 0 is written, so we're just going to call this UnknownField for now + UnknownField = data.ReadInt8(); + if (UnknownField == 0) + { + ItemId = data.ReadUInt16(); + Amount = data.ReadUInt16(); + } + } + + /// + /// Determines if the unlock is valid and the player has permission to perform the unlock. + /// Syncs unlock status if the packet is accepted + /// + /// + /// + public void HandlePacket(TSPlayer player, out bool rejectPacket) + { + if (!Main.GameModeInfo.IsJourneyMode) + { + TShock.Log.ConsoleDebug( + "NetModuleHandler received attempt to unlock sacrifice while not in journey mode from", + player.Name + ); + + rejectPacket = true; + return; + } + + if (UnknownField != 0) + { + TShock.Log.ConsoleDebug( + "CreativeUnlocksHandler received non-vanilla unlock request. Random field value: {0} but should be 0 from {1}", + UnknownField, + player.Name + ); + + rejectPacket = true; + return; + } + + if (!player.HasPermission(Permissions.journey_contributeresearch)) + { + player.SendErrorMessage("You do not have permission to contribute research."); + rejectPacket = true; + return; + } + + var totalSacrificed = TShock.ResearchDatastore.SacrificeItem(ItemId, Amount, player); + + var response = NetCreativeUnlocksModule.SerializeItemSacrifice(ItemId, totalSacrificed); + NetManager.Instance.Broadcast(response); + + rejectPacket = false; + } + } +} diff --git a/TShockAPI/Handlers/NetModules/INetModuleHandler.cs b/TShockAPI/Handlers/NetModules/INetModuleHandler.cs new file mode 100644 index 00000000..459d22f4 --- /dev/null +++ b/TShockAPI/Handlers/NetModules/INetModuleHandler.cs @@ -0,0 +1,23 @@ +using System.IO; + +namespace TShockAPI.Handlers.NetModules +{ + /// + /// Describes a handler for a net module + /// + public interface INetModuleHandler + { + /// + /// Reads the net module's data from the given stream + /// + /// + void Deserialize(MemoryStream data); + + /// + /// Provides handling for the packet and determines if it should be accepted or rejected + /// + /// + /// + void HandlePacket(TSPlayer player, out bool rejectPacket); + } +} diff --git a/TShockAPI/Handlers/NetModules/LiquidHandler.cs b/TShockAPI/Handlers/NetModules/LiquidHandler.cs new file mode 100644 index 00000000..e7fe647f --- /dev/null +++ b/TShockAPI/Handlers/NetModules/LiquidHandler.cs @@ -0,0 +1,29 @@ +using System.IO; + +namespace TShockAPI.Handlers.NetModules +{ + /// + /// Handles the NetLiquidModule. Rejects all incoming net liquid requests, as clients should never send them + /// + public class LiquidHandler : INetModuleHandler + { + /// + /// Does nothing. We should not deserialize this data + /// + /// + public void Deserialize(MemoryStream data) + { + // No need to deserialize + } + + /// + /// Rejects the packet. Clients should not send this to us + /// + /// + /// + public void HandlePacket(TSPlayer player, out bool rejectPacket) + { + rejectPacket = true; + } + } +} diff --git a/TShockAPI/Handlers/NetModules/NetModulePacketHandler.cs b/TShockAPI/Handlers/NetModules/NetModulePacketHandler.cs new file mode 100644 index 00000000..16d58640 --- /dev/null +++ b/TShockAPI/Handlers/NetModules/NetModulePacketHandler.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using Terraria; +using static TShockAPI.GetDataHandlers; + +namespace TShockAPI.Handlers.NetModules +{ + /// + /// Handles packet 82 - Load Net Module packets + /// + public class NetModulePacketHandler : IPacketHandler + { + /// + /// Maps net module types to handlers for the net module type. Add to or edit this dictionary to customise handling + /// + public static Dictionary NetModulesToHandlersMap = new Dictionary + { + { NetModuleType.CreativePowers, typeof(CreativePowerHandler) }, + { NetModuleType.CreativeUnlocksPlayerReport, typeof(CreativeUnlocksHandler) }, + { NetModuleType.TeleportPylon, typeof(PylonHandler) }, + { NetModuleType.Liquid, typeof(LiquidHandler) }, + { NetModuleType.Bestiary, typeof(BestiaryHandler) }, + { NetModuleType.Ambience, typeof(AmbienceHandler) } + }; + + /// + /// Invoked when a load net module packet is received. This method picks a based on the + /// net module type being loaded, then forwards the data to the chosen handler to process + /// + /// + /// + public void OnReceive(object sender, ReadNetModuleEventArgs args) + { + INetModuleHandler handler; + + if (NetModulesToHandlersMap.ContainsKey(args.ModuleType)) + { + handler = (INetModuleHandler)Activator.CreateInstance(NetModulesToHandlersMap[args.ModuleType]); + } + else + { + // We don't have handlers for NetModuleType.Ping and NetModuleType.Particles. + // These net modules are fairly innocuous and can be processed normally by the game + args.Handled = false; + return; + } + + handler.Deserialize(args.Data); + handler.HandlePacket(args.Player, out bool rejectPacket); + + args.Handled = rejectPacket; + } + } +} diff --git a/TShockAPI/Handlers/NetModules/PylonHandler.cs b/TShockAPI/Handlers/NetModules/PylonHandler.cs new file mode 100644 index 00000000..75ca0c1d --- /dev/null +++ b/TShockAPI/Handlers/NetModules/PylonHandler.cs @@ -0,0 +1,62 @@ +using System.IO; +using System.IO.Streams; +using Terraria.GameContent; +using static Terraria.GameContent.NetModules.NetTeleportPylonModule; + +namespace TShockAPI.Handlers.NetModules +{ + /// + /// Handles a pylon net module + /// + public class PylonHandler : INetModuleHandler + { + /// + /// Event occuring + /// + public SubPacketType PylonEventType { get; set; } + /// + /// Tile X coordinate of the pylon + /// + public short TileX { get; set; } + /// + /// Tile Y coordinate of the pylon + /// + public short TileY { get; set; } + /// + /// Type of Pylon + /// + public TeleportPylonType PylonType { get; set; } + + /// + /// Reads the pylon data from the net module + /// + /// + public void Deserialize(MemoryStream data) + { + PylonEventType = (SubPacketType)data.ReadInt8(); + TileX = data.ReadInt16(); + TileY = data.ReadInt16(); + PylonType = (TeleportPylonType)data.ReadInt8(); + } + + /// + /// Rejects a pylon teleport request if the player does not have permission + /// + /// + /// + public void HandlePacket(TSPlayer player, out bool rejectPacket) + { + if (PylonEventType == SubPacketType.PlayerRequestsTeleport) + { + if (!player.HasPermission(Permissions.pylon)) + { + rejectPacket = true; + player.SendErrorMessage("You do not have permission to teleport with pylons."); + return; + } + } + + rejectPacket = false; + } + } +} diff --git a/TShockAPI/Permissions.cs b/TShockAPI/Permissions.cs index baf21a73..3b401004 100644 --- a/TShockAPI/Permissions.cs +++ b/TShockAPI/Permissions.cs @@ -267,6 +267,9 @@ namespace TShockAPI [Description("User can use wormhole potions.")] public static readonly string wormhole = "tshock.tp.wormhole"; + + [Description("User can use pylons to teleport")] + public static readonly string pylon = "tshock.tp.pylon"; #endregion #region tshock.world nodes @@ -409,6 +412,9 @@ namespace TShockAPI [Description("User can use Creative UI to set the NPC spawn rate of the world.")] public static readonly string journey_setspawnrate = "tshock.journey.setspawnrate"; + + [Description("User can contribute research by sacrificing items")] + public static readonly string journey_contributeresearch = "tshock.journey.research"; #endregion #region Non-grouped diff --git a/TShockAPI/TShockAPI.csproj b/TShockAPI/TShockAPI.csproj index af383252..bc26b96d 100644 --- a/TShockAPI/TShockAPI.csproj +++ b/TShockAPI/TShockAPI.csproj @@ -89,6 +89,14 @@ + + + + + + + + @@ -213,7 +221,7 @@ - +