Bloog Bot
Hotspots - 2/17/2021
Our bot is off to a great start. He'll grind in an area as long as he finds things to kill. As a refresher, let's look at our current targeting logic in GrindState.cs:
public void Update() { var newTarget = ObjectManager .Units .FirstOrDefault(u => u.TargetGuid == ObjectManager.Player.Guid) ?? ObjectManager .Units .Where(u => u.Health > 0) .Where(u => u.CreatureType != CreatureType.Critter && u.CreatureType != CreatureType.NotSpecified && u.CreatureType != CreatureType.Totem) .Where(u => u.UnitReaction(player.Pointer) == UnitReaction.Hostile || u.UnitReaction(player.Pointer) == UnitReaction.Unfriendly || u.UnitReaction(player.Pointer) == UnitReaction.Neutral) .Where(u => u.Level <= player.Level + 2 && u.Level >= player.Level - 4) .OrderBy(u => u.Position.DistanceTo(player.Position)) .FirstOrDefault(); if (newTarget != null) { player.SetTarget(newTarget.Guid); botStates.Push(new MoveToTargetState(botStates, newTarget, food)); } }
First we check if anything is attacking us, and if so, make that our target. If not, we build a list of units that meet the following criteria:
- Health is greater than 0
- CreatureType is not Critter, NotSpecified, or Totem
- UnitReaction is either Hostile, Unfriendly, or Neutral (we basically want to avoid Friendly and Hated, which is NPC guards and the like)
- Level is no more than 2 levels above the bot, and no less than 4 levels below the bot
Then we order the units by distance to the character, and choose the closest one, making that our target, and transitioning into the MoveToTargetState (and then eventually the CombatState). But what happens if no units meet our targeting criteria?
As of right now, our bot will simply stand there until he finds an eligible target in range. If you recall from our conversation on EnumerateVisibleObjects, players can only see other units within a certain distance from the character (and indeed, the Server only passes visible units to the Client, so even if we dig around in memory, we can only see things within a radius around the character). So the bot will just stand there until something spawns, or wanders into range, then he'll find it and target it. But this is less than ideal - the bot should be smart enough to run around in a given area, proactively looking for targets.
In this chapter, we're going to implement a concept I call a "Hotspot", which is simply a group of 3D coordinates in the game world that the bot will move between in order to find new targets. First we'll add some functionality to the UI that allows us to create new Hotspots, then we'll update the logic in GrindState.cs so that when the bot fails to find an eligible target, he'll choose a random coordinate from the current Hotspot and move toward that, hopefully finding an eligible target along the way. We'll also need to update the UI by adding a dropdown to choose the current Hotspot (we want to be able to define many of them and switch between them as needed).
My early version of BloogBot stored these hotspots in a .json file on disk. But as more of my friends starting using my bot, we were all defining our own Hotspots, which ended up being a lot of duplicated effort. It got tedious trying to share these .json files back and forth, resolve conflicts in the file, etc. So my current solution stores the Hotspots .json in an Azure SQL database. When creating new Hotspots using the BloogBot UI, these Hotspots are pushed to the cloud. And when the bot first starts, it queries the SQL database to retrieve all the existing Hotspots and populates the dropdown in the BloogBot UI. This made it a lot easier to have multiple people using BloogBot, and we could all use the Hotspots created by others.
To follow along with this chapter, you're going to need an Azure subscription. If you aren't interested in this, it should be pretty straightforward to adjust the code to simply save/load the Hotspots from a file on disk, like I was doing before. You could also use any other cloud provider or database engine, you'll just have to adjust the queries accordingly. That being said, we're going to be adding more cloud integration later on, and it's definitely proven to be very useful, so I would suggest you get setup on Azure now.
I'm not going to give specific instuctions on creating and configuring resources in Azure. There is plenty of good free and public documentation out there, so if you need help, consult the official Microsoft docs.
Once you have an Azure subscription, you should create a new SQL Server, and under that, a new SQL Database. I'm just using the Basic pricing tier - this doesn't have to be crazy fast. I would also suggest you install SQL Server Management Studio (SSMS) because it makes working with your SQL Database a lot easier. When you create the SQL Server in Azure, you have to specify the admin account password - make sure you save this. Once you have this stuff created, go ahead and connect to your new database in SSMS using the admin account you specified while creating the SQL Server.
Create a new table. You won't be able to specify a name until you save the new table - when you do, name it Hotspots. Your table schema should look like this:
Make sure you set the Id column to be the table's Primary Key by changing (Is Identity) from false to true.
This is all we need to do in SSMS for now. Make sure your new Hotspots table is created, then switch back over to Visual Studio.
First, create a new class called Hotspot. This will be the model for our new Hotspots table.
using BloogBot.Game; namespace BloogBot { public class Hotspot { public Hotspot( int id, Position[] waypoints, string zone, string faction, int minimumLevel, string description) { Id = id; Waypoints = waypoints; Zone = zone; Faction = faction; MinimumLevel = minimumLevel; Description = description; } public int Id { get; } public Position[] Waypoints { get; } public string Zone { get; } public string Faction { get; } public int MinimumLevel { get; } public string Description { get; } public string DisplayName => $"{MinimumLevel} - {Zone}: {Description}"; } }
Next we're going to create a new Repository class that will contain all of the code that interacts with our Azure SQL database. Create a new file named Repository.cs under the BloogBot project.
using BloogBot.Game; using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Data.SqlClient; namespace BloogBot { static public class Repository { static string connectionString; static internal void Initialize(string parConnectionString) { connectionString = parConnectionString; } static public Hotspot AddHotspot( Position[] waypoints, string zone, string faction, int minimumLevel, string description) { var encodedZone = Encode(zone); var encodedFaction = Encode(faction); var encodedDescription = Encode(description); var waypointsJson = JsonConvert.SerializeObject(waypoints); using (var db = new SqlConnection(connectionString)) { db.Open(); // first insert var sql = $"INSERT INTO Hotspots VALUES ('{waypointsJson}', '{encodedZone}', '{encodedFaction}', {minimumLevel}, '{encodedDescription}');"; var command = new SqlCommand(sql, db); command.ExecuteNonQuery(); // then retrieve it so we have the id sql = $"SELECT TOP 1 * FROM Hotspots WHERE Description = '{encodedDescription}';"; command = new SqlCommand(sql, db); var reader = command.ExecuteReader(); reader.Read(); var id = Convert.ToInt32(reader["Id"]); var hotspot = new Hotspot( id, waypoints, zone, faction, minimumLevel, description); reader.Close(); db.Close(); return hotspot; } } static public IEnumerable<Hotspot> ListHotspots() { var hotspots = new List<Hotspot>(); using (var db = new SqlConnection(connectionString)) { db.Open(); var sql = @"SELECT Waypoints, Zone, Faction, MinimumLevel, Description FROM Hotspots"; var command = new SqlCommand(sql, db); var reader = command.ExecuteReader(); while (reader.Read()) { var id = Convert.ToInt32(reader["Id"]); var waypointsJson = Convert.ToString(reader["Waypoints"]); var waypoints = JsonConvert.DeserializeObject<Position[]>(waypointsJson); var zone = Convert.ToString(reader["Zone"]); var faction = Convert.ToString(reader["Faction"]); var minLevel = Convert.ToInt32(reader["MinimumLevel"]); var description = Convert.ToString(reader["Description"]); hotspots.Add(new Hotspot( id, waypoints, zone, faction, minLevel, description)); } reader.Close(); db.Close(); return hotspots; } } static string Encode(string value) => value.Replace("'", "''"); } }
You'll notice we're using the JsonConvert class. This comes from the Newtonsoft.Json package, so you'll need to add that to your project as a Nuget package dependency. Speaking of Newtonsoft.json - earlier versions of my bot used the JSON serialization built into the .Net framework, but frankly, Newtonsoft is better. In fact, they actually wrapped Newtonsoft into .Net Core, so I'm more familiar using it. I can't remember if old chapters of this series still reference the old .Net JSON stuff, or Newtonsoft. So if necessary, you may have to make some changes to your code to look like the snippets in this chapter that use Newtonsoft.
There are two public methods here - AddHotspot
and ListHotspots
. Let's start with AddHotspot. The Zone, Faction, MinimumLevel, and Description will be fields that will be used to properly order and display the elements in a dropdown in the UI. The real interesting bit here is the Waypoints field. This is an array of `Position` objects that we serialize as a JSON string and save in the database. Shortly, we'll add some new code to the UI that will allow us to "Record" a new Hotspot by running around and choosing the Positions we want to include. AddHotspot
is going to be used to persist our newly created Hotspots in the database.
We also pass all string input through the Encode method - this ensures that any strings from WoW that may include a single apostrophe will properly escape that character (the apostrophe is a special character in SQL).
Our Repository class is static, but you'll notice there's an Initialize method that takes a connection string. This is how we'll authenticate to the Azure SQL Database, so we'll need to call Initialize method somewhere early on in our bot's initialization code. First, go find the the connection to your SQL database in the Azure portal and make note of it:
Add a new key/value pair to botSettings.json. The key should be "DatabaseConnectionString"
, and the value should be the connection string you found in the Azure portal (make sure you replace the {your_password} section with the admin password you configured when creating the database). Also make sure you DO NOT check in this botSettings.json file to source control if you're using it. In my case, I added it to my .gitignore file.
Next, update BotSettings.cs to include this new field:
namespace BloogBot { class BotSettings { public string Food { get; set; } public string DatabaseConnectionString { get; set; } } } }
And finally, inside the constructor of MainViewModel, we're going to call Repository.Initialize to make sure it gets initialized with the correct connection string:
public MainViewModel() { ReloadBots(); LoadBotSettings(); Repository.Initialize(botSettings.DatabaseConnectionString); }
From here, you should be able to start the bot and step through that code to see the Repository class is successfully initialized. But as of right now, we aren't actually doing anything with the database. To start, we're going to add some code that allows us to record new Hotspots. Create a new file called HotspotGenerator.cs under the Bloogbot project:
using BloogBot.Game; using System.Collections.Generic; using System.Linq; namespace BloogBot { static public class HotspotGenerator { static readonly IList<Position> positions = new List<Position>(); static public bool Recording { get; private set; } static public int PositionCount => positions.Count; static public void Record() { Recording = true; positions.Clear(); } static public void AddWaypoint(Position position) => positions.Add(position); static public void Cancel() => Recording = false; static public Position[] Save() { Recording = false; return positions.ToArray(); } } }
The UI for our bot is going to start getting messy, so we're going to introduce a tabbed structure. Update MainWindow.xaml to look like this (note that I'm experimenting with embedding Gists for large code snippets - let's see how it works out):
View code on GistHere are a few screenshots of what the UI looks like now:
We've moved the input that allows us to specify our character's food preference to a new Settings tab. And we've also created a new Hotspots tab with a bunch of new inputs and controls. And we've created a new Grinding Hotspot dropdown on the Overview tab - this is what we'll use to choose the current Hotspot we want the bot to grind in once we have multiple Hotspots created.
We'll need to wire up the new controls on the Hotspots tab, as well as the new dropdown on the Overview tab. There's a lot of new code here - take your time and read through the new functions that are referenced from the buttons in the UI. These will control the creation of new Hotspots. Here's what MainViewModel.cs looks like now:
View code on GistFirst, note that in the constructor we're now calling a new method InitializeHotspots. If you look at what that does, we're calling Repository.ListHotspots, and setting a new ObservableCollection to the results. This is what the UI uses to populate the Grinding Hotspot dropdown on the Overview tab. So the first thing the bot does is queries the Azure SQL database to find all the existing hotspots, then populates the UI with the results. It will also initialize the dropdown with whatever value you have saved in botSettings.json as the GrindingHotspotId. You'll need to add that to BotSettings.cs as well (just like the connection string earlier).
Next, we have four new commands - StartRecordingHotspot, AddHotspotWaypoint, SaveHotspot, and CancelHotspot. To record a new Hotspot, first click the Start button, then run around and click Add wherever you want it to save a new waypoint. When you're done, click Save, and it'll save the new Hotspot in the database. If you want to start over without saving, click Cancel. If you test this, and then take a look at the new record in SQL, you'll see the Waypoints column contains a bunch of 3d coordinates serialized as JSON. The code will handle serializing/deserializing this as a Position[] for the bot to consume in its navigation code.
You'll also notice we have some logic that determines whether to enable or disable some controls in the UI. This is just good UI design, but it's not entirely necessary. For example, you shouldn't be able to save a new Hotspot until you add at least one waypoint. Feel free to add additional logic to enable/disable other buttons or inputs in the UI as you see fit.
We also need to add a new property to the ObjectManager class that will read the player's current Zone from memory. Here's that code:
... const int ZONE_TEXT_PTR_ADDR = 0xB4B404; static public string Zone { // this is weird and throws an exception right after entering world, // so we catch and ignore the exception to avoid console noise get { try { var ptr = MemoryManager.ReadIntPtr((IntPtr)ZONE_TEXT_PTR_ADDR); return MemoryManager.ReadString(ptr); } catch (Exception) { return ""; } } } ...
You also need to update Position.cs to make sure Newtonsoft.Json knows how to deserialize this type by adding an annotation to the constructor:
[JsonConstructor] internal Position(float x, float y, float z) { X = x; Y = y; Z = z; }
At this point, you should be able to record new Hotspots and see them persisted in the Azure SQL database. You should also be able to restart the bot and see all the Hotspots you have previously created in the dropdown on the Overview tab. To wrap things up, we're going to update some of the bot's primary logic to USE the currently selected Grinding hotspot.
First, update the signature of the Start method in IBot.cs to look like so:
void Start(string food, Hotspot grindingHotspot)
And then update the Start method in WarriorBot.cs to look like so:
public void Start(string food, Hotspot grindingHotspot) { running = true; botStates.Push(new GrindState(botStates, food, grindingHotspot)); StartInternal(); }
Then update GrindState.cs like so:
using BloogBot; using BloogBot.AI.SharedStates; using BloogBot.Game; using BloogBot.Game.Enums; using BloogBot.Game.Objects; using System; using System.Collections.Generic; using System.Linq; namespace WarriorBot { class GrindState : IBotState { static readonly Random random = new Random(); readonly Stack<IBotState< botStates; readonly LocalPlayer player; readonly string food; readonly Hotspot grindingHotspot; internal GrindState(Stack<IBotState> botStates, string food, Hotspot grindingHotspot) { this.botStates = botStates; this.food = food; this.grindingHotspot = grindingHotspot; player = ObjectManager.Player; } public void Update() { var newTarget = ObjectManager .Units .FirstOrDefault(u => u.TargetGuid == ObjectManager.Player.Guid) ?? ObjectManager .Units .Where(u => u.Health > 0) .Where(u => u.CreatureType != CreatureType.Critter && u.CreatureType != CreatureType.NotSpecified && u.CreatureType != CreatureType.Totem) .Where(u => u.UnitReaction(player.Pointer) == UnitReaction.Hostile || u.UnitReaction(player.Pointer) == UnitReaction.Unfriendly || u.UnitReaction(player.Pointer) == UnitReaction.Neutral) .Where(u => u.Level <= player.Level + 2 && u.Level >= player.Level - 4) .OrderBy(u => u.Position.DistanceTo(player.Position)) .FirstOrDefault(); if (newTarget != null) { player.SetTarget(newTarget.Guid); botStates.Push(new MoveToTargetState(botStates, newTarget, food)); } else { var waypointCount = grindingHotspot.Waypoints.Length; var waypoint = grindingHotspot.Waypoints[random.Next(0, waypointCount)]; botStates.Push(new MoveToHotspotWaypointState(botStates, waypoint)); } } } }
Notice the new else
block - if the target is not null, we move to the target and fight like before. However, if the target IS null (meaning there are no valid targets nearby) - we push a new MoveToHotspotWaypointState onto the stack. And the target we move to is a randomly chosen waypoint from the currently selected Hotspot (as chosen by the Grinding Hotspot dropdown in the UI). The code for that new state is as follows:
using BloogBot.Game; using BloogBot.Game.Objects; using System.Collections.Generic; using WarriorBot; namespace BloogBot.AI.SharedStates { public class MoveToHotspotWaypointState : IBotState { readonly Stack<IBotState> botStates; readonly Position destination; readonly LocalPlayer player; public MoveToHotspotWaypointState(Stack<IBotState> botStates, Position destination) { this.botStates = botStates; this.destination = destination; player = ObjectManager.Player; } public void Update() { if (player.Position.DistanceTo(destination) < 3) { botStates.Pop(); return; } var nextWaypoint = Navigation.GetNextWaypoint(player.MapId, player.Position, destination, false); player.ClickToMove(nextWaypoint); } } }
This state is pretty straightforward - the bot will navigate to the waypoint, then pop itself off the stack, returning to the previous state (which will be the GrindState in this case). This is nice because if the bot can't find a target, he'll move to one of the waypoints you've specified in your hotspot, which presumably will have more targets nearby to fight. Put simply, your bot will spend less time standing around waiting for respawns this way. And it's important that the waypoint is randomly selected to avoid the bot running in a very obvious pattern from point a to point b to point c every time.
Last but not least, update the code in MainViewModel.cs that calls into WarriorBot.Start to make sure we pass the currently selected Hotspot in:
... void Start() => currentBot.Start(Food, GrindingHotspot); ...
The new code in this chapter has some flaws, however. Right now, we only have the "food" and "grinding hotspot" settings that need to be passed to the Start method. But we're going to add a lot more over time, and we don't want to constantly have to update the method signature of the Start method, as well as the constructors for all of the new states that we'll be adding. So in the next chapter, we're going to introduce a solution to this problem that will allow our bot and its states to automatically have access to any new settings we create. Additionally, when the bot can't find a target in GrindState, it shouldn't have to walk all the way to the waypoint before it starts looking for a target - it should be smart enough to look for a target WHILE he's moving to the waypoint, and pop back to the Grind state as soon as he finds one. We will solve this problem in the next chapter as well.