diff --git a/Razor/Agents/ItemInfoExtractorAgent.cs b/Razor/Agents/ItemInfoExtractorAgent.cs new file mode 100644 index 00000000..96ae147e --- /dev/null +++ b/Razor/Agents/ItemInfoExtractorAgent.cs @@ -0,0 +1,215 @@ +#region license +// Razor: An Ultima Online Assistant +// Copyright (c) 2022 Razor Development Community on GitHub +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +#endregion + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using System.Xml; +using Assistant.UI; + +namespace Assistant.Agents +{ + + public class ItemInfoExtractorAgent : Agent + { + public static ItemInfoExtractorAgent Instance { get; private set; } + + public static void Initialize() + { + Agent.Add(Instance = new ItemInfoExtractorAgent()); + } + + public ItemInfoExtractorAgent() + { + } + + public override string Name => "Item-Info-Extractor"; + + public override string Alias { get; set; } + + public override int Number { get; } = 0; + + public override void OnSelected(ListBox subList, params Button[] buttons) + { + buttons[0].Text = "Export Item Info"; + buttons[0].Visible = true; + } + + public override void OnButtonPress(int num) + { + switch (num) + { + case 1: + World.Player.SendMessage(MsgLevel.Force, "Target a container to export."); + Targeting.OneTimeTarget(new Targeting.TargetResponseCallback(OnTargetBag)); + break; + } + } + + private async void OnTargetBag(bool location, Serial serial, Point3D loc, ushort gfx) + { + Engine.MainWindow.SafeAction(s => s.ShowMe()); + if (location || !serial.IsItem) { return; } + + Item item = World.FindItem(serial); + if (item?.IsContainer != true) + { + World.Player.SendMessage(MsgLevel.Force, LocString.InvalidCont); + return; + } + + try + { + var children = new ConcurrentBag(); + var listeningTask = Task.Run(async () => + { + PacketViewerCallback callback = (p, args) => + { + ushort id = p.ReadUInt16(); + if (id != 1) return; // object property list + + Serial s = p.ReadUInt32(); + if (!s.IsItem) return; + + Item returnedItem = new Item(s); // Don't add to the world + returnedItem.ReadPropertyList(p); + + children.Add(returnedItem); + }; + + try + { + World.Player.SendMessage(MsgLevel.Force, $"Processing {item.Contains.Count} items..."); + PacketHandler.RegisterServerToClientViewer(0xD6, callback); // Register callback - 0xD6 "encoded" packets + + Client.Instance.SendToServer(new DoubleClick(item.Serial)); // Open the bag + + // Check every 500ms or until we've built up the expected children + var cancellationTokenSource = new CancellationTokenSource(3_000); + while (!cancellationTokenSource.IsCancellationRequested && children.Count != item.Contains.Count) + { + try + { + await Task.Delay(500, cancellationTokenSource.Token); + } + catch (OperationCanceledException) + { + // ignore + } + catch + { + // Don't return a partial result set + return null; + } + } + + if (children.Count != item.Contains.Count) + { + World.Player.SendMessage(MsgLevel.Warning, $"The operation timed out. Only {children.Count} of {item.Contains.Count} items returned information."); + } + else + { + World.Player.SendMessage(MsgLevel.Force, "Retrieved information for all items."); + } + } + finally + { + PacketHandler.RemoveServerToClientViewer(0xD6, callback); // De-register callback + } + + var containerSerialId = item.Serial.Value.ToString(); + + const string serialIdKey = "$serial_id"; + const string countainerSerialIdKey = "$container_serial_id"; + var headers = new HashSet() { countainerSerialIdKey, serialIdKey }; + var rows = new List>(); + foreach (var containerItem in children) + { + if (containerItem.IsContainer) continue; + if (containerItem.IsResource) continue; + + var itemPropertiesWithValue = containerItem.ObjPropList.ExportProperties(); + + // Add the Serial + itemPropertiesWithValue.Add(serialIdKey, containerItem.Serial.Value.ToString()); + itemPropertiesWithValue.Add(countainerSerialIdKey, containerSerialId); + + rows.Add(itemPropertiesWithValue); + + foreach (var key in itemPropertiesWithValue.Keys) + { + headers.Add(key); + } + } + + var builder = new StringBuilder(); + var sortedHeaders = headers.ToList(); + builder.AppendLine(string.Join(",", sortedHeaders)); + + foreach (var row in rows) + { + builder.AppendLine(string.Join(",", sortedHeaders.Select(column => + { + if (!row.TryGetValue(column, out var val)) return ""; + if (val == null) return "1"; // Assume the property can't have a value + if (val.IndexOf(',') < 0) return val; // No comma, return the value directly + + return $"\"{val.Replace("\"", "\"\"")}\""; // Quote values if necessary + }))); + } + + return builder.ToString(); + }, CancellationToken.None); + + var response = await listeningTask; + if (string.IsNullOrWhiteSpace(response)) + { + World.Player.SendMessage(MsgLevel.Warning, "No items were extracted."); + return; + } + + Clipboard.SetText(response); + World.Player.SendMessage(MsgLevel.Force, "Item information has been saved to your clipboard."); + } + catch (Exception ex) + { + World.Player.SendMessage(MsgLevel.Warning, $"An unhandled error occurred attempting to extract items. {ex.Message}"); + } + } + + public override void Save(XmlTextWriter xml) + { + // Do nothing + } + + public override void Load(XmlElement node) + { + // Do nothing + } + + public override void Clear() + { + // Do nothing + } + } +} diff --git a/Razor/Core/ObjectPropertyList.cs b/Razor/Core/ObjectPropertyList.cs index 28b56f6e..971c6434 100644 --- a/Razor/Core/ObjectPropertyList.cs +++ b/Razor/Core/ObjectPropertyList.cs @@ -19,6 +19,7 @@ using System; using System.Text; using System.Collections.Generic; +using System.Text.RegularExpressions; namespace Assistant { @@ -48,6 +49,7 @@ public OPLEntry(int num, string args) private int m_CustomHash = 0; private List m_CustomContent = new List(); + private static Regex m_RegEx = new Regex(@"~(\d+)[_\w]+~", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant); private UOEntity m_Owner = null; @@ -375,7 +377,7 @@ public Packet BuildPacket() } } } - + foreach (OPLEntry ent in m_CustomContent) { try @@ -417,7 +419,217 @@ public Packet BuildPacket() return p; } - } + + public Dictionary ExportProperties() + { + var values = new Dictionary(); + for (int i = 0; i < m_Content.Count; i++) + { + OPLEntry ent = m_Content[i]; + if (ent == null) continue; + + var response = ConvertContentToKeyValuePair(ent.Number, ent.Args); + if (response.key == null) continue; + + response.key = response.key?.Trim(); + response.value = response.value?.Trim(); + + // Assume the first returned value is the "name" + if (i == 0) + { + values.Add("$name", response.key); + + // Handle special case. Ex: {amount} {name} + if (!string.IsNullOrWhiteSpace(response.value) && int.TryParse(response.value, out _)) + { + var parsedName = UnfoldArgClilocNumbers(ent.Args); + values.Add("$amount", response.value); + } + + continue; + } + + // Special case: Containers are being passed in. + if (response.key?.Contains("stones") == true) continue; + + // Clean up the property value + response.key = response.key.Trim(':', ' '); + + // Move the % to the property if it exists + if (response.value?.EndsWith("%") == true) + { + response.value = response.value.TrimEnd('%'); + response.key += " %"; + } + + // If a duplicate would be created, include the cliloc number instead + var key = values.TryGetValue(response.key, out var existing) + ? $"{response.key} ({ent.Number}" // Hope this never happens ... but worth at least getting it out + : response.key; + + values.Add(key, response.value); + } + + return values; + } + + private string UnfoldArgClilocNumbers(string args) + { + if (string.IsNullOrWhiteSpace(args)) return null; + + // Replace all Cliloc numbers that are passed in. + // Ex: Exceptional #{resource_cliloc} #{armor_cliloc} -- Exceptional {barbed} {leather tunic} + + var builder = new StringBuilder(); + var isNumericRun = false; + var isCharRun = false; + int startIndex = 0; + for (var i = 0; i < args.Length; i++) + { + var c = args[i]; + + if (isNumericRun) + { + if (char.IsNumber(c)) continue; + + // Look up value + var intValue = int.Parse(args.Substring(startIndex + 1, i - startIndex)); + var clilocValue = Language.GetClilocUnformatted(intValue); + + // Copy value + builder.Append(clilocValue); + builder.Append(c); + + isCharRun = isNumericRun = false; + + continue; + } + + if (isCharRun) + { + // Multiple args are separated by '\t'. Break into a new run. + if (c != '\t') continue; + + // Copy value + builder.Append(args.Substring(startIndex, i - startIndex)); + builder.Append(c); + + isCharRun = isNumericRun = false; + + continue; + } + + // A '#' indicates it's a Cliloc number + isNumericRun = c == '#'; + isCharRun = !isNumericRun; + startIndex = i; + } + + // Flush final pass + if (isNumericRun) + { + var intValue = int.Parse(args.Substring(startIndex + 1)); + var clilocValue = Language.GetClilocUnformatted(intValue); + + builder.Append(clilocValue); + } + else if (isCharRun) + { + builder.Append(args.Substring(startIndex)); + } + + return builder.ToString(); + } + + private (string key, string value) ConvertContentToKeyValuePair(int initialClilocNumber, string args) + { + var unformattedClilocValue = Language.GetClilocUnformatted(initialClilocNumber); + if (string.IsNullOrWhiteSpace(unformattedClilocValue)) return (null, null); // Unknown value + + // Ex: {Axe Of The Heavens}, {Mage Armor} + // If there are no variables, return the whole value + if (string.IsNullOrWhiteSpace(args)) return (unformattedClilocValue, null); + + var matches = m_RegEx.Matches(unformattedClilocValue); + if (matches.Count == 0) return (unformattedClilocValue, null); // Weird case, we have Args but nowhere to put them. + + // Unfold + args = UnfoldArgClilocNumbers(args); + + // Physical resist: {number} + // Crafted by {string} + if (matches.Count == 1) return (unformattedClilocValue.Replace(matches[0].Value, ""), args); + + var multipleArgs = args.Split('\t'); + var formattedClilocValue = unformattedClilocValue; + for (int i = 0; i < matches.Count; i++) + { + // {skill} +{number} -- Number removed + // {number} {resource} -- Number removed + // Exceptional #{resource_cliloc} #{armor_cliloc} -- Exceptional {barbed} {leather tunic} -- Nothing removed + + // Rip out numeric args and replace the rest with their values + // var replacementValue = int.TryParse(multipleArgs[i], out _) ? "" : multipleArgs[i]; + formattedClilocValue = formattedClilocValue.Replace(matches[i].Value, multipleArgs[i]); // AKA "PropertyValue" + } + + (var flippingIndex, var firstValueNumeric) = ParseFormattedClilocValue(formattedClilocValue); + + // Extract property/value strings + string value = null; + string property = null; + if (firstValueNumeric) + { + value = formattedClilocValue.Substring(0, flippingIndex); + property = formattedClilocValue.Substring(flippingIndex); + } + else + { + value = formattedClilocValue.Substring(flippingIndex); + property = formattedClilocValue.Substring(0, flippingIndex); + } + + return (property, value); + } + + private (int flippingIndex, bool firstValueNumeric) ParseFormattedClilocValue(string value) + { + /* Example cases + {amount} {name} - value / label + {skill} +{value} - label / value + {property}: {value} + {property} {value_1} / {value_2} + */ + + bool isNumericRun = false; + bool isCharRun = false; + int i = 0; + + for (i = 0; i < value.Length; i++) + { + var c = value[i]; + var isNumber = char.IsNumber(c); + + if (isNumericRun) + { + if (isNumber) continue; + break; + } + + if (isCharRun) + { + if (!isNumber) continue; + break; + } + + // Figure out which comes first + isNumericRun = isNumber; + isCharRun = !isNumericRun; + } + + return (i, isNumericRun); + } + } public class OPLInfo : Packet { diff --git a/Razor/Razor.csproj b/Razor/Razor.csproj index 1ca6fc77..495237ab 100644 --- a/Razor/Razor.csproj +++ b/Razor/Razor.csproj @@ -176,6 +176,7 @@ + diff --git a/Razor/UI/Languages.cs b/Razor/UI/Languages.cs index 7a42a9b7..5720075c 100644 --- a/Razor/UI/Languages.cs +++ b/Razor/UI/Languages.cs @@ -889,6 +889,19 @@ public static string GetCliloc(int num) return string.Empty; } + public static string GetClilocUnformatted(int num) + { + if (m_CliLoc == null) + return String.Empty; + + StringEntry se = m_CliLoc.GetEntry(num); + + if (se != null) + return se.Text; + else + return string.Empty; + } + public static string ClilocFormat(int num, string argstr) { if (m_CliLoc == null)