From 0e16c6fb2bfc81268584ca1fc97b49331b97785e Mon Sep 17 00:00:00 2001 From: Citrinate Date: Thu, 12 Dec 2024 00:45:05 -0500 Subject: [PATCH] Add support for trade up crafting #15 --- CS2Interface/CS2Interface.csproj | 2 +- CS2Interface/CSClient/Client.cs | 129 +++++++++++++++--- CS2Interface/CSClient/GCFetcher.cs | 38 ++++-- CS2Interface/CSClient/GCMsg.cs | 86 ++++++++++++ .../IPC/Api/CS2InterfaceController.cs | 49 ++++++- CS2Interface/Localization/Strings.resx | 28 +++- README.md | 19 ++- 7 files changed, 313 insertions(+), 38 deletions(-) create mode 100644 CS2Interface/CSClient/GCMsg.cs diff --git a/CS2Interface/CS2Interface.csproj b/CS2Interface/CS2Interface.csproj index 5c1d8b1..1a5581e 100644 --- a/CS2Interface/CS2Interface.csproj +++ b/CS2Interface/CS2Interface.csproj @@ -2,7 +2,7 @@ Citrinate - 1.0.12.1 + 1.1.0.0 enable latest net9.0 diff --git a/CS2Interface/CSClient/Client.cs b/CS2Interface/CSClient/Client.cs index 16e610c..2f21f89 100644 --- a/CS2Interface/CSClient/Client.cs +++ b/CS2Interface/CSClient/Client.cs @@ -68,11 +68,12 @@ internal async Task Run() { client_launcher = 0, steam_launcher = 0 }}; - var fetcher = new GCFetcher((uint) EGCBaseClientMsg.k_EMsgGCClientWelcome); + // var fetcher = new GCFetcher((uint) EGCBaseClientMsg.k_EMsgGCClientWelcome); + var fetcher = new GCFetcher((uint) EGCBaseClientMsg.k_EMsgGCClientWelcome); Bot.ArchiLogger.LogGenericDebug(Strings.SendingHello); - if (fetcher.Fetch(this, msg, resendMsg: true) == null) { + if (fetcher.Fetch(this, msg, resendMsg: true) == null) { throw new ClientException(EClientExceptionType.Timeout, Strings.GCConnectionFailed); } @@ -267,14 +268,18 @@ internal async Task In param_m = param_m }}; - var fetcher = new GCFetcher { + var fetcher = new GCFetcher { GCResponseMsgType = (uint) ECsgoGCMsg.k_EMsgGCCStrike15_v2_Client2GCEconPreviewDataBlockResponse, - VerifyFunc = response => response.Body.iteminfo.itemid == param_a + VerifyResponse = message => { + var response = new ClientGCMsgProtobuf(message); + + return response.Body.iteminfo.itemid == param_a; + } }; Bot.ArchiLogger.LogGenericDebug(String.Format("{0}: s {1} a {2} d {3} m {4}", Strings.InspectingItem, param_s, param_a, param_d, param_m)); - var response = fetcher.Fetch(this, msg); + var response = fetcher.Fetch(this, msg); if (response == null) { throw new ClientException(EClientExceptionType.Timeout, Strings.RequestTimeout); } @@ -304,14 +309,18 @@ internal async Task RequestPlayerProfile(ulon request_level = 32 }}; - var fetcher = new GCFetcher{ + var fetcher = new GCFetcher{ GCResponseMsgType = (uint) ECsgoGCMsg.k_EMsgGCCStrike15_v2_PlayersProfile, - VerifyFunc = response => response.Body.account_profiles.FirstOrDefault()?.account_id == account_id + VerifyResponse = message => { + var response = new ClientGCMsgProtobuf(message); + + return response.Body.account_profiles.FirstOrDefault()?.account_id == account_id; + } }; Bot.ArchiLogger.LogGenericDebug(String.Format("{0}: {1}", Strings.InspectingPlayer, steam_id)); - var response = fetcher.Fetch(this, msg); + var response = fetcher.Fetch(this, msg); if (response == null) { throw new ClientException(EClientExceptionType.Timeout, Strings.RequestTimeout); } @@ -358,14 +367,18 @@ internal async Task> GetCasketContents(ulong casket_id) { item_item_id = casket_id }}; - var fetcher = new GCFetcher{ + var fetcher = new GCFetcher{ GCResponseMsgType = (uint) EGCItemMsg.k_EMsgGCItemCustomizationNotification, - VerifyFunc = response => response.Body.item_id.FirstOrDefault() == casket_id && response.Body.request == (uint) EGCItemCustomizationNotification.k_EGCItemCustomizationNotification_CasketContents + VerifyResponse = message => { + var response = new ClientGCMsgProtobuf(message); + + return response.Body.item_id.FirstOrDefault() == casket_id && response.Body.request == (uint) EGCItemCustomizationNotification.k_EGCItemCustomizationNotification_CasketContents; + } }; Bot.ArchiLogger.LogGenericDebug(String.Format(Strings.OpeningCasket, casket_id)); - if (fetcher.Fetch(this, msg) == null) { + if (fetcher.Fetch(this, msg) == null) { throw new ClientException(EClientExceptionType.Timeout, Strings.RequestTimeout); } @@ -395,8 +408,13 @@ internal async Task AddItemToCasket(ulong casket_id, ulong item_id) { throw new ClientException(EClientExceptionType.Failed, Strings.InventoryNotLoaded); } - if (Inventory.Values.FirstOrDefault(x => x.ItemInfo.id == item_id) == null) { - throw new ClientException(EClientExceptionType.BadRequest, Strings.InventoryItemNotFound); + InventoryItem? item = Inventory.Values.FirstOrDefault(x => x.ItemInfo.id == item_id); + if (item == null) { + throw new ClientException(EClientExceptionType.BadRequest, String.Format(Strings.InventoryItemNotFound, item_id)); + } + + if (item.CasketID != null) { + throw new ClientException(EClientExceptionType.BadRequest, String.Format(Strings.InventoryItemFoundInCrate, item_id)); } InventoryItem? casket = Inventory.Values.FirstOrDefault(x => x.ItemInfo.id == casket_id); @@ -419,9 +437,11 @@ internal async Task AddItemToCasket(ulong casket_id, ulong item_id) { item_item_id = item_id }}; - var fetcher = new GCFetcher{ + var fetcher = new GCFetcher{ GCResponseMsgType = (uint) ESOMsg.k_ESOMsg_Destroy, - VerifyFunc = response => { + VerifyResponse = message => { + var response = new ClientGCMsgProtobuf(message); + if (response.Body.type_id != 1) { // Ignore non-inventory changes return false; @@ -440,7 +460,7 @@ internal async Task AddItemToCasket(ulong casket_id, ulong item_id) { Bot.ArchiLogger.LogGenericDebug(String.Format(Strings.AddingItemToCasket, item_id, casket_id)); - if (fetcher.Fetch(this, msg) == null) { + if (fetcher.Fetch(this, msg) == null) { throw new ClientException(EClientExceptionType.Timeout, Strings.RequestTimeout); } @@ -474,9 +494,11 @@ internal async Task RemoveItemFromCasket(ulong casket_id, ulong item_id) { item_item_id = item_id }}; - var fetcher = new GCFetcher{ + var fetcher = new GCFetcher{ GCResponseMsgType = (uint) ESOMsg.k_ESOMsg_Create, - VerifyFunc = response => { + VerifyResponse = message => { + var response = new ClientGCMsgProtobuf(message); + if (response.Body.type_id != 1) { // Ignore non-inventory changes return false; @@ -495,7 +517,7 @@ internal async Task RemoveItemFromCasket(ulong casket_id, ulong item_id) { Bot.ArchiLogger.LogGenericDebug(String.Format(Strings.RemovingItemFromCasket, item_id, casket_id)); - if (fetcher.Fetch(this, msg) == null) { + if (fetcher.Fetch(this, msg) == null) { throw new ClientException(EClientExceptionType.Timeout, Strings.RequestTimeout); } @@ -504,6 +526,75 @@ internal async Task RemoveItemFromCasket(ulong casket_id, ulong item_id) { GCSemaphore.Release(); } } + + internal async Task Craft(ushort recipe, List item_ids) { + if (!HasGCSession) { + throw new ClientException(EClientExceptionType.Failed, Strings.ClientNotConnectedToGC); + } + + if (Inventory == null) { + throw new ClientException(EClientExceptionType.Failed, Strings.InventoryNotLoaded); + } + + if (item_ids.Count > ushort.MaxValue) { + throw new ClientException(EClientExceptionType.BadRequest, Strings.InvalidCraftTooManyInputs); + } + + { + HashSet duplicateIDCheck = new(); + foreach (ulong item_id in item_ids) { + InventoryItem? inventoryItem = Inventory.Values.FirstOrDefault(x => x.ItemInfo.id == item_id); + + if (inventoryItem == null) { + throw new ClientException(EClientExceptionType.BadRequest, String.Format(Strings.InventoryItemNotFound, item_id)); + } + + if (inventoryItem.CasketID != null) { + throw new ClientException(EClientExceptionType.BadRequest, String.Format(Strings.InventoryItemFoundInCrate, item_id)); + } + + if (inventoryItem.Moveable != true) { + throw new ClientException(EClientExceptionType.BadRequest, String.Format(Strings.InvalidCraftInput, item_id)); + } + + if (!duplicateIDCheck.Add(item_id)) { + throw new ClientException(EClientExceptionType.BadRequest, String.Format(Strings.InvalidCraftDuplicateInput, item_id)); + } + } + } + + await GCSemaphore.WaitAsync().ConfigureAwait(false); + + try { + var msg = new ClientGCMsg() { + Body = { + Recipe = recipe, + ItemCount = (ushort) item_ids.Count, + ItemIDs = item_ids + } + }; + + var fetcher = new GCFetcher{ + GCResponseMsgType = (uint) EGCItemMsg.k_EMsgGCCraftResponse, + VerifyResponse = message => { + var response = new ClientGCMsg(message); + + return response.Body.Recipe == recipe || response.Body.Recipe == GCMsg.MsgCraft.UnknownRecipe; + } + }; + + Bot.ArchiLogger.LogGenericDebug(String.Format(Strings.CraftingItem, recipe, String.Join(", ", item_ids))); + + var response = fetcher.RawFetch(this, msg); + if (response == null) { + throw new ClientException(EClientExceptionType.Timeout, Strings.RequestTimeout); + } + + return response.Body; + } finally { + GCSemaphore.Release(); + } + } } [Flags] diff --git a/CS2Interface/CSClient/GCFetcher.cs b/CS2Interface/CSClient/GCFetcher.cs index 2287b7a..a566a0d 100644 --- a/CS2Interface/CSClient/GCFetcher.cs +++ b/CS2Interface/CSClient/GCFetcher.cs @@ -2,24 +2,45 @@ using ProtoBuf; using SteamKit2; using SteamKit2.GC; +using SteamKit2.Internal; namespace CS2Interface { - internal sealed class GCFetcher where TMsg : IExtensible, new() where TResponse : IExtensible, new() { + internal sealed class GCFetcher { internal int TTLSeconds { get; set; } = 30; internal uint GCResponseMsgType { get; set; } - internal Func, bool>? VerifyFunc { get; set; } + internal Func? VerifyResponse { get; set; } private bool GotMatch = false; IPacketGCMsg? PacketMsg; internal GCFetcher() {} - internal GCFetcher(uint gcResponseMsgType, int? ttlSeconds = null, Func, bool>? verify = null) { + internal GCFetcher(uint gcResponseMsgType, int? ttlSeconds = null, Func? verifyResponse = null) { GCResponseMsgType = gcResponseMsgType; TTLSeconds = ttlSeconds ?? TTLSeconds; - VerifyFunc = verify; + VerifyResponse = verifyResponse; } - internal ClientGCMsgProtobuf? Fetch(Client client, ClientGCMsgProtobuf msg, bool resendMsg = false) { + internal ClientGCMsgProtobuf? Fetch(Client client, IClientGCMsg msg, bool resendMsg = false) where TResponse : IExtensible, new() { + GetResponse(client, msg, resendMsg); + + if (!GotMatch || PacketMsg == null) { + return null; + } + + return new ClientGCMsgProtobuf(PacketMsg); + } + + internal ClientGCMsg? RawFetch(Client client, IClientGCMsg msg, bool resendMsg = false) where TResponse : IGCSerializableMessage, new() { + GetResponse(client, msg, resendMsg); + + if (!GotMatch || PacketMsg == null) { + return null; + } + + return new ClientGCMsg(PacketMsg); + } + + private void GetResponse(Client client, IClientGCMsg msg, bool resendMsg = false) { client.OnGCMessageRecieved += CheckMatch; client.GameCoordinator.Send(msg, Client.AppID); @@ -41,11 +62,6 @@ internal GCFetcher(uint gcResponseMsgType, int? ttlSeconds = null, Func(PacketMsg); } internal void CheckMatch(SteamGameCoordinator.MessageCallback callback) { @@ -53,7 +69,7 @@ internal void CheckMatch(SteamGameCoordinator.MessageCallback callback) { return; } - if (VerifyFunc != null && !VerifyFunc(new ClientGCMsgProtobuf(callback.Message))) { + if (VerifyResponse != null && !VerifyResponse(callback.Message)) { return; } diff --git a/CS2Interface/CSClient/GCMsg.cs b/CS2Interface/CSClient/GCMsg.cs new file mode 100644 index 0000000..593f830 --- /dev/null +++ b/CS2Interface/CSClient/GCMsg.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json.Serialization; +using SteamKit2.GC.CSGO.Internal; +using SteamKit2.Internal; + +namespace CS2Interface { + public class GCMsg { + public class MsgCraft : IGCSerializableMessage { + public const ushort UnknownRecipe = 0xFFFF; + + public ushort Recipe; + public ushort ItemCount; + public List ItemIDs; + + public MsgCraft() { + Recipe = 0; + ItemCount = 0; + ItemIDs = []; + } + + public uint GetEMsg() { + return (uint) EGCItemMsg.k_EMsgGCCraft; + } + + public void Serialize(Stream stream) { + BinaryWriter bw = new BinaryWriter(stream); + + bw.Write(Recipe); + bw.Write(ItemCount); + foreach(ulong ItemID in ItemIDs) { + bw.Write(ItemID); + } + } + + public void Deserialize(Stream stream) { + throw new NotImplementedException(); + } + } + + public class MsgCraftResponse : IGCSerializableMessage { + [JsonInclude] + [JsonPropertyName("recipe")] + public ushort Recipe; + + [JsonInclude] + [JsonPropertyName("unknown")] + public uint Unknown; + + [JsonInclude] + [JsonPropertyName("itemcount")] + public ushort ItemCount; + + [JsonInclude] + [JsonPropertyName("itemids")] + public List ItemIDs; + + public MsgCraftResponse() { + Recipe = 0; + Unknown = 0; + ItemCount = 0; + ItemIDs = []; + } + + public uint GetEMsg() { + return (uint) EGCItemMsg.k_EMsgGCCraftResponse; + } + + public void Serialize(Stream stream) { + throw new NotImplementedException(); + } + + public void Deserialize(Stream stream) { + BinaryReader br = new BinaryReader(stream); + + Recipe = br.ReadUInt16(); + Unknown = br.ReadUInt32(); + ItemCount = br.ReadUInt16(); + for (int i = 0; i < ItemCount; i++) { + ItemIDs.Add(br.ReadUInt64()); + } + } + } + } +} \ No newline at end of file diff --git a/CS2Interface/IPC/Api/CS2InterfaceController.cs b/CS2Interface/IPC/Api/CS2InterfaceController.cs index 5736c13..91b93d5 100644 --- a/CS2Interface/IPC/Api/CS2InterfaceController.cs +++ b/CS2Interface/IPC/Api/CS2InterfaceController.cs @@ -268,7 +268,7 @@ public async Task> StoreItem(string botName, ulong return await HandleClientException(bot, e).ConfigureAwait(false); } - return Ok(new GenericResponse(true, "Item successfully added to storage unit")); + return Ok(new GenericResponse(true)); } [HttpGet("{botName:required}/RetrieveItem/{crateID:required}/{itemID:required}")] @@ -297,7 +297,52 @@ public async Task> RetrieveItem(string botName, ul return await HandleClientException(bot, e).ConfigureAwait(false); } - return Ok(new GenericResponse(true, "Item successfully removed from storage unit")); + return Ok(new GenericResponse(true)); + } + + [HttpGet("{botName:required}/CraftItem/{recipeID:required}")] + [SwaggerOperation (Summary = "Crafts an item using the specified trade up recipe")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.GatewayTimeout)] + public async Task> CraftItem( + string botName, + ushort recipeID, + [FromQuery] + [SwaggerParameter(Description = "A comma separated list of item ids", Required = true)] + string itemIDs + ) { + if (string.IsNullOrEmpty(botName)) { + throw new ArgumentNullException(nameof(botName)); + } + + Bot? bot = Bot.GetBot(botName); + if (bot == null) { + return BadRequest(new GenericResponse(false, string.Format(ArchiSteamFarm.Localization.Strings.BotNotFound, botName))); + } + + (Client? client, string client_status) = ClientHandler.ClientHandlers[bot.BotName].GetClient(); + if (client == null) { + return BadRequest(new GenericResponse(false, client_status)); + } + + List item_ids = new(); + foreach (string itemIDString in itemIDs.Split(",")) { + if (!ulong.TryParse(itemIDString, out ulong item_id)) { + return BadRequest(new GenericResponse(false, String.Format(ArchiSteamFarm.Localization.Strings.ErrorParsingObject, nameof(itemIDs)))); + } + + item_ids.Add(item_id); + } + + GCMsg.MsgCraftResponse craftResponse; + try { + craftResponse = await client.Craft(recipeID, item_ids).ConfigureAwait(false); + } catch (ClientException e) { + return await HandleClientException(bot, e).ConfigureAwait(false); + } + + return Ok(new GenericResponse(true, craftResponse)); } private async Task> HandleClientException(Bot bot, ClientException e) { diff --git a/CS2Interface/Localization/Strings.resx b/CS2Interface/Localization/Strings.resx index 646ceaa..fcde1ba 100644 --- a/CS2Interface/Localization/Strings.resx +++ b/CS2Interface/Localization/Strings.resx @@ -198,8 +198,8 @@ {0} will be replaced by a number, {1} will be replaced by a number - Item not found in inventory - + Item not found in inventory: {0} + {0} will be replaced by a number Storage unit is full @@ -397,4 +397,28 @@ Level Up Reward + + No crafting recipe was found for the given inputs + + + + Cannot use item that's currently in a storage unit: {0} + {0} will be replaced by a number + + + This item cannot be used in crafting recipies: {0} + {0} will be replaced by a number + + + Crafting recipe {0} using items: {1} + {0} will be replaced by a number, {1} will be replaced by a list of numbers + + + Same item ID used multiple times as an input: {0} + {0} will be replaced by a number + + + Too many crafting inputs given + + \ No newline at end of file diff --git a/README.md b/README.md index dcb9554..6dee8fe 100644 --- a/README.md +++ b/README.md @@ -58,15 +58,28 @@ Command | Alias | > [!NOTE] > Once the plugin is installed additional documentation can be found, by default, at: [`/swagger`](http://localhost:1242/swagger) +#### Interface + API | Method | Parameters | Description --- | --- | --- | --- `/Api/CS2Interface/{botNames}/Start`|`GET`| |Starts the CS2 Interface `/Api/CS2Interface/{botNames}/Stop`|`GET`| |Stops the CS2 Interface -`/Api/CS2Interface/{botNames}/InspectItem`|`GET`|`url`, `s`, `a`, `d`, `m`, `minimal`, `showDefs`|Inspect a CS2 Item [^1] + +#### Players + +API | Method | Parameters | Description +--- | --- | --- | --- `/Api/CS2Interface/{botName}/PlayerProfile/{steamID}`|`GET`| |Get a friend's CS2 player profile -`/Api/CS2Interface/{botName}/Inventory`|`GET`|`minimal`, `showDefs`|Get the given bot's CS2 inventory + +#### Items + +API | Method | Parameters | Description +--- | --- | --- | --- +`/Api/CS2Interface/{botName}/CraftItem/{recipeID}`|`GET`|`itemIDs`|Crafts an item using the specified trade up recipe `/Api/CS2Interface/{botName}/GetCrateContents/{crateID}`|`GET`|`minimal`, `showDefs`|Get the contents of the given bot's crate -`/Api/CS2Interface/{botName}/StoreItem/{crateID}/{itemID}`|`GET`| |Stores an item into the specified crate +`/Api/CS2Interface/{botNames}/InspectItem`|`GET`|`url`, `s`, `a`, `d`, `m`, `minimal`, `showDefs`|Inspect a CS2 Item [^1] +`/Api/CS2Interface/{botName}/Inventory`|`GET`|`minimal`, `showDefs`|Get the given bot's CS2 inventory `/Api/CS2Interface/{botName}/RetrieveItem/{crateID}/{itemID}`|`GET`| |Retrieves an item from the specified crate +`/Api/CS2Interface/{botName}/StoreItem/{crateID}/{itemID}`|`GET`| |Stores an item into the specified crate [^1]: Responses are not dependent on the account used to make these requests. You may provide multiple `botNames`, and the first available bot will be used to make the request.